Files
rea/src/domain/analysisErrorProjection.test.ts
T
pablodiazjorge 02e2c9cd7f fix(providers): report provider-selection diagnostics when the selected provider refuses its profile (#4103)
* fix(providers): report provider-selection diagnostics when the selected provider refuses its profile

An explicitly selected provider that refuses the target during profile resolution now yields the target_unsupported selection error with its candidates and rejections, keeping the refusal's message and, when present, its specific recovery.

Fixes #3930

* fix(providers): keep refusal evidence and cleanup ownership on selection failures

A refusal with unconfirmed cleanup is returned unchanged; otherwise the selection failure carries the refusal's captured output, diagnostics, partial observation and cause. Registry tests cover the copied recovery and the cleanup guard.
2026-10-23 02:31:23 +08:00

1094 lines
35 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
AnalysisCapabilityUnavailableError,
AnalysisUnsupportedTargetError,
AnalysisInputError,
AnalysisOutputError,
AnalysisCancelledError,
AnalysisTimeoutError,
AnalysisResourceConstraintError,
} from "./analysisErrorCore.js";
import { ArtifactOperationError } from "./artifactOperationError.js";
import { BinaryTargetError } from "./configurationErrors.js";
import { BrowserObservationError } from "./browserObservationError.js";
import { EvidenceFileError, EvidenceIntegrityError } from "./evidenceErrors.js";
import {
HopperProcessError,
HopperRemoteError,
HopperStartError,
HopperTimeoutError,
} from "./hopperErrors.js";
import { ProviderAdapterError } from "./providerAdapterError.js";
import { UnknownRegistryError } from "./unknownRegistryError.js";
import { projectAnalysisError } from "./analysisErrorProjection.js";
import { ProviderSelectionError } from "./providerSelectionError.js";
import { analysisErrorProjectionSchema } from "../contracts/errorSchemas.js";
import { nativeCallPartialObservationSchema } from "./native/nativeCallPartialObservation.js";
describe("agent recovery metadata", () => {
it("does not mark deterministic input validation as retryable", () => {
const projected = projectAnalysisError(
new AnalysisInputError("extract_firmware", undefined, [
{ path: ["range", "length"], reason: "missing_argument" },
]),
);
expect(projected).toMatchObject({
code: "invalid_request",
retryable: false,
remediation: {
action: expect.stringContaining("Do not repeat the unchanged call."),
},
});
expect(analysisErrorProjectionSchema.parse(projected)).toEqual(projected);
});
it("keeps retryability for transient conflicts and provider timeouts", () => {
expect(
projectAnalysisError(new UnknownRegistryError("revision-conflict")),
).toMatchObject({ code: "revision_conflict", retryable: true });
expect(
projectAnalysisError(
new AnalysisTimeoutError("inspect_binary_layout", 2500),
),
).toMatchObject({ code: "provider_timeout", retryable: true });
});
it("names the input contract for malformed analysis arguments", () => {
const projected = projectAnalysisError(
new AnalysisInputError("inspect_analysis_view", undefined, [
{
path: ["view", "kind"],
reason: "invalid_value",
expected: ["summary", "module"],
},
]),
);
expect(projected).toMatchObject({
recovery: {
kind: "inspect_input_contract",
operation: "inspect_analysis_view",
},
remediation: {
action:
"Read the declared input schema or CLI usage for inspect_analysis_view; if using MCP and the catalog may be stale, call tools/list. Correct the listed fields, then retry. Do not repeat the unchanged call.",
},
});
expect(analysisErrorProjectionSchema.parse(projected)).toEqual(projected);
});
it("separates timeout-state inspection from client deadline recovery", () => {
const projected = projectAnalysisError(
new AnalysisTimeoutError("inspect_binary_layout", 2_500),
);
expect(projected).toMatchObject({
code: "provider_timeout",
recovery: {
kind: "inspect_timeout_state",
operation: "inspect_binary_layout",
timeout_ms: 2_500,
},
message: expect.stringContaining("server/provider deadline"),
remediation: {
action: expect.stringContaining("provider diagnostics"),
},
});
expect(projected.message).toContain("not a client-side timeout");
expect(projected.remediation.action).not.toContain(
"raise its tool deadline",
);
expect(projected.remediation.action).not.toMatch(
/declared timeout controls|larger server\/provider budget|without assuming adjustable limits|do not assume adjustable/u,
);
expect(analysisErrorProjectionSchema.parse(projected)).toEqual(projected);
});
});
describe("capability failure retry disposition", () => {
it.each([
{ options: {}, retryable: false, category: "unsupported_provider" },
{
options: { retryable: false },
retryable: false,
category: "unsupported_provider",
},
{ options: { retryable: true }, retryable: true, category: "unavailable" },
] as const)(
"preserves the producer's $retryable disposition",
({ options, retryable, category }) => {
const reason = "Native self-test failed with a sharing violation";
const projected = projectAnalysisError(
new AnalysisCapabilityUnavailableError(
"fixture-provider",
"inspect",
reason,
options,
),
);
expect(projected).toMatchObject({
code: "capability_unavailable",
category,
retryable,
details: { reason },
});
if (retryable) {
expect(projected.message).toBe(
"Required runtime controls are unavailable for this attempt.",
);
expect(projected.remediation.action).toContain(
"retry the same analysis",
);
}
expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(
true,
);
},
);
it("preserves a caller-selected message for a retryable failure", () => {
expect(
projectAnalysisError(
new AnalysisCapabilityUnavailableError(
"fixture-provider",
"inspect",
"temporary failure",
{
retryable: true,
userMessage: "Selected runtime diagnostic",
},
),
).message,
).toBe("Selected runtime diagnostic");
});
it("retains cleanup ownership before allowing a transient operation to retry", () => {
expect(
projectAnalysisError(
new AnalysisCapabilityUnavailableError(
"fixture-provider",
"inspect",
"temporary failure",
{
retryable: true,
cleanup: {
reason: "owned handle is still live",
resources: ["native-owner"],
},
},
),
),
).toMatchObject({
code: "cleanup_incomplete",
retryable: false,
details: {
cleanup: "incomplete",
resources: ["native-owner"],
execution_failure: "capability_unavailable",
},
});
});
});
it("projects a published file receipt alongside retained cleanup failure", () => {
const projected = projectAnalysisError(
new EvidenceFileError("write", "io", {
path: "/tmp/evidence.json",
published: { path: "/tmp/evidence.json", bytes: 42 },
cleanup: {
reason: "staging directory is still owned",
resources: ["/tmp/.rea-write-abc"],
},
cause: new Error("cleanup incomplete"),
}),
);
expect(projected).toMatchObject({
code: "cleanup_incomplete",
message:
"The output file was published, but staging cleanup remains incomplete.",
remediation: {
action:
"Use the published file at details.published_path and resolve the reported staging cleanup failure.",
},
details: {
operation: "write",
path: "/tmp/evidence.json",
published: true,
published_path: "/tmp/evidence.json",
bytes: 42,
resources: ["/tmp/.rea-write-abc"],
},
});
expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(true);
});
const nativeCallPartialObservation = nativeCallPartialObservationSchema.parse({
kind: "native-call-observation",
target: {
path: "/tmp/selected-app",
sha256: "a".repeat(64),
architecture: "arm64",
arguments: [],
environment: {},
working_directory: null,
},
process: {
pid: 4242,
stdout: {
text: "partial stdout",
bytes: 13,
truncated: false,
complete: false,
},
stderr: null,
other_stops: [],
},
debugger: { version: null },
events: [],
coverage: { status: "partial", reason: "cancelled" },
limitations: ["Observation ended before the requested window completed."],
});
const retainedOutput = {
stdout: "selected output\u0000",
stderr: "upstream warning",
truncated: true,
};
it("distinguishes unsupported target formats from unavailable provider capabilities", () => {
const projected = projectAnalysisError(
new AnalysisUnsupportedTargetError(
"inspect_evm_interface",
"/selected/runtime.hex",
"EOF-style EF00 container is unsupported.",
{ capturedOutput: retainedOutput },
),
);
expect(projected).toMatchObject({
code: "unsupported_target",
category: "unsupported_target",
retryable: false,
details: {
operation: "inspect_evm_interface",
path: "/selected/runtime.hex",
reason: "EOF-style EF00 container is unsupported.",
captured_output: retainedOutput,
},
});
expect(projected.message).toContain("EF00");
expect(projected.remediation.action).toContain("target format");
expect(projected.remediation.action).not.toContain("doctor");
expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(true);
});
it("keeps a refusing provider's recovery on a target selection failure", () => {
const selection = {
reason: "target_unsupported" as const,
requestedProviderId: "ghidra",
candidateIds: ["ghidra"],
userMessage: "Unsupported target: the configured extension is x86-64 only.",
};
expect(
projectAnalysisError(
new ProviderSelectionError({
...selection,
remediationAction: "Omit the extension setting for this target.",
}),
),
).toMatchObject({
code: "capability_unavailable",
message: selection.userMessage,
remediation: { action: "Omit the extension setting for this target." },
details: { selection_reason: "target_unsupported" },
});
expect(
projectAnalysisError(new ProviderSelectionError(selection)).remediation
.action,
).toBe(selection.userMessage);
});
it.each([false, true])(
"marks occupied capacity retryable only while cleanup is confirmed: %s",
(cleanupIncomplete) => {
const projected = projectAnalysisError(
new AnalysisResourceConstraintError(
"inspect_sqlite_database",
"capacity",
"Inspection slots are occupied",
{ max_concurrent_inspections: 2 },
cleanupIncomplete
? {
cleanup: {
reason: "Snapshot removal denied",
resources: ["/private/snapshot"],
},
}
: undefined,
),
);
expect(projected.retryable).toBe(!cleanupIncomplete);
expect(projected.code).toBe(
cleanupIncomplete ? "cleanup_incomplete" : "resource_constraint",
);
expect(projected.details).toMatchObject({
resource: "capacity",
reported_limits: { max_concurrent_inspections: 2 },
});
expect(projected.remediation.action).toContain("Wait");
expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(
true,
);
},
);
it.each([
["memory", null],
["memory", { address_space_bytes: 67108864 }],
["cpu", null],
["cpu", { cpu_seconds: 1 }],
["file-size", null],
["file-size", { file_size_bytes: 1024 }],
["transport", { result_budget_bytes: 10485760 }],
] as const)(
"projects reported %s constraints with observed or unknown limits: %j",
(resource, limits) => {
const projected = projectAnalysisError(
new AnalysisResourceConstraintError(
"inspect_binary_layout",
resource,
resource === "memory"
? "Memory allocation failed; exact cause unknown."
: "Observed SIGXCPU; exact signal cause unknown.",
limits,
{ capturedOutput: retainedOutput },
),
);
expect(projected).toMatchObject({
code: "resource_constraint",
category: "resource_constraint",
retryable: false,
details: {
resource,
reported_limits: limits,
captured_output: retainedOutput,
},
});
expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(
true,
);
},
);
it.each([
new AnalysisInputError("inspect", { capturedOutput: retainedOutput }),
new AnalysisOutputError("inspect", "invalid reply", {
capturedOutput: retainedOutput,
}),
new AnalysisCapabilityUnavailableError(
"provider",
"inspect",
"unsupported profile",
{ capturedOutput: retainedOutput },
),
new AnalysisCancelledError("inspect", { capturedOutput: retainedOutput }),
new AnalysisTimeoutError("inspect", 30, { capturedOutput: retainedOutput }),
new ProviderSelectionError({
operation: "inspect",
reason: "provider_unavailable",
requestedProviderId: "provider",
candidateIds: [],
capturedOutput: retainedOutput,
}),
])(
"retains captured output without changing the typed primary failure: $._tag",
(failure) => {
expect(projectAnalysisError(failure).details?.captured_output).toEqual(
retainedOutput,
);
},
);
it.each([
new AnalysisCancelledError("observe_native_calls", {
cleanup: {
reason: "target process termination could not be verified",
resources: ["native-target:4242"],
},
}),
new AnalysisTimeoutError("observe_native_calls", 65_000, {
cleanup: {
reason: "target process identity was unavailable",
resources: ["native-target:4242"],
},
}),
])("retains incomplete cleanup for lifecycle failures: $._tag", (failure) => {
expect(projectAnalysisError(failure)).toMatchObject({
code: "cleanup_incomplete",
details: {
cleanup: "incomplete",
cleanup_reason: expect.any(String),
resources: ["native-target:4242"],
},
});
});
it("retains the original failure tag and code when cleanup is incomplete", () => {
const error = new EvidenceIntegrityError(
"The launched native target did not match its selected digest",
{
cleanup: {
reason: "target survivor identity could not be verified",
resources: ["native-target:4242"],
},
},
);
expect(error._tag).toBe("EvidenceIntegrityError");
expect(projectAnalysisError(error)).toMatchObject({
code: "cleanup_incomplete",
category: "integrity_mismatch",
details: {
cleanup: "incomplete",
cleanup_reason: "target survivor identity could not be verified",
resources: ["native-target:4242"],
execution_failure: "evidence_integrity_mismatch",
},
});
});
it("projects native partial observations through healthy cancellation failures", () => {
const projected = projectAnalysisError(
new AnalysisCancelledError("observe_native_calls", {
partialObservation: nativeCallPartialObservation,
}),
);
expect(projected).toMatchObject({
code: "cancelled",
details: {
operation: "observe_native_calls",
cleanup: "complete",
partial_observation: nativeCallPartialObservation,
},
});
expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(true);
});
it("retains native partial observations and cleanup uncertainty on provider failures", () => {
const projected = projectAnalysisError(
new ProviderAdapterError("native-lldb", "observe_native_calls", {
partialObservation: nativeCallPartialObservation,
cleanup: {
reason: "target process termination could not be verified",
resources: ["native-target:4242"],
},
}),
);
expect(projected).toMatchObject({
code: "cleanup_incomplete",
details: {
provider_id: "native-lldb",
operation: "observe_native_calls",
cleanup: "incomplete",
cleanup_reason: "target process termination could not be verified",
resources: ["native-target:4242"],
execution_failure: "execution_failure",
partial_observation: nativeCallPartialObservation,
},
});
expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(true);
});
describe("analysis error projection: browser provider failures", () => {
it("explains the service-worker policy refusal and names its recovery", () => {
const projected = projectAnalysisError(
new BrowserObservationError(
"capture_browser_scenario",
"service_worker_blocked",
),
);
expect(projected).toMatchObject({
code: "target_unavailable",
category: "unavailable",
retryable: false,
message: expect.stringContaining("service-worker policy is block"),
remediation: {
action: expect.stringContaining("environment.service_workers to allow"),
},
details: {
operation: "capture_browser_scenario",
reason: "service_worker_blocked",
},
});
});
it("projects the primary browser failure alongside incomplete cleanup", () => {
const projected = projectAnalysisError(
new BrowserObservationError(
"observe_javascript_runtime",
"cleanup_failed",
{
cause: new AggregateError([
new BrowserObservationError(
"observe_javascript_runtime",
"target_changed",
),
new Error("socket close failed"),
]),
},
),
);
expect(projected).toMatchObject({
code: "cleanup_incomplete",
details: {
operation: "observe_javascript_runtime",
reason: "cleanup_failed",
cleanup: "incomplete",
resources: ["browser_transport"],
primary_reason: "target_changed",
},
});
});
it("keeps producer facts when a browser failure is attributed to another operation", () => {
const partial = { kind: "browser-test-partial" } as never;
const original = new BrowserObservationError(
"inspect_web_page",
"cleanup_failed",
{
detail: "Target page-1 closed while REA released its session.",
remediationAction: "Release session page-1, then retry.",
cleanup: { reason: "socket close failed", resources: ["page-1"] },
diagnostics: { endpoint: "loopback" },
partialObservation: partial,
cause: new AggregateError([
new BrowserObservationError("inspect_web_page", "target_changed"),
new Error("socket close failed"),
]),
},
);
const relabelled = original.forOperation("capture_web_screenshot");
expect(relabelled).toMatchObject({
operation: "capture_web_screenshot",
reason: "cleanup_failed",
cause: original.cause,
diagnostics: { endpoint: "loopback" },
partialObservation: partial,
});
const { details, ...projected } = projectAnalysisError(relabelled);
const { details: originalDetails, ...originalProjected } =
projectAnalysisError(original);
expect(projected).toEqual(originalProjected);
expect(projected).toMatchObject({
code: "cleanup_incomplete",
message: "Target page-1 closed while REA released its session.",
remediation: { action: "Release session page-1, then retry." },
});
expect(details).toEqual({
...originalDetails,
operation: "capture_web_screenshot",
});
expect(details).toMatchObject({
resources: ["page-1"],
primary_reason: "target_changed",
});
});
it("derives default browser guidance for the attributed operation", () => {
const original = new BrowserObservationError(
"inspect_web_page",
"target_not_found",
);
expect(original.forOperation("inspect_web_page")).toBe(original);
const relabelled = original.forOperation("discover_webmcp_tools");
const projected = projectAnalysisError(relabelled);
expect(projected).toMatchObject({
code: "target_unavailable",
message: expect.stringMatching(/^discover_webmcp_tools could not find/),
details: {
operation: "discover_webmcp_tools",
reason: "target_not_found",
},
});
expect(projected.message).not.toContain("inspect_web_page");
expect(
projectAnalysisError(
new BrowserObservationError("inspect_web_page", "timeout").forOperation(
"observe_web_session",
),
),
).toMatchObject({
code: projectAnalysisError(
new BrowserObservationError("observe_web_session", "timeout"),
).code,
details: { operation: "observe_web_session", reason: "timeout" },
});
});
});
describe("analysis error projection: Hopper provider failures", () => {
it("reports a timed-out active Hopper request and actionable retry guidance", () => {
const projected = projectAnalysisError(
new HopperTimeoutError(30_000, "trace_feature", 42, "busy"),
);
expect(projected).toMatchObject({
code: "provider_timeout",
category: "timeout",
retryable: true,
details: {
stage: "analysis",
operation: "trace_feature",
request_id: 42,
provider_state: "busy",
timeout_ms: 30_000,
},
});
expect(projected.message).toContain("binary_session.analysis_activity");
expect(projected.remediation.action).toContain(
"wait for the active Hopper request",
);
});
it("identifies the REA session that already owns a Hopper target", () => {
const error = new HopperStartError({
ownerRunId: "session-owner",
userMessage:
"This Hopper target is already open in REA session session-owner. Use that REA session or close it before opening this target again.",
});
expect(projectAnalysisError(error)).toMatchObject({
category: "unavailable",
message: expect.stringContaining("session-owner"),
remediation: {
action: expect.stringContaining(
"Use the active REA session session-owner",
),
},
});
});
it("retains the Hopper request and provider diagnostic on a remote failure", () => {
expect(
projectAnalysisError(
new HopperRemoteError(
7,
"Hopper analysis failed in the selected document",
{
diagnosticType: "bridge_exception",
operation: "trace_feature",
requestId: 43,
},
),
),
).toMatchObject({
code: "execution_failure",
details: {
stage: "analysis",
provider_code: 7,
diagnostic_type: "bridge_exception",
operation: "trace_feature",
request_id: 43,
},
message: expect.stringContaining("Hopper analysis failed"),
});
});
});
describe("analysis error projection: detached provider diagnostics", () => {
it.each(["adapter", "cancelled", "timeout"])(
"preserves detached, actionable diagnostics for %s errors",
(kind) => {
const diagnostics = {
runtime_root: "/tmp/rea-ghidra-fixture",
profile_digest: "a".repeat(64),
exit_code: 1,
};
const error =
kind === "adapter"
? new ProviderAdapterError("ghidra", "health", { diagnostics })
: kind === "cancelled"
? new AnalysisCancelledError("health", { diagnostics })
: new AnalysisTimeoutError("health", 250, { diagnostics });
expect(error.diagnostics).not.toBe(diagnostics);
expect(projectAnalysisError(error)).toMatchObject({
details: {
...(kind === "adapter" ? { provider_id: "ghidra" } : {}),
operation: "health",
diagnostics,
},
});
diagnostics.exit_code = 2;
expect(error.diagnostics?.exit_code).toBe(1);
},
);
});
describe("analysis error projection: Hopper startup failures", () => {
it("maps the Linux startup compatibility exit codes without host internals", () => {
const expected = [
[70, "private_display_unavailable"],
[71, "x11_authorization_failed"],
[72, "unsupported_hopper_build"],
[73, "invalid_launch_command"],
[74, "process_ownership_mismatch"],
[75, "hopper_exited_during_startup"],
[76, "unsupported_demo_dialog"],
[77, "unexpected_display_geometry"],
[78, "x11_input_failed"],
[79, "runtime_dependency_unavailable"],
[80, "x11_socket_directory_unusable"],
] as const;
for (const [exitCode, code] of expected) {
const projected = projectAnalysisError(new HopperProcessError(exitCode));
expect(projected).toMatchObject({
code: "provider_unavailable",
details: { failure_code: code, exit_code: exitCode },
});
expect(projected.message).toMatch(/\S/u);
expect(JSON.stringify(projected)).not.toContain("/proc/");
}
});
});
describe("analysis error projection: retry guidance", () => {
it.each([
[
"unreadable output",
new AnalysisOutputError("inspect_firmware_regions", "report missing"),
"unreadable_output",
],
[
"adapter failure",
new ProviderAdapterError("firmware", "inspect_firmware_regions"),
"execution_failure",
],
[
"Hopper remote failure",
new HopperRemoteError(7, "Hopper analysis failed", {
diagnosticType: "bridge_exception",
operation: "trace_feature",
}),
"execution_failure",
],
])(
"does not recommend an unchanged retry for non-retryable %s",
(_name, error, code) => {
const projected = projectAnalysisError(error);
expect(projected).toMatchObject({ code, retryable: false });
for (const text of [projected.message, projected.remediation.action])
expect(text).not.toMatch(/\bretry\b/iu);
},
);
});
describe("analysis error projection: caller contract", () => {
it("preserves the reported output constraint without exposing its internal cause", () => {
const projected = projectAnalysisError(
new AnalysisOutputError(
"extract_firmware",
"Report exceeded the declared byte budget",
{ cause: new Error("private internal cause") },
),
);
expect(projected).toMatchObject({
code: "unreadable_output",
details: {
operation: "extract_firmware",
reason: "Report exceeded the declared byte budget",
},
});
expect(JSON.stringify(projected)).not.toContain("private internal cause");
});
it("maps representative failures without exposing causes", () => {
const secretCause = new Error("secret-token");
const projected = [
projectAnalysisError(
new AnalysisInputError("overview", { cause: secretCause }),
),
projectAnalysisError(
new AnalysisCapabilityUnavailableError("fixture", "overview", "absent"),
),
projectAnalysisError(
new BinaryTargetError("/local/targets/app", "invalid", {
cause: secretCause,
}),
),
];
expect(projected.map(({ code }) => code)).toEqual([
"invalid_request",
"capability_unavailable",
"target_unavailable",
]);
expect(projected[1]).toMatchObject({
category: "unsupported_provider",
message:
"This analysis is unavailable for the current target. Choose another analysis or target.",
details: {
provider_id: "fixture",
operation: "overview",
reason: "absent",
},
});
expect(projected[2]).toMatchObject({
details: { path: "/local/targets/app" },
});
expect(JSON.stringify(projected)).not.toContain("secret-token");
});
it.each(["EACCES", "EPERM"] as const)(
"reports a target the host refused to read (%s) as access_denied",
(systemCode) => {
const denied = Object.assign(new Error("denied"), { code: systemCode });
const reason = `permission denied while reading target: ${systemCode}`;
expect(
projectAnalysisError(
new BinaryTargetError("/local/targets/app", reason, {
cause: denied,
}),
),
).toMatchObject({
code: "access_denied",
category: "unavailable",
retryable: false,
remediation: {
action:
"Check the current process's read access to the selected path. Retry with a readable local file.",
},
details: {
path: "/local/targets/app",
reason,
system_code: systemCode,
boundary: "filesystem-read",
},
});
expect(
projectAnalysisError(
new BinaryTargetError("/local/targets/app", "missing", {
cause: Object.assign(new Error("missing"), { code: "ENOENT" }),
}),
),
).toMatchObject({ code: "target_unavailable" });
},
);
it("uses explicit capability recovery while retaining the constraint", () => {
const projected = projectAnalysisError(
new AnalysisCapabilityUnavailableError(
"fixture",
"capture",
"host constraint",
{
userMessage: "Use the supported host workflow.",
cause: new Error("private internal cause"),
},
),
);
expect(projected).toMatchObject({
code: "capability_unavailable",
category: "unsupported_provider",
message: "Use the supported host workflow.",
details: {
provider_id: "fixture",
operation: "capture",
reason: "host constraint",
},
});
expect(JSON.stringify(projected)).not.toContain("private internal cause");
});
it("preserves exact artifact-integrity coordinates", () => {
expect(
projectAnalysisError(
new ArtifactOperationError("inventory_artifact", "integrity", {
logicalPath: "main.js",
declaredSha256: "a".repeat(64),
calculatedSha256: "b".repeat(64),
unpacked: true,
}),
),
).toMatchObject({
category: "integrity_mismatch",
details: {
logical_path: "main.js",
declared_sha256: "a".repeat(64),
calculated_sha256: "b".repeat(64),
unpacked: true,
},
});
});
it("gives missing residual unknowns lookup-specific remediation", () => {
expect(
projectAnalysisError(new UnknownRegistryError("not-found")),
).toMatchObject({
code: "execution_failure",
message:
"The requested residual unknown does not exist in this session. Check the unknown_id and try again.",
remediation: {
action:
"Check that the unknown_id belongs to this session, then retry.",
},
details: { reason: "not-found" },
});
});
});
describe("analysis error projection: operational diagnostics", () => {
it("distinguishes an observed Hopper exit from an undetermined provider failure", () => {
const exited = projectAnalysisError(
new HopperProcessError(7, { operation: "search_strings", requestId: 4 }),
);
expect(exited).toMatchObject({
code: "provider_unavailable",
retryable: true,
details: {
stage: "analysis",
provider_state: "exited",
retry_action: "restart_provider",
operation: "search_strings",
request_id: 4,
exit_code: 7,
},
});
expect(exited.message).not.toContain("Run `rea doctor`, then try again.");
expect(exited.remediation.action).toContain("Restart the owned provider");
const unknown = projectAnalysisError(
new HopperProcessError(null, {
operation: "search_strings",
requestId: 5,
}),
);
expect(unknown.details).toMatchObject({
stage: "analysis",
provider_state: "unknown",
retry_action: "unknown",
operation: "search_strings",
request_id: 5,
exit_code: null,
});
expect(unknown.message).toContain("could not determine");
expect(unknown.message).not.toContain("stopped");
expect(unknown.remediation.action).toContain("provider_operation_health");
const unreachable = projectAnalysisError(
new HopperProcessError(null, {
operation: "search_strings",
providerState: "unreachable",
}),
);
expect(unreachable.message).toContain(
"If Hopper may have exited, call export_evidence_bundle before close_binary",
);
expect(unreachable.message).toContain("closing clears retained records");
expect(unreachable.remediation.action).toContain("export_evidence_bundle");
});
});
describe("analysis error projection: evidence files", () => {
it.each([
["read", "missing", "invalid_request", "invalid_input"],
["read", "not-file", "invalid_request", "invalid_input"],
["write", "missing", "invalid_request", "invalid_input"],
["write", "exists", "invalid_request", "invalid_input"],
[
"read",
"invalid-json",
"evidence_integrity_mismatch",
"execution_failure",
],
["read", "io", "execution_failure", "execution_failure"],
] as const)(
"classifies a %s %s evidence file by who can correct it",
(operation, reason, code, category) => {
const projected = projectAnalysisError(
new EvidenceFileError(operation, reason, { path: "/selected.json" }),
);
expect(projected).toMatchObject({
code,
category,
details: { operation, reason, path: "/selected.json" },
});
expect(analysisErrorProjectionSchema.parse(projected)).toEqual(projected);
},
);
});
it("retains the primary failure and both cleanup reports after a secondary failure", () => {
const callerReason = { caller: "stop" };
const primary = new AnalysisCancelledError("execute_rizin_command", {
cause: callerReason,
capturedOutput: {
stdout: "partial",
stderr: "diagnostic",
truncated: false,
},
cleanup: { reason: "writer remains", resources: ["writer"] },
});
const secondary = new AnalysisOutputError("open_binary", "cannot rebind", {
cleanup: { reason: "provider remains", resources: ["provider", "writer"] },
});
const enriched = primary.retainSecondaryFailure(secondary, {
rebind_error: projectAnalysisError(secondary),
});
expect(enriched).toBe(primary);
expect(enriched._tag).toBe("AnalysisCancelledError");
expect(enriched.cause).toBe(callerReason);
expect(enriched.secondaryFailures).toEqual([secondary]);
expect(projectAnalysisError(enriched)).toMatchObject({
code: "cleanup_incomplete",
details: {
execution_failure: "cancelled",
resources: ["writer"],
cleanup_reason: "writer remains",
captured_output: { stdout: "partial", stderr: "diagnostic" },
diagnostics: { rebind_error: { code: "cleanup_incomplete" } },
},
});
});
it("retains secondary failures when the primary error has no cause", () => {
const primary = new AnalysisCancelledError("execute_rizin_command");
const secondary = new AnalysisOutputError("open_binary", "cannot rebind");
primary.retainSecondaryFailure(secondary, {});
expect(primary.cause).toBeUndefined();
expect(primary.secondaryFailures).toEqual([secondary]);
});
it.each([false, true])(
"preserves producer capability recovery with retryable=%s",
(retryable) => {
const recovery =
"Choose an explicit disposition, then retry the same session.";
const projected = projectAnalysisError(
new AnalysisCapabilityUnavailableError("fixture", "close", "live state", {
retryable,
remediationAction: recovery,
}),
);
expect(projected).toMatchObject({
code: "capability_unavailable",
retryable,
remediation: { action: recovery },
details: {
provider_id: "fixture",
operation: "close",
reason: "live state",
},
});
expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(
true,
);
},
);