diff --git a/src/application/AnalysisContextQueries.test.ts b/src/application/AnalysisContextQueries.test.ts index ef384e935..41ccaab44 100644 --- a/src/application/AnalysisContextQueries.test.ts +++ b/src/application/AnalysisContextQueries.test.ts @@ -259,3 +259,24 @@ describe("analysis context queries: address facets", () => { }); }); }); + +it("does not start address facets after document selection completes under cancellation", async () => { + const controller = new AbortController(); + const reason = { request: "cancelled after document selection" }; + const calls: string[] = []; + const result = await inspectAddressContext( + { + execute: async (operation) => { + calls.push(operation); + controller.abort(reason); + return ok(createAnalysisExecution("fixture", provider)); + }, + }, + { address: "0x401000" }, + controller.signal, + ); + if (result.ok) throw new Error("expected cancelled selection"); + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(reason); + expect(calls).toEqual(["current_document"]); +}); diff --git a/src/application/AnalysisDocument.test.ts b/src/application/AnalysisDocument.test.ts new file mode 100644 index 000000000..e60ecafc1 --- /dev/null +++ b/src/application/AnalysisDocument.test.ts @@ -0,0 +1,203 @@ +import { expect, it } from "vitest"; +import { resolveAnalysisDocument } from "./AnalysisDocument.js"; +import { + createAnalysisExecution, + type AnalysisOperationPort, +} from "./AnalysisProvider.js"; +import { + AnalysisCancelledError, + AnalysisCapabilityUnavailableError, + AnalysisOutputError, +} from "../domain/analysisErrorCore.js"; +import { parseEvidence } from "../domain/evidence.js"; +import { err, ok } from "../domain/result.js"; + +const provider = { + id: "controlled", + name: "Controlled document provider", + version: "1", +}; +const unsupported = () => + new AnalysisCapabilityUnavailableError( + "controlled", + "current_document", + "No current UI selection", + ); + +it.each([ + null, + "caller stopped", + { request: "opaque" }, + new AnalysisCapabilityUnavailableError( + "caller", + "caller_operation", + "Caller-owned cleanup", + { + cleanup: { + reason: "caller resource not released", + resources: ["caller resource"], + }, + }, + ), +])("retains the exact admission cancellation reason: %j", async (reason) => { + const controller = new AbortController(); + controller.abort(reason); + const result = await resolveAnalysisDocument( + { + execute: async () => { + throw new Error("must not query provider"); + }, + }, + undefined, + controller.signal, + ); + if (result.ok) throw new Error("expected cancelled admission"); + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(reason); + expect(result.error.cleanupIncomplete).toBe(false); + if (reason instanceof AnalysisCapabilityUnavailableError) + expect(reason.cleanupIncomplete).toBe(true); +}); + +it.each([false, true])( + "does not fall back from an unavailable selection with incomplete cleanup (cancelled: %s)", + async (aborted) => { + const controller = new AbortController(); + const failure = new AnalysisCapabilityUnavailableError( + "controlled", + "current_document", + "Unavailable while cleanup is pending", + { + cleanup: { + reason: "live provider resource", + resources: ["controlled resource"], + }, + }, + ); + const calls: string[] = []; + const port: AnalysisOperationPort = { + execute: async (operation) => { + calls.push(operation); + if (aborted) controller.abort(null); + return operation === "current_document" + ? err(failure) + : ok(createAnalysisExecution(["selected"], provider)); + }, + }; + const result = await resolveAnalysisDocument( + port, + undefined, + controller.signal, + ); + expect(result).toEqual(err(failure)); + expect(calls).toEqual(["current_document"]); + expect(failure.cleanupIncomplete).toBe(true); + }, +); + +it("does not start fallback after a clean unavailable selection completes under cancellation", async () => { + const controller = new AbortController(); + const reason = { request: "cancel before fallback" }; + const calls: string[] = []; + const result = await resolveAnalysisDocument( + { + execute: async (operation) => { + calls.push(operation); + controller.abort(reason); + return operation === "current_document" + ? err(unsupported()) + : ok(createAnalysisExecution(["selected"], provider)); + }, + }, + undefined, + controller.signal, + ); + if (result.ok) throw new Error("expected cancelled fallback admission"); + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(reason); + expect(calls).toEqual(["current_document"]); +}); + +it.each(["current_document", "list_documents"] as const)( + "retains validated %s observations on late cancellation", + async (completedOperation) => { + const controller = new AbortController(); + const reason = { request: "late selection" }; + const result = await resolveAnalysisDocument( + { + execute: async (operation) => { + if (operation !== completedOperation) return err(unsupported()); + controller.abort(reason); + return ok( + createAnalysisExecution( + operation === "current_document" ? "selected" : ["selected"], + provider, + { + rawResult: { selected_source: "controlled" }, + limitations: ["controlled provider only"], + subject: { + path: "/selected/image", + sha256: "a".repeat(64), + format: "pe", + }, + locations: [{ kind: "artifact-path", path: "/selected/image" }], + }, + ), + ); + }, + }, + undefined, + controller.signal, + ); + if (result.ok) throw new Error("expected cancelled completion"); + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(reason); + expect(parseEvidence(result.error.partialObservation)).toMatchObject({ + operation: completedOperation, + parameters: {}, + provider, + confidence: "observed", + normalized_result: + completedOperation === "current_document" ? "selected" : ["selected"], + raw_result: { selected_source: "controlled" }, + limitations: ["controlled provider only"], + subject: { local_path: "/selected/image" }, + locations: [{ kind: "artifact-path", path: "/selected/image" }], + }); + }, +); + +it.each(["current_document", "list_documents"] as const)( + "validates malformed %s before late cancellation", + async (completedOperation) => { + const controller = new AbortController(); + const result = await resolveAnalysisDocument( + { + execute: async (operation) => { + if (operation !== completedOperation) return err(unsupported()); + controller.abort(null); + return ok(createAnalysisExecution(null, provider)); + }, + }, + undefined, + controller.signal, + ); + if (result.ok) throw new Error("expected output refusal"); + expect(result.error).toBeInstanceOf(AnalysisOutputError); + expect(result.error.partialObservation).toBeUndefined(); + }, +); + +it("preserves an explicit document without provider selection", async () => { + await expect( + resolveAnalysisDocument( + { + execute: async () => { + throw new Error("must not query"); + }, + }, + "caller selection", + undefined, + ), + ).resolves.toEqual(ok("caller selection")); +}); diff --git a/src/application/AnalysisDocument.ts b/src/application/AnalysisDocument.ts index 22f4b565f..4c1e490f1 100644 --- a/src/application/AnalysisDocument.ts +++ b/src/application/AnalysisDocument.ts @@ -1,4 +1,8 @@ -import type { AnalysisOperationPort } from "./AnalysisProvider.js"; +import { + createAnalysisExecutionEvidence, + type AnalysisExecution, + type AnalysisOperationPort, +} from "./AnalysisProvider.js"; import { AnalysisCancelledError, AnalysisCapabilityUnavailableError, @@ -19,15 +23,26 @@ export const resolveAnalysisDocument = async ( ): Promise> => { if (document !== undefined) return ok(document); if (signal?.aborted === true) - return err(new AnalysisCancelledError("current_document")); + return err( + new AnalysisCancelledError("current_document", { cause: signal.reason }), + ); const current = await analysis.execute( "current_document", {}, executionOptions(signal), ); if (!current.ok) { - if (!(current.error instanceof AnalysisCapabilityUnavailableError)) + if ( + !(current.error instanceof AnalysisCapabilityUnavailableError) || + current.error.cleanupIncomplete + ) return current; + if (signal?.aborted) + return err( + new AnalysisCancelledError("current_document", { + cause: signal.reason, + }), + ); const listed = await analysis.execute( "list_documents", {}, @@ -58,11 +73,21 @@ export const resolveAnalysisDocument = async ( "provider returned no document identity", ), ); - return ok(documentName); + return completeSelection( + documentName, + "list_documents", + listed.value, + signal, + ); } return typeof current.value.result === "string" && current.value.result.length > 0 - ? ok(current.value.result) + ? completeSelection( + current.value.result, + "current_document", + current.value, + signal, + ) : err( new AnalysisOutputError( "current_document", @@ -70,3 +95,25 @@ export const resolveAnalysisDocument = async ( ), ); }; + +/** Preserve the completed provider observation after validating document identity. */ +const completeSelection = ( + document: string, + operation: "current_document" | "list_documents", + execution: AnalysisExecution, + signal: AbortSignal | undefined, +): Result => { + if (signal?.aborted) + return err( + new AnalysisCancelledError(operation, { + cause: signal.reason, + partialObservation: createAnalysisExecutionEvidence( + operation, + {}, + execution, + "observed", + ), + }), + ); + return ok(document); +}; diff --git a/tests/boundary/mcp/enhancedTools.04-uses-complete-inventories-inline.test.ts b/tests/boundary/mcp/enhancedTools.04-uses-complete-inventories-inline.test.ts index 7b51f9435..b400d9a4f 100644 --- a/tests/boundary/mcp/enhancedTools.04-uses-complete-inventories-inline.test.ts +++ b/tests/boundary/mcp/enhancedTools.04-uses-complete-inventories-inline.test.ts @@ -443,3 +443,37 @@ describe("overview document identity through MCP", () => { }); }); }); + +it("refuses document fallback while the current-selection owner reports incomplete cleanup", async () => { + const failure = new AnalysisCapabilityUnavailableError( + "fixture", + "current_document", + "Selection unavailable during cleanup", + { + cleanup: { + reason: "controlled live selection resource", + resources: ["selection owner"], + }, + }, + ); + const calls: string[] = []; + const client = await connect({ + execute: async (operation) => { + calls.push(operation); + return operation === "current_document" + ? err(failure) + : ok(["must-not-be-selected"]); + }, + }); + const response = await client.callTool({ + name: "binary_overview", + arguments: {}, + }); + expect(response.isError).toBe(true); + expect(parseMcpToolError(response).error).toMatchObject({ + code: "cleanup_incomplete", + details: { operation: "current_document" }, + }); + expect(calls).toEqual(["current_document"]); + expect(failure.cleanupIncomplete).toBe(true); +});