fix(observation): keep incomplete cancellation observations

Service projection treated a retained observation that failed the completed
schema as a malformed completed result. Cancellation became unreadable_output
and the partial was dropped. Incomplete observations now stay on the owner
error, including the exact abort reason.
This commit is contained in:
Cursor Agent
2026-10-20 18:03:29 +00:00
parent b3aadaf474
commit 381fcbdb8c
10 changed files with 267 additions and 79 deletions
@@ -3,9 +3,13 @@ import type { BrowserObservationPort } from "./BrowserObservationPort.js";
import { listBrowserTargets } from "./BrowserObservationService.js";
import type { BrowserTargetList } from "../domain/browserObservationSchemas.js";
import { parseEvidence } from "../domain/evidence.js";
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
import { ok } from "../domain/result.js";
import { err, ok } from "../domain/result.js";
import { passiveNetworkCapture } from "../../tests/fixtures/passiveNetworkCapture.js";
import { inspectWebPage } from "./BrowserObservationService.js";
import { analyzeWebBundleInputSchema } from "../domain/webBundleAnalysis.js";
@@ -215,3 +219,71 @@ it("preserves a prior caller reason without admitting browser work", async () =>
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
});
it("keeps page cancellation when the retained inspection is incomplete", async () => {
const reason = { requested: "stop during page inspection" };
const partial = {
target: { target_id: "page" },
network: {},
capture_window: {},
};
const failure = new AnalysisCancelledError("inspect_web_page", {
cause: reason,
partialObservation: partial as never,
});
const browser = {
...provider(async () => ok(report)),
inspectPage: async () => err(failure),
} as unknown as BrowserObservationPort;
const pageInput = inspectWebPageInputSchema.parse({
cdp_endpoint: input.cdp_endpoint.recorded,
allowed_origins: [],
target_id: "page",
observation_ms: 0,
include_script_sources: false,
});
const result = await inspectWebPage(browser, pageInput);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error).toBe(failure);
expect(result.error.cause).toBe(reason);
expect(result.error.partialObservation).toBe(partial);
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cancelled",
details: { partial_observation: partial },
});
});
it("keeps bundle cancellation and an earlier failure when retained facts are incomplete", async () => {
const reason = { requested: "stop during bundle analysis" };
const partial = { observations: [], capture: { status: "partial" } };
const cancelled = new AnalysisCancelledError("analyze_web_bundle", {
cause: reason,
partialObservation: partial as never,
});
const timedOut = new BrowserObservationError(
"analyze_web_bundle",
"timeout",
{ partialObservation: partial as never },
);
const selected = analyzeWebBundleInputSchema.parse({
cdp_endpoint: input.cdp_endpoint.recorded,
target_id: "page",
observation_ms: 0,
});
for (const failure of [cancelled, timedOut]) {
const browser = {
...provider(async () => ok(report)),
analyzeBundle: async () => err(failure),
} as unknown as BrowserObservationPort;
const result = await analyzeWebBundle(browser, selected);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the owner failure");
expect(result.error).toBe(failure);
expect(result.error.partialObservation).toBe(partial);
}
expect(projectAnalysisError(cancelled)).toMatchObject({
code: "cancelled",
details: { partial_observation: partial },
});
});
+2 -28
View File
@@ -27,7 +27,6 @@ import { createBrowserEvidence } from "./BrowserEvidence.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { completeProviderEvidence } from "./ProviderOperationExecution.js";
import { webPageInspectionSchema } from "../domain/browserObservationSchemas.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
/** List browser targets within the endpoint and origin scope in the request. */
export const listBrowserTargets = async (
@@ -291,22 +290,7 @@ const retainBrowserObservation = (
"capture" in partial
) {
const bundle = webBundleAnalysisSchema.safeParse(partial);
if (!bundle.success) {
const malformed = new AnalysisOutputError(
errorOperation,
"Browser provider retained malformed completed bundle facts.",
{ cause: bundle.error },
);
return err(
error.cleanupIncomplete
? analysisErrorWithCleanupFailure(
malformed,
error,
errorOperation,
).bindCleanupDependencies([malformed, error])
: malformed,
);
}
if (!bundle.success) return err(error);
error.partialObservation = createBrowserEvidence(
"analyze_web_bundle",
input,
@@ -324,17 +308,7 @@ const retainBrowserObservation = (
)
return err(error);
const inspection = webPageInspectionSchema.safeParse(partial);
if (!inspection.success) {
if (error instanceof AnalysisCancelledError)
return err(
new AnalysisOutputError(
errorOperation,
"Browser provider retained a malformed completed page inspection.",
{ cause: inspection.error },
),
);
return err(error);
}
if (!inspection.success) return err(error);
const pageInput: InspectWebPageInput =
"fetch_source_maps" in input
? (({ fetch_source_maps: _fetchSourceMaps, ...page }) => page)(input)
@@ -1,10 +1,18 @@
import { expect, it } from "vitest";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
import { parseEvidence } from "../domain/evidence.js";
import { browserScenarioInputSchema } from "../domain/browserScenario.js";
import { err } from "../domain/result.js";
import type {
BrowserScenarioCapture,
BrowserScenarioPartialObservation,
} from "../domain/browserScenarioCapture.js";
import { err, ok } from "../domain/result.js";
import { scriptScenarioFixture } from "../../tests/fixtures/webScriptCapture.js";
import type { BrowserScenarioCapturePort } from "./BrowserScenarioCapturePort.js";
import { captureBrowserScenario } from "./BrowserScenarioCaptureService.js";
@@ -103,3 +111,55 @@ it.each([
expect(calls).toBe(0);
},
);
it("keeps startup cancellation and its incomplete capture", async () => {
const reason = { requested: "stop during initial navigation" };
const completed = scriptScenarioFixture();
const {
completeness: _completeness,
steps: _steps,
browser,
...retained
} = completed;
const partial: BrowserScenarioPartialObservation = {
kind: "browser-scenario-observation",
capture: {
...retained,
browser: { ...browser, cleanup: "incomplete" },
steps: [],
},
};
const failure = new AnalysisCancelledError("capture_browser_scenario", {
cause: reason,
partialObservation: partial,
});
const provider: BrowserScenarioCapturePort = {
identity: () => ({ id: "fixture", name: "fixture", version: "1" }),
captureScenario: async () => err(failure),
};
const result = await captureBrowserScenario(provider, scenario);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error).toBe(failure);
expect(result.error.cause).toBe(reason);
expect(result.error.partialObservation).toBe(partial);
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cancelled",
details: { partial_observation: partial },
});
});
it("rejects a successful capture that is not a completed scenario", async () => {
const provider: BrowserScenarioCapturePort = {
identity: () => ({ id: "fixture", name: "fixture", version: "1" }),
captureScenario: async () => ok({} as BrowserScenarioCapture),
};
const result = await captureBrowserScenario(provider, scenario);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected a completed-capture refusal");
expect(result.error).toBeInstanceOf(AnalysisOutputError);
expect(result.error.message).toContain(
"returned a malformed completed capture",
);
expect(result.error.partialObservation).toBeUndefined();
});
@@ -43,6 +43,8 @@ export const captureBrowserScenario = async (
partial.kind === "browser-scenario-observation"
) {
const report = browserScenarioCaptureSchema.safeParse(partial.capture);
// Only a completed capture becomes Evidence. An incomplete observation
// stays on the owner error, including typed cancellation.
if (
report.success &&
report.data.steps.every(({ status }) => status === "completed")
@@ -52,16 +54,6 @@ export const captureBrowserScenario = async (
report.data,
provider.identity(),
);
} else if (!report.success) {
if (captured.error instanceof AnalysisCancelledError)
return err(
new AnalysisOutputError(
OPERATION,
"Browser scenario provider retained a malformed completed capture.",
{ cause: report.error },
),
);
return captured;
}
}
return captured;
+64 -2
View File
@@ -6,10 +6,14 @@ import {
webListenersFixture,
} from "./WebRuntimeService.fixture.js";
import { err, ok } from "../domain/result.js";
import { AnalysisTimeoutError } from "../domain/analysisErrorCore.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { parseEvidence } from "../domain/evidence.js";
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
it("rejects malformed input and prior cancellation without calling a provider", async () => {
const provider = recordingWebRuntimePort();
@@ -161,3 +165,61 @@ it("retains completed execution Evidence on late abort and keeps invalid output
});
if (!invalid.ok) expect(invalid.error).toBeInstanceOf(AnalysisOutputError);
});
it.each([
{
operation: "observe" as const,
partial: {
instrumentation: { cleanup: "unconfirmed" },
window: { requested_ms: 5 },
},
},
{
operation: "inspect" as const,
partial: {
selected_node: { selector: "#selected" },
listeners: [],
},
},
])(
"keeps $operation cancellation when the retained observation is incomplete",
async ({ operation, partial }) => {
const reason = { requested: `stop incomplete ${operation}` };
const failure = new AnalysisCancelledError(
operation === "observe"
? "observe_web_execution"
: "inspect_web_event_listeners",
{ cause: reason, partialObservation: partial as never },
);
const service = new WebRuntimeService({
identity: () => ({
id: "web-runtime",
name: "Runtime fixture",
version: "1",
}),
observeExecution: async () => err(failure),
inspectEventListeners: async () => err(failure),
});
const result =
operation === "observe"
? await service.observe({
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "selected-page",
observation_ms: 5,
})
: await service.inspect({
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "selected-page",
selector: "#selected",
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error).toBe(failure);
expect(result.error.cause).toBe(reason);
expect(result.error.partialObservation).toBe(partial);
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cancelled",
details: { partial_observation: partial },
});
},
);
+2 -16
View File
@@ -56,14 +56,7 @@ export class WebRuntimeService {
if (evidence.ok) result.error.partialObservation = evidence.value;
else if (result.error instanceof AnalysisCancelledError)
return evidence;
} else if (result.error instanceof AnalysisCancelledError)
return err(
new AnalysisOutputError(
operation,
"Provider retained a malformed completed execution observation.",
{ cause: verifiedPartial.error },
),
);
}
}
return result;
}
@@ -118,14 +111,7 @@ export class WebRuntimeService {
if (evidence.ok) result.error.partialObservation = evidence.value;
else if (result.error instanceof AnalysisCancelledError)
return evidence;
} else if (result.error instanceof AnalysisCancelledError)
return err(
new AnalysisOutputError(
operation,
"Provider retained malformed completed event-listener data.",
{ cause: verifiedPartial.error },
),
);
}
}
return result;
}
@@ -5,6 +5,7 @@ import {
AnalysisCancelledError,
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import { parseEvidence } from "../../domain/evidence.js";
import {
@@ -244,3 +245,32 @@ it.each(["list", "inspect"] as const)(
expect(providerCalls).toBe(0);
},
);
it("keeps cancellation when the retained Electron inspection is incomplete", async () => {
const reason = { requested: "stop before page inspection completed" };
const partial = {
kind: "electron-page-observation",
inspection: { target_id: "electron-page" },
cleanup: "incomplete",
};
const failure = new AnalysisCancelledError("inspect_electron_page", {
cause: reason,
partialObservation: partial as never,
});
const provider: ElectronObservationPort = {
identity: () => identity,
listTargets: async () =>
err(new AnalysisCancelledError("list_electron_targets")),
inspectPage: async () => err(failure),
};
const result = await inspectElectronPage(provider, input);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error).toBe(failure);
expect(result.error.cause).toBe(reason);
expect(result.error.partialObservation).toBe(partial);
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cancelled",
details: { partial_observation: partial },
});
});
@@ -79,17 +79,7 @@ export const inspectElectronPage = async (
partial.kind === "electron-page-observation"
) {
const report = electronPageInspectionSchema.safeParse(partial.inspection);
if (!report.success) {
if (result.error instanceof AnalysisCancelledError)
return err(
new AnalysisOutputError(
"inspect_electron_page",
"Electron provider retained a malformed completed page inspection.",
{ cause: report.error },
),
);
return result;
}
if (!report.success) return result;
const inspection = {
...report.data,
limitations: [
@@ -5,6 +5,7 @@ import {
AnalysisCancelledError,
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { err, ok } from "../../domain/result.js";
import { parseEvidence } from "../../domain/evidence.js";
import type { JavaScriptRuntimeTargetList } from "../../domain/javascript/javascriptRuntimeObservation.js";
@@ -317,3 +318,32 @@ it.each(["list", "observe"] as const)(
expect(providerCalls).toBe(0);
},
);
it("keeps cancellation when the retained runtime observation is incomplete", async () => {
const reason = { requested: "stop before runtime observation completed" };
const partial = { runtime: "partial", scripts: [] };
const failure = new AnalysisCancelledError("observe_javascript_runtime", {
cause: reason,
partialObservation: partial as never,
});
const provider: JavaScriptRuntimeObservationPort = {
identity: () => ({ id: "test", name: "Test", version: "1" }),
listTargets: async () =>
err(new AnalysisCancelledError("list_javascript_runtime_targets")),
observe: async () => err(failure),
};
const result = await observeJavaScriptRuntime(provider, {
inspector_endpoint: "http://127.0.0.1:9229",
target_id: "target",
observation_ms: 0,
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error).toBe(failure);
expect(result.error.cause).toBe(reason);
expect(result.error.partialObservation).toBe(partial);
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cancelled",
details: { partial_observation: partial },
});
});
@@ -76,14 +76,6 @@ export const observeJavaScriptRuntime = async (
!("kind" in partial)
) {
const report = javascriptRuntimeObservationSchema.safeParse(partial);
if (!report.success && result.error instanceof AnalysisCancelledError)
return err(
new AnalysisOutputError(
"observe_javascript_runtime",
"Inspector retained a malformed completed runtime observation.",
{ cause: report.error },
),
);
if (report.success)
result.error.partialObservation =
createJavaScriptRuntimeObservationEvidence(