feat(mcp): add batch summaries with read-only detail retrieval

Add opt-in response_detail=summary with step and change counts, compact state, warnings, and actionable errors or prompts. Return an ocs_read batch_result request for full cached details without resuming mutations.

Keep compact/full behavior and idempotent batch retries intact. Preserve active-step context and report unavailable cached results explicitly. Document cache scope and retention; update schemas and digest.

Validation: 47 MCP unit tests, live stdio smoke test, rustfmt, and diff checks passed.
This commit is contained in:
Karim Jerbi committed 2026-10-12 09:39:54 +01:00
1 parent f4b33d5cde
commit 7b68d274ad
3 files changed
+322 -10

No files matched your search

+16
View File
@@ -191,6 +191,22 @@ Use `batch` when the steps are already known. OCS supplies each step with the st
Execute responses use `response_detail: "compact"` by default and return only the state needed for the next edit. Use `changed_entities` to receive the current geometry of affected handles in the same response, or `full` when the complete editor state is needed. Execute responses use `response_detail: "compact"` by default and return only the state needed for the next edit. Use `changed_entities` to receive the current geometry of affected handles in the same response, or `full` when the complete editor state is needed.
For large batches, use `response_detail: "summary"`. It omits the full step
results and change list, returning step counts, `failed_steps`, `change_count`,
compact final state and a `details` tool request. `change_count` counts change
records, not unique entities. Warnings, failed or waiting-input steps in
`attention`, and any `active_step` are retained so the next action remains clear.
For non-batch operations, `summary` behaves like `compact`.
To inspect the retained full result, call the tool and arguments in `details`:
`ocs_read` with the same `ocs_session_id`, `op: "batch_result"`, and
`parameters: {"request_id": "<original batch request_id>"}`. This reads a cached
snapshot without executing or resuming any step. Results are retained in the
originating MCP connection, for up to 64 batches per session; after eviction or
reconnection the read fails explicitly. Do not replay a mutation merely to
retrieve expired details. A `running` snapshot is not completion; resume work
through the existing batch retry/task workflow.
`ocs_read` query accepts exact handles, type/layer filters, field projection, world-XY bounds, nearest-curve ranking, closed-curve containment, and exact intersections between two planar curves. `detail: "full"` adds the complete serialized entity properties. Nearest points, containment, curve length, area, and intersections are calculated by the geometry kernel: `ocs_read` query accepts exact handles, type/layer filters, field projection, world-XY bounds, nearest-curve ranking, closed-curve containment, and exact intersections between two planar curves. `detail: "full"` adds the complete serialized entity properties. Nearest points, containment, curve length, area, and intersections are calculated by the geometry kernel:
```json ```json
+11
View File
@@ -51,6 +51,8 @@ def legacy(server: Path) -> None:
definitions = {tool["name"]: tool for tool in tools["result"]["tools"]} definitions = {tool["name"]: tool for tool in tools["result"]["tools"]}
names = set(definitions) names = set(definitions)
assert names == TOOLS, names assert names == TOOLS, names
assert "summary" in definitions["ocs_execute"]["inputSchema"]["properties"]["response_detail"]["enum"]
assert "batch_result" in definitions["ocs_read"]["inputSchema"]["properties"]["op"]["enum"]
execute_request = definitions["ocs_execute"]["inputSchema"]["properties"]["request"] execute_request = definitions["ocs_execute"]["inputSchema"]["properties"]["request"]
variants = execute_request.get("anyOf") or execute_request.get("oneOf") variants = execute_request.get("anyOf") or execute_request.get("oneOf")
# Resolve local references from the complete tool input schema. # Resolve local references from the complete tool input schema.
@@ -103,6 +105,15 @@ def legacy(server: Path) -> None:
"params": {"name": "ocs_sessions", "arguments": {"launch_if_none": False}}, "params": {"name": "ocs_sessions", "arguments": {"launch_if_none": False}},
}) })
assert not sessions["result"].get("isError"), sessions assert not sessions["result"].get("isError"), sessions
missing_batch = request(process, {
"jsonrpc": "2.0", "id": 4, "method": "tools/call",
"params": {"name": "ocs_read", "arguments": {
"ocs_session_id": "missing", "op": "batch_result",
"parameters": {"request_id": "expired-batch"},
}},
})
assert missing_batch["result"]["isError"] is True
assert "absent or expired" in missing_batch["result"]["content"][0]["text"]
close(process) close(process)
+295 -10
View File
@@ -31,7 +31,7 @@ const MODERN_PROTOCOL_VERSION: &str = "2026-07-28";
/// ///
/// `block_delete` gained its optional `force` parameter. /// `block_delete` gained its optional `force` parameter.
#[cfg(test)] #[cfg(test)]
const TOOL_SCHEMA_DIGEST: &str = "d597165afb46e8f067a77a3b827851b49be99d4739b15cf939b3be5b6c780bb1"; const TOOL_SCHEMA_DIGEST: &str = "634a75872d56ae4445dbd9f27479b3ff3d8c8e88c72db69a699fafa3d9f29b72";
const MAX_REQUEST: usize = 1_048_576; const MAX_REQUEST: usize = 1_048_576;
const MAX_RESPONSE: u64 = 16 * 1024 * 1024; const MAX_RESPONSE: u64 = 16 * 1024 * 1024;
const CACHE_TTL_MS: u64 = 3_600_000; const CACHE_TTL_MS: u64 = 3_600_000;
@@ -59,6 +59,7 @@ const READ_OPS: &[&str] = &[
"commands", "commands",
"events", "events",
"operation", "operation",
"batch_result",
"xdata_get", "xdata_get",
"audit", "audit",
"text_search", "text_search",
@@ -384,6 +385,7 @@ struct BatchExecution {
steps: Vec<Value>, steps: Vec<Value>,
next: usize, next: usize,
active: Option<String>, active: Option<String>,
pending: Option<Value>,
results: Vec<Value>, results: Vec<Value>,
changes: Vec<Value>, changes: Vec<Value>,
state: Option<Value>, state: Option<Value>,
@@ -1230,6 +1232,7 @@ impl GuiClient {
steps, steps,
next: 0, next: 0,
active: None, active: None,
pending: None,
results: Vec::new(), results: Vec::new(),
changes: Vec::new(), changes: Vec::new(),
state: None, state: None,
@@ -1330,8 +1333,19 @@ impl GuiClient {
} }
}; };
// Keep the latest command/prompt state even while a step is running.
if let Some(state) = response.get("state") {
batch.state = Some(state.clone());
}
if matches!(response["status"].as_str(), Some("accepted" | "running")) { if matches!(response["status"].as_str(), Some("accepted" | "running")) {
batch.active = Some(step_id); batch.active = Some(step_id);
let mut pending = response.clone();
if let Some(object) = pending.as_object_mut() {
object.remove("state");
object.insert("step".into(), Value::from(batch.next));
object.insert("op".into(), batch.steps[batch.next]["op"].clone());
}
batch.pending = Some(pending);
let result = batch_result(&batch, "running", true); let result = batch_result(&batch, "running", true);
self.batches.push_back(batch); self.batches.push_back(batch);
trim_batches(&mut self.batches); trim_batches(&mut self.batches);
@@ -1339,9 +1353,7 @@ impl GuiClient {
} }
batch.active = None; batch.active = None;
if let Some(state) = response.get("state") { batch.pending = None;
batch.state = Some(state.clone());
}
if let Some(changes) = response["changes"].as_array() { if let Some(changes) = response["changes"].as_array() {
batch batch
.changes .changes
@@ -1403,7 +1415,7 @@ fn batch_step_id(id: &str, step: usize) -> String {
} }
fn batch_result(batch: &BatchExecution, status: &str, ok: bool) -> Value { fn batch_result(batch: &BatchExecution, status: &str, ok: bool) -> Value {
json!({ let mut result = json!({
"ok":ok, "ok":ok,
"status":status, "status":status,
"request_id":batch.id, "request_id":batch.id,
@@ -1413,7 +1425,85 @@ fn batch_result(batch: &BatchExecution, status: &str, ok: bool) -> Value {
"results":batch.results, "results":batch.results,
"changes":batch.changes, "changes":batch.changes,
"state":batch.state "state":batch.state
}) });
if let Some(pending) = &batch.pending {
result["active_step"] = pending.clone();
}
result
}
/// Project only the outgoing response; retained batch results stay lossless.
fn summarize_batch_response(mut response: Value, session_id: &str) -> Value {
let mut attention = Vec::new();
let mut failed_steps = 0;
if let Some(results) = response["results"].as_array() {
for (index, step) in results.iter().enumerate() {
let failed = step["ok"].as_bool() == Some(false)
|| matches!(step["status"].as_str(), Some("failed" | "cancelled"));
if failed {
failed_steps += 1;
}
let awaiting_input = response["status"] == "waiting_input"
&& index + 1 == results.len()
&& step["status"] == "waiting_input";
if failed || awaiting_input {
// Error and interactive payloads may carry recovery guidance.
attention.push(step.clone());
} else if step
.get("warnings")
.is_some_and(|v| !v.is_null() && v != &json!([]))
{
attention.push(json!({
"step":step["step"], "op":step["op"],
"status":step["status"], "warnings":step["warnings"]
}));
}
}
}
let change_count = response["changes"].as_array().map_or(0, Vec::len);
let request_id = response["request_id"].clone();
if let Some(object) = response.as_object_mut() {
object.remove("results");
object.remove("changes");
object.insert("change_count".into(), json!(change_count));
object.insert("failed_steps".into(), json!(failed_steps));
object.insert("attention".into(), json!(attention));
object.insert(
"details".into(),
json!({
"tool":"ocs_read", "arguments":{
"ocs_session_id":session_id, "op":"batch_result",
"parameters":{"request_id":request_id}
}
}),
);
if let Some(state) = object.get_mut("state") {
*state = compact_state(state);
}
}
response
}
fn cached_batch_result(
clients: &HashMap<String, GuiClient>,
session_id: &str,
request_id: &str,
) -> Result<Value, CallError> {
// Do not connect, poll, resume or recreate a batch on this read-only path.
let batch = clients.get(session_id)
.and_then(|gui| gui.batches.iter().find(|batch| batch.id == request_id))
.ok_or_else(|| CallError::Tool(
"Batch result absent or expired in this MCP connection; do not replay a mutation to retrieve it".into()
))?;
let mut result = batch
.terminal
.clone()
.unwrap_or_else(|| batch_result(batch, "running", true));
let validation = validate_execute_request(&batch.request, "batch")?;
if !validation.warnings.is_empty() {
result["warnings"] = json!(validation.warnings);
}
Ok(result)
} }
fn trim_batches(batches: &mut VecDeque<BatchExecution>) { fn trim_batches(batches: &mut VecDeque<BatchExecution>) {
@@ -1853,6 +1943,10 @@ fn call_tool(
if !READ_OPS.contains(&op) { if !READ_OPS.contains(&op) {
return Err("Use ocs_execute for mutations".to_string().into()); return Err("Use ocs_execute for mutations".to_string().into());
} }
if op == "batch_result" {
let request_id = required_string(&arguments["parameters"], "request_id")?;
return cached_batch_result(clients, session_id, request_id);
}
if op == "capture" { if op == "capture" {
// The GUI capture op needs a file path and lives behind the // The GUI capture op needs a file path and lives behind the
// ocs_capture tool; agents hitting the bare read op get // ocs_capture tool; agents hitting the bare read op get
@@ -1901,10 +1995,11 @@ fn call_tool(
return Err("request_id must not exceed 128 bytes".to_string().into()); return Err("request_id must not exceed 128 bytes".to_string().into());
} }
let val_res = validate_execute_request(&request, op)?; let val_res = validate_execute_request(&request, op)?;
let is_batch = op == "batch";
let wait = arguments["wait_seconds"].as_f64().unwrap_or(30.0); let wait = arguments["wait_seconds"].as_f64().unwrap_or(30.0);
let detail = arguments["response_detail"].as_str().unwrap_or("compact"); let detail = arguments["response_detail"].as_str().unwrap_or("compact");
let gui = client(clients, session_id)?; let gui = client(clients, session_id)?;
let mut response = if op == "batch" { let mut response = if is_batch {
gui.execute_batch(request, wait, pump, mcp_id)? gui.execute_batch(request, wait, pump, mcp_id)?
} else { } else {
gui.request(request, wait, pump, mcp_id)? gui.request(request, wait, pump, mcp_id)?
@@ -1917,7 +2012,11 @@ fn call_tool(
); );
} }
} }
shape_execute_response(response, detail, gui, pump).map_err(CallError::from) if is_batch && detail == "summary" {
Ok(summarize_batch_response(response, session_id))
} else {
shape_execute_response(response, detail, gui, pump).map_err(CallError::from)
}
} }
"ocs_capture" => { "ocs_capture" => {
let session_id = required_string(arguments, "ocs_session_id")?; let session_id = required_string(arguments, "ocs_session_id")?;
@@ -2254,7 +2353,7 @@ fn execute_request_schema() -> Value {
// Local references resolve from the tool input schema root, not from request. // Local references resolve from the tool input schema root, not from request.
fn execute_input_schema() -> Value { fn execute_input_schema() -> Value {
let mut schema = json!({"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"request":execute_request_schema(),"wait_seconds":{"type":"number","minimum":0,"maximum":60,"default":30,"description":"Total time to wait for completion before returning."},"response_detail":{"type":"string","enum":["compact","changed_entities","full"],"default":"compact","description":"compact returns only state needed for the next edit; changed_entities also returns current geometry for changed handles; full preserves the complete editor state."}},"required":["ocs_session_id","request"],"additionalProperties":false}); let mut schema = json!({"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"request":execute_request_schema(),"wait_seconds":{"type":"number","minimum":0,"maximum":60,"default":30,"description":"Total time to wait for completion before returning."},"response_detail":{"type":"string","enum":["compact","summary","changed_entities","full"],"default":"compact","description":"compact returns only state needed for the next edit; summary also replaces batch step results and changes with counts, attention items and a read-only details request (non-batch operations use compact); changed_entities also returns current geometry for changed handles; full preserves the complete editor state."}},"required":["ocs_session_id","request"],"additionalProperties":false});
let request = schema["properties"]["request"].as_object_mut().unwrap(); let request = schema["properties"]["request"].as_object_mut().unwrap();
let defs = request.remove("$defs").unwrap(); let defs = request.remove("$defs").unwrap();
let dialect = request.remove("$schema").unwrap(); let dialect = request.remove("$schema").unwrap();
@@ -2300,7 +2399,7 @@ pub(crate) fn tool_definitions() -> Value {
}, },
{ {
"name":"ocs_read", "name":"ocs_read",
"description":"Discover capabilities and record schemas, or read state, complete database records, command manifests, entities, properties, kernel measurements and spatial relationships, history, events or operation status from a live OCS session.", "description":"Discover capabilities and record schemas, or read state, complete database records, command manifests, entities, properties, kernel measurements and spatial relationships, history, events or operation status from a live OCS session. Use batch_result with parameters.request_id to retrieve retained batch details without executing steps; available only in the originating MCP connection, for up to 64 retained batches per session.",
"inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name or record name."},"search":{"type":"string","description":"Case-insensitive command or record-type search."},"find":{"type":"string","description":"Text query string for text_search."},"match_case":{"type":"boolean","default":false,"description":"Case-sensitive text search."},"whole_word":{"type":"boolean","default":false,"description":"Match only whole words."},"ignore_accents":{"type":"boolean","description":"Ignore accents/diacritics in text (defaults to true when match_case is false)."},"scope":{"type":"string","enum":["all","active_space","blocks"],"default":"all","description":"Text search scope."},"system_spellcheck":{"type":"boolean","default":false,"description":"Use native OS spell-checker (Windows, macOS, Linux)."},"language":{"type":"string","description":"Language tag for spell-checker (e.g. 'fr-FR', 'en-US', 'de-DE', 'es-ES')."},"suggest":{"type":"boolean","default":true,"description":"Include suggested corrections for misspelled words."},"check_terms":{"type":"array","items":{"type":"string"},"description":"List of terms or suspect misspellings to flag in text_audit."},"dictionary":{"type":"array","items":{"type":"string"},"description":"Known valid words for text_audit dictionary check."},"pairs":{"type":"array","description":"Find/replace pairs for dry-run simulation in text_audit.","items":{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find","replace"]}},"dry_run_pairs":{"type":"array","description":"Alias for pairs in text_audit.","items":{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find","replace"]}},"document_id":{"type":"integer","minimum":0},"path":{"type":"string","description":"Optional intended output path for audit; extension determines target format."},"target_format":{"type":"string","enum":["dwg","dxf"],"description":"Intended output format for audit."},"target_version":{"type":"string","enum":["R14","2000","2004","2007","2010","2013","2018","AC1014","AC1015","AC1018","AC1021","AC1024","AC1027","AC1032"],"description":"Intended CAD output version for audit."},"collection":{"type":"string","description":"Record collection, all for records, or omit to discover collections and schema types."},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"},"description":"Exact entity or record handles."},"type":{"type":"string","description":"Entity or record type filter; for record_schema, returns its complete type graph and writable field paths."},"layer":{"type":"string","description":"Layer name filter for query."},"detail":{"type":"string","enum":["summary","geometry","full"],"default":"geometry","description":"Entity detail returned by query."},"fields":{"type":"array","items":{"type":"string"},"description":"Return only these entity fields plus handle."},"paths":{"type":"array","items":{"type":"string"},"description":"Project RFC 6901 JSON Pointer paths relative to record.properties."},"where":{"type":"array","description":"All property filters must match.","items":{"type":"object","properties":{"path":{"type":"string"},"op":{"type":"string","enum":["eq","ne","lt","lte","gt","gte","contains","starts_with","ends_with","in","exists","not_exists"],"default":"eq"},"value":{}},"required":["path"],"additionalProperties":false}},"near":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Rank planar curves by exact kernel distance to this world XY point."},"point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"World point whose object snap the snap op reports."},"from":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Base point for perpendicular and tangent snaps (snap op)."},"contains_point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Return closed planar curves containing this world XY point."},"bounds":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"Filter entities whose world XY bounds overlap [min_x,min_y,max_x,max_y]."},"intersections":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":2,"description":"Return exact kernel intersections between two planar curve handles."},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":10000}},"additionalProperties":false}},"required":["ocs_session_id"],"additionalProperties":false}, "inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name or record name."},"search":{"type":"string","description":"Case-insensitive command or record-type search."},"find":{"type":"string","description":"Text query string for text_search."},"match_case":{"type":"boolean","default":false,"description":"Case-sensitive text search."},"whole_word":{"type":"boolean","default":false,"description":"Match only whole words."},"ignore_accents":{"type":"boolean","description":"Ignore accents/diacritics in text (defaults to true when match_case is false)."},"scope":{"type":"string","enum":["all","active_space","blocks"],"default":"all","description":"Text search scope."},"system_spellcheck":{"type":"boolean","default":false,"description":"Use native OS spell-checker (Windows, macOS, Linux)."},"language":{"type":"string","description":"Language tag for spell-checker (e.g. 'fr-FR', 'en-US', 'de-DE', 'es-ES')."},"suggest":{"type":"boolean","default":true,"description":"Include suggested corrections for misspelled words."},"check_terms":{"type":"array","items":{"type":"string"},"description":"List of terms or suspect misspellings to flag in text_audit."},"dictionary":{"type":"array","items":{"type":"string"},"description":"Known valid words for text_audit dictionary check."},"pairs":{"type":"array","description":"Find/replace pairs for dry-run simulation in text_audit.","items":{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find","replace"]}},"dry_run_pairs":{"type":"array","description":"Alias for pairs in text_audit.","items":{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find","replace"]}},"document_id":{"type":"integer","minimum":0},"path":{"type":"string","description":"Optional intended output path for audit; extension determines target format."},"target_format":{"type":"string","enum":["dwg","dxf"],"description":"Intended output format for audit."},"target_version":{"type":"string","enum":["R14","2000","2004","2007","2010","2013","2018","AC1014","AC1015","AC1018","AC1021","AC1024","AC1027","AC1032"],"description":"Intended CAD output version for audit."},"collection":{"type":"string","description":"Record collection, all for records, or omit to discover collections and schema types."},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"},"description":"Exact entity or record handles."},"type":{"type":"string","description":"Entity or record type filter; for record_schema, returns its complete type graph and writable field paths."},"layer":{"type":"string","description":"Layer name filter for query."},"detail":{"type":"string","enum":["summary","geometry","full"],"default":"geometry","description":"Entity detail returned by query."},"fields":{"type":"array","items":{"type":"string"},"description":"Return only these entity fields plus handle."},"paths":{"type":"array","items":{"type":"string"},"description":"Project RFC 6901 JSON Pointer paths relative to record.properties."},"where":{"type":"array","description":"All property filters must match.","items":{"type":"object","properties":{"path":{"type":"string"},"op":{"type":"string","enum":["eq","ne","lt","lte","gt","gte","contains","starts_with","ends_with","in","exists","not_exists"],"default":"eq"},"value":{}},"required":["path"],"additionalProperties":false}},"near":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Rank planar curves by exact kernel distance to this world XY point."},"point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"World point whose object snap the snap op reports."},"from":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Base point for perpendicular and tangent snaps (snap op)."},"contains_point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Return closed planar curves containing this world XY point."},"bounds":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"Filter entities whose world XY bounds overlap [min_x,min_y,max_x,max_y]."},"intersections":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":2,"description":"Return exact kernel intersections between two planar curve handles."},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":10000}},"additionalProperties":false}},"required":["ocs_session_id"],"additionalProperties":false},
"outputSchema":read_output_schema(), "outputSchema":read_output_schema(),
"title":"Read OCS state","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false} "title":"Read OCS state","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}
@@ -4341,6 +4440,192 @@ mod tests {
assert!(after * 100 < before * 70, "expected at least 30% reduction"); assert!(after * 100 < before * 70, "expected at least 30% reduction");
} }
#[test]
fn batch_summary_is_small_and_keeps_actionable_information() {
let full = json!({
"ok":true,"status":"completed","request_id":"b1",
"completed_steps":64,"total_steps":64,"next_step":null,
"results":(0..64).map(|i| json!({
"step":i,"op":"run","ok":true,"status":"completed",
"result":{"payload":"x".repeat(1000)}
})).collect::<Vec<_>>(),
"changes":(0..64).map(|i| json!({"step":i,"handle":format!("{i:X}")})).collect::<Vec<_>>(),
"warnings":["top-level warning"],
"state":{"document_id":7,"revision":65,"selection":[],"command":null,"documents":[{"large":"payload"}]}
});
let summary = summarize_batch_response(full.clone(), "s1");
assert!(summary.get("results").is_none());
assert!(summary.get("changes").is_none());
assert_eq!(summary["change_count"], 64);
assert_eq!(summary["failed_steps"], 0);
assert_eq!(summary["completed_steps"], 64);
assert_eq!(summary["warnings"], full["warnings"]);
assert_eq!(summary["state"]["revision"], 65);
assert!(summary.to_string().len() * 10 < full.to_string().len());
assert_eq!(
summary["details"]["arguments"]["parameters"]["request_id"],
"b1"
);
let mut resolved = full.clone();
resolved["results"] = json!([
{"step":0,"status":"waiting_input"},
{"step":1,"status":"completed"}
]);
assert_eq!(
summarize_batch_response(resolved, "s1")["attention"],
json!([])
);
let early = summarize_batch_response(
json!({
"ok":true,"status":"running","request_id":"early","state":null
}),
"s1",
);
assert!(tool_result(early)["structuredContent"]["state"].is_object());
for status in ["failed", "cancelled", "waiting_input"] {
let step = json!({"step":1,"op":"run","status":status,
"ok":status == "waiting_input", "code":"needs_attention",
"error":"reason", "result":{"input_example":"10,20"}});
let state = json!({"revision":3,"command":{"accepts":["point"],"input_example":"10,20"},"modal":{"title":"Input"}});
let mut detailed = full.clone();
detailed["status"] = json!(status);
detailed["ok"] = json!(status == "waiting_input");
detailed["state"] = state.clone();
detailed["results"] = json!([
{"step":0,"op":"run","ok":true,"status":"completed","warnings":["approximation"],"result":{"big":"x".repeat(1000)}},
step.clone()
]);
let summary = summarize_batch_response(detailed.clone(), "s1");
assert_eq!(summary["ok"], detailed["ok"]);
assert_eq!(summary["status"], status);
assert_eq!(
summary["failed_steps"],
if status == "waiting_input" { 0 } else { 1 }
);
assert_eq!(
summary["attention"][0]["warnings"],
json!(["approximation"])
);
assert!(summary["attention"][0].get("result").is_none());
assert_eq!(summary["attention"][1], step);
assert_eq!(summary["state"], state);
}
}
#[test]
fn batch_details_are_read_only_session_scoped_and_expire() {
let batch = BatchExecution {
id: "b1".into(),
request: json!({"op":"batch","request_id":"b1","steps":[{"op":"run","cmd":"LINE 0,0 1,1"}]}),
steps: vec![json!({"op":"run","cmd":"LINE 0,0 1,1"})],
next: 0,
active: Some("step1".into()),
pending: Some(
json!({"step":0,"op":"run","status":"running","result":{"prompt":"Pick point"},"warnings":["notice"]}),
),
results: vec![],
changes: vec![],
state: Some(json!({"revision":4,"command":{"accepts":["point"]}})),
terminal: None,
};
let running = batch_result(&batch, "running", true);
let summary = summarize_batch_response(running.clone(), "s1");
assert_eq!(summary["active_step"], running["active_step"]);
assert_eq!(summary["state"]["command"]["accepts"], json!(["point"]));
let mut clients = HashMap::new();
clients.insert(
"s1".into(),
GuiClient {
descriptor: Descriptor {
session_id: "s1".into(),
port: 0,
token: String::new(),
pid: None,
},
state: json!({}),
client_id: "test".into(),
batches: VecDeque::new(),
inflight: HashMap::new(),
dismissed: VecDeque::new(),
},
);
clients.get_mut("s1").unwrap().batches.push_back(batch);
assert_eq!(cached_batch_result(&clients, "s1", "b1").unwrap(), running);
assert_eq!(clients["s1"].batches[0].next, 0);
let mut batch = clients.get_mut("s1").unwrap().batches.pop_front().unwrap();
batch.next = 1;
batch.active = None;
batch.pending = None;
batch.results = vec![
json!({"step":0,"op":"run","ok":true,"status":"completed","result":{"handles":["A"]}}),
];
let full = batch_result(&batch, "completed", true);
for detail in ["compact", "full"] {
let response = shape_execute_response(
full.clone(),
detail,
clients.get_mut("s1").unwrap(),
&mut detached_pump(),
)
.unwrap();
assert_eq!(response["results"], full["results"]);
assert_eq!(response["changes"], full["changes"]);
}
batch.terminal = Some(full.clone());
clients.get_mut("s1").unwrap().batches.push_back(batch);
let summary = summarize_batch_response(full.clone(), "s1");
let read = |clients: &mut HashMap<String, GuiClient>, args: &Value| {
call_tool(
"ocs_read",
args,
clients,
&mut ResourceStore::default(),
&mut detached_pump(),
None,
)
};
assert_eq!(
read(&mut clients, &summary["details"]["arguments"]).unwrap(),
full
);
assert_eq!(clients["s1"].batches[0].terminal, Some(full.clone()));
let mut wrong_session = summary["details"]["arguments"].clone();
wrong_session["ocs_session_id"] = json!("s2");
assert!(read(&mut clients, &wrong_session).is_err());
assert_eq!(clients.len(), 1);
// Idempotent replay still returns the complete cached result.
let request = clients["s1"].batches[0].request.clone();
assert_eq!(
clients
.get_mut("s1")
.unwrap()
.execute_batch(request, 0.0, &mut detached_pump(), None)
.unwrap(),
full
);
for i in 0..64 {
clients
.get_mut("s1")
.unwrap()
.batches
.push_back(BatchExecution {
id: format!("other-{i}"),
request: json!({}),
steps: vec![],
next: 0,
active: None,
pending: None,
results: vec![],
changes: vec![],
state: None,
terminal: None,
});
}
trim_batches(&mut clients.get_mut("s1").unwrap().batches);
assert!(read(&mut clients, &summary["details"]["arguments"]).is_err());
}
#[test] #[test]
fn schema_size_and_defs_structure() { fn schema_size_and_defs_structure() {
let schema = execute_request_schema(); let schema = execute_request_schema();