diff --git a/bridge/cutter/rea_bridge.py b/bridge/cutter/rea_bridge.py index 931b032d3c..f8f7a833d4 100644 --- a/bridge/cutter/rea_bridge.py +++ b/bridge/cutter/rea_bridge.py @@ -556,6 +556,8 @@ class _RequestDispatcher(QObject): "ok": False, "error": "active-document-generation-changed", "execution_state": "not_started", + "document_generation": self.generation, + "output_truncated": False, }) self._record_command_completion(request_id, "not_started") request["_event"].set() @@ -566,6 +568,8 @@ class _RequestDispatcher(QObject): "ok": False, "error": "invalid-command", "execution_state": "not_started", + "document_generation": self.generation, + "output_truncated": False, }) self._record_command_completion(request_id, "not_started") request["_event"].set() @@ -587,6 +591,7 @@ class _RequestDispatcher(QObject): "cutter_version": self.cutter_version, "identity_status": "partial", "execution_state": "complete", + "output_truncated": False, }) self._record_command_completion(request_id, "complete") else: @@ -598,6 +603,7 @@ class _RequestDispatcher(QObject): "cutter_version": self.cutter_version, "identity_status": "partial", "execution_state": "unknown", + "output_truncated": False, "message": "The command raised an exception; it may have produced partial effects. Do not retry automatically.", }) self._record_command_completion(request_id, "failed") diff --git a/docs/reverse-engineering-tools.md b/docs/reverse-engineering-tools.md index 8f1103f654..63a67ab3b8 100644 --- a/docs/reverse-engineering-tools.md +++ b/docs/reverse-engineering-tools.md @@ -312,6 +312,11 @@ At most 8 connection threads run. A further accept is closed, and a failure to s A command reply that does not fit keeps its completion state and document generation, and reports an oversized path as unknown. Cutter response frames use this same ceiling and also exclude the terminating LF. A command response the client discards because it exceeds that ceiling stays an unknown execution and sets `output_truncated`, so Evidence records the omitted output. Hopper response reads are unchanged. +`cutter_command` reports `output_truncated` and `document_generation` as `null` +when the plugin did not declare them, for example when no reply was read, the +reply was malformed, or an older plugin omitted them. A null generation means +the document may have changed; list sessions again before choosing an +`expected_generation`. Evidence carries a limitation for each unknown field. Each bridge request waits 25000 ms. The Cutter hello frame still must arrive within 1 second. The authenticated command frame is read with one second per mebibyte of `REA_BRIDGE_MAX_REQUEST_BYTES`, and that read never waits longer diff --git a/src/contracts/cutterToolContracts.ts b/src/contracts/cutterToolContracts.ts index 3a31524006..fa574950af 100644 --- a/src/contracts/cutterToolContracts.ts +++ b/src/contracts/cutterToolContracts.ts @@ -28,10 +28,12 @@ export const cutterCommandResultSchema = z.strictObject({ execution_state: z.enum(["not_started", "complete", "failed", "unknown"]), error: z.string().nullable(), message: z.string().nullable(), - output_truncated: z.boolean(), + /** Null when the bridge did not declare whether output was omitted. */ + output_truncated: z.boolean().nullable(), cutter_version: z.string().nullable(), current_file: z.string().nullable(), - document_generation: z.int().nonnegative(), + /** Null when the bridge did not report the generation after the command. */ + document_generation: z.int().nonnegative().nullable(), identity_status: z.literal("partial"), }); diff --git a/src/cutter/CutterBridgeClient.ts b/src/cutter/CutterBridgeClient.ts index 148ce69c72..d3e4beb5e1 100644 --- a/src/cutter/CutterBridgeClient.ts +++ b/src/cutter/CutterBridgeClient.ts @@ -249,13 +249,15 @@ export interface CutterBridgeExecution { readonly requestId: string; readonly output: unknown; readonly currentFile: string | null; - readonly documentGeneration: number; + /** Generation the bridge reported after the command; null when it did not. */ + readonly documentGeneration: number | null; readonly identityStatus: "partial"; readonly cutterVersion: string | null; readonly executionState: CutterBridgeExecutionState; readonly error: string | null; readonly message: string | null; - readonly outputTruncated: boolean; + /** Whether output was omitted; null when the bridge did not declare it. */ + readonly outputTruncated: boolean | null; readonly completionToken?: string; } @@ -547,15 +549,17 @@ export class CutterBridgeClient { requestId, output: null, currentFile: descriptor.current_file, - documentGeneration: input.expectedGeneration, + // No reply was read, so the generation after the command is unknown. + documentGeneration: null, identityStatus: "partial", cutterVersion: descriptor.cutter_version, executionState: "unknown", error: "transport-response-missing", message: `${cause.message}; the command may have completed; do not retry automatically`, - // The carried limit is the framed response ceiling. The command was - // sent, and the discarded response is omitted output. - outputTruncated: cause.limit !== undefined, + // The carried limit is the framed response ceiling: the command was + // sent and its discarded response is omitted output. Any other + // missing reply leaves truncation unknown. + outputTruncated: cause.limit === undefined ? null : true, ...(completionToken === undefined ? {} : { completionToken }), }; } @@ -565,14 +569,14 @@ export class CutterBridgeClient { requestId, output: null, currentFile: descriptor.current_file, - documentGeneration: input.expectedGeneration, + documentGeneration: null, identityStatus: "partial", cutterVersion: descriptor.cutter_version, executionState: "unknown", error: "malformed-bridge-response", message: "Cutter may have completed the command, but its response was invalid; do not retry automatically", - outputTruncated: false, + outputTruncated: null, ...(completionToken === undefined ? {} : { completionToken }), }; const response = parsed.data; @@ -623,14 +627,18 @@ export class CutterBridgeClient { response.current_file === undefined ? descriptor.current_file : response.current_file, - documentGeneration: - response.document_generation ?? input.expectedGeneration, + // An undeclared generation or truncation stays unknown: the command + // may have changed the document, and an omitted flag is not "false". + documentGeneration: response.document_generation ?? null, identityStatus: "partial", cutterVersion: response.cutter_version ?? descriptor.cutter_version, executionState, error: response.ok ? null : (response.error ?? "cutter-command-failed"), message: response.message ?? null, - outputTruncated: response.output_truncated ?? false, + // A command that never started produced no output to omit. + outputTruncated: + response.output_truncated ?? + (executionState === "not_started" ? false : null), ...(completionToken === undefined ? {} : { completionToken }), }; } catch (cause) { diff --git a/src/cutter/CutterBridgeService.ts b/src/cutter/CutterBridgeService.ts index 23ba115a2e..8988df11df 100644 --- a/src/cutter/CutterBridgeService.ts +++ b/src/cutter/CutterBridgeService.ts @@ -200,10 +200,19 @@ export class CutterBridgeService { "Command completion or its output could not be confirmed; do not retry automatically because the command may have produced partial or persistent effects.", ] : []), - ...(output.outputTruncated + ...(output.outputTruncated === true ? [ "The command completed or may have completed, but output exceeded the bridge transport limit and was omitted.", ] + : output.outputTruncated === null + ? [ + "The bridge did not declare whether output was omitted, so the output may be incomplete.", + ] + : []), + ...(output.documentGeneration === null + ? [ + "The bridge did not report the document generation after this command; list Cutter sessions again before selecting an expected_generation.", + ] : []), ], }, diff --git a/tests/boundary/mcp/cutterMcp.test.ts b/tests/boundary/mcp/cutterMcp.test.ts index 1d1cd849e2..76e7c46d05 100644 --- a/tests/boundary/mcp/cutterMcp.test.ts +++ b/tests/boundary/mcp/cutterMcp.test.ts @@ -47,8 +47,16 @@ const assertUncertainEvidence = async ( execution_state: "unknown", error: "transport-response-missing", message: expect.stringContaining("do not retry automatically"), - document_generation: 3, + // No reply was read, so the generation after the command is unknown. + document_generation: null, + output_truncated: null, }); + expect(uncertainEvidence.limitations).toContainEqual( + expect.stringMatching(/did not report the document generation/u), + ); + expect(uncertainEvidence.limitations).toContainEqual( + expect.stringMatching(/did not declare whether output was omitted/u), + ); expect(uncertainEvidence.limitations).toContainEqual( expect.stringMatching( /completion.*could not be confirmed.*not retry.*effects/iu, diff --git a/tests/boundary/providers/CutterBridgeClient.test.ts b/tests/boundary/providers/CutterBridgeClient.test.ts index 03f7a44716..6e7d3a4ae8 100644 --- a/tests/boundary/providers/CutterBridgeClient.test.ts +++ b/tests/boundary/providers/CutterBridgeClient.test.ts @@ -140,7 +140,7 @@ const verifyCutterCancellationAfterDispatch = async (): Promise => { executionState: "unknown", error: "transport-response-missing", message: expect.stringContaining("do not retry automatically"), - documentGeneration: 0, + documentGeneration: null, }); } finally { await new Promise((resolve) => server.close(() => resolve())); @@ -487,7 +487,7 @@ describe("CutterBridgeClient: scenario group 1", () => { executionState: "unknown", error: "transport-response-missing", message: expect.stringContaining("do not retry automatically"), - documentGeneration: 0, + documentGeneration: null, }); }); @@ -809,12 +809,13 @@ describe("CutterBridgeClient: scenario group 4", () => { command: "pd 4", json: true, }); + // An undeclared generation and truncation stay unknown, not inferred. await expect(run()).resolves.toMatchObject({ executionState: "complete", output: null, currentFile: "/tmp/input", - documentGeneration: 8, - outputTruncated: false, + documentGeneration: null, + outputTruncated: null, }); response = { ok: false, diff --git a/tests/fixtures/cutter_bridge_protocol_test.py b/tests/fixtures/cutter_bridge_protocol_test.py index e9face4610..36e4771bd7 100644 --- a/tests/fixtures/cutter_bridge_protocol_test.py +++ b/tests/fixtures/cutter_bridge_protocol_test.py @@ -176,6 +176,8 @@ class ServeDispatcher: "ok": False, "error": "active-document-generation-changed", "execution_state": "not_started", + "document_generation": self.generation, + "output_truncated": False, }) self.completed_commands[request["request_id"]] = "not_started" elif request.get("kind") == "command" and request.get("command") == "fixture-unknown": @@ -194,6 +196,7 @@ class ServeDispatcher: "cutter_version": self.cutter_version, "identity_status": "partial", "execution_state": "complete", + "output_truncated": False, }) self.completed_commands[request["request_id"]] = "complete" self.fail_next_ack = True @@ -206,6 +209,7 @@ class ServeDispatcher: "cutter_version": self.cutter_version, "identity_status": "partial", "execution_state": "complete", + "output_truncated": False, }) self.completed_commands[request["request_id"]] = "complete" self.malformed_next_ack = True @@ -222,6 +226,7 @@ class ServeDispatcher: "cutter_version": self.cutter_version, "identity_status": "partial", "execution_state": "complete", + "output_truncated": False, }) if request.get("kind") == "command": self.completed_commands[request["request_id"]] = "complete" @@ -376,10 +381,13 @@ class DocumentIdentityTest(unittest.TestCase): stale = handle(kind="command", request_id=str(uuid.uuid4()), expected_generation=0, command="iI") self.assertEqual(stale["error"], "active-document-generation-changed") self.assertEqual(stale["execution_state"], "not_started") + self.assertEqual(stale["document_generation"], 1) + self.assertIs(stale["output_truncated"], False) self.assertEqual(commands, []) current = handle(kind="command", request_id=str(uuid.uuid4()), expected_generation=1, command="iI") self.assertEqual(current["output"], "observed") self.assertEqual(current["document_generation"], 1) + self.assertIs(current["output_truncated"], False) self.assertEqual(commands, ["iI"]) def test_absent_or_unreadable_document_config_remains_unknown(self):