mirror of
https://github.com/morluto/rea.git
synced 2026-10-18 20:10:13 -05:00
fix(analysis): gate document selection fallback and completion
This commit is contained in:
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"]);
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user