fix(analysis): gate document selection fallback and completion

This commit is contained in:
morluto committed 2026-10-18 09:19:37 +08:00
1 parent 94bc7ed322
commit 9df559b7e7
4 files changed
+310 -5

No files matched your search

@@ -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"]);
});
+203
View File
@@ -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"));
});
+52 -5
View File
@@ -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<Result<string, AnalysisError>> => {
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<string, AnalysisError> => {
if (signal?.aborted)
return err(
new AnalysisCancelledError(operation, {
cause: signal.reason,
partialObservation: createAnalysisExecutionEvidence(
operation,
{},
execution,
"observed",
),
}),
);
return ok(document);
};
@@ -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);
});