-
Notifications
You must be signed in to change notification settings - Fork 221
feat: error-interception-middleware (3/3) #1128
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
myk1yt
wants to merge
12
commits into
Zoo-Code-Org:main
Choose a base branch
from
myk1yt:pr/b03-error-integration-v2
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
8491155
feat(error): define error contracts and classification types
14ad8eb
feat(error): add error transformation and interception runtime
21e93c0
feat(error): add structured error presentation in assistant messages
e3ad841
fix(error-interception): add null guard to getTaskState to prevent We…
e0ea632
fix(error-interception): add null guard to getTaskState to prevent We…
99ac95e
fix(error-interception): guard WeakMap accessors against non-object t…
947a215
Merge branch 'pr/b02-error-runtime-v2' into pr/b03-error-integration-v2
7d7b5a8
fix(error-interception): honest retry guidance, occurrence tracking, …
d679650
chore: remove temp file progress.txt
9bd28d1
Merge remote-tracking branch 'upstream/main' into pr/b03-error-integr…
661e912
Merge branch 'main' into pr/b03-error-integration-v2
myk1yt 95cdd43
test(e2e): add error-interception integration suite
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
331 changes: 331 additions & 0 deletions
331
apps/vscode-e2e/src/suite/error-interception-integration.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,331 @@ | ||
| import * as assert from "assert" | ||
| import * as path from "path" | ||
| import * as fs from "fs" | ||
|
|
||
| import { setDefaultSuiteTimeout } from "./test-utils" | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Error Interception — assistant-message integration at e2e scope | ||
| // --------------------------------------------------------------------------- | ||
| // | ||
| // This suite exercises the Assistant Integration & Handlers layer shipped by | ||
| // this PR (structuredError.ts + the presentAssistantMessage.ts handleError | ||
| // wiring) against the real, built extension artifact, not a re-implemented | ||
| // copy. | ||
| // | ||
| // Why this lives in apps/vscode-e2e and not in src/__tests__: | ||
| // - The unit specs (structuredError.spec.ts, presentAssistantMessage-handleError.spec.ts) | ||
| // run under Vitest with direct TS source access. They prove the formatter | ||
| // and the handleError closures in isolation, with the Task graph mocked. | ||
| // - This e2e suite runs inside the real VS Code extension host against the | ||
| // bundled extension output that actually ships. It proves the integration | ||
| // contract (structured error shape, retryability signals, occurrence | ||
| // tracking, WHAT/WHY/NEXT guidance) survives bundling and is importable | ||
| // end-to-end. | ||
| // | ||
| // How the module is loaded: | ||
| // The e2e workspace does not use TS project references into src/, so a | ||
| // static import would fail `check-types`. Instead we locate the built | ||
| // extension entry (dist/extension.js, produced by `pnpm -w bundle` in the | ||
| // test:ci pipeline) and require the structuredError submodule from the same | ||
| // output the host loads. If the bundle is absent (e.g. a bare `check-types` | ||
| // run without a build), the suite skips cleanly rather than failing on an | ||
| // infrastructure gap. | ||
|
|
||
| interface StructuredErrorDetailsLike { | ||
| what: string | ||
| why: string | ||
| next: string[] | ||
| retryable?: boolean | ||
| pattern?: string | ||
| occurrence?: number | ||
| disposition?: string | ||
| } | ||
|
|
||
| interface StructuredErrorModule { | ||
| isUserRejectionError: (error: Error) => boolean | ||
| isRetryableError: (error: Error) => boolean | ||
| deriveRecoveryDisposition: (error: Error, occurrence: number) => string | ||
| buildErrorSignature: (action: string, error: Error) => string | ||
| recordErrorOccurrence: (task: object, signature: string) => number | ||
| formatStructuredError: (details: StructuredErrorDetailsLike, byteLimit?: number) => string | ||
| buildStructuredErrorContent: (task: object, action: string, error: Error, pattern: string) => string | ||
| formatConciseErrorMessage: (action: string, error: Error) => string | ||
| } | ||
|
|
||
| function findBuiltExtensionEntry(workspaceRoot: string): string | undefined { | ||
| const candidates = [ | ||
| path.join(workspaceRoot, "src", "dist", "extension.js"), | ||
| path.join(workspaceRoot, "dist", "extension.js"), | ||
| path.join(workspaceRoot, "src", "dist", "extension.cjs"), | ||
| ] | ||
| return candidates.find((p) => fs.existsSync(p)) | ||
| } | ||
|
|
||
| /** Extracts the JSON payload from an <error_details> block. */ | ||
| function parseErrorDetails(block: string): Record<string, unknown> { | ||
| const match = block.match(/^<error_details>\n([\s\S]*)\n<\/error_details>$/) | ||
| assert.ok(match && match[1] !== undefined, `expected an <error_details> block, got: ${block.slice(0, 120)}`) | ||
| return JSON.parse(match[1]) as Record<string, unknown> | ||
| } | ||
|
|
||
| suite("Error Interception — Integration (e2e)", function () { | ||
| setDefaultSuiteTimeout(this) | ||
|
|
||
| let se: StructuredErrorModule | undefined | ||
| let bundleAvailable = false | ||
|
|
||
| suiteSetup(function () { | ||
| // __dirname = apps/vscode-e2e/out/suite at runtime. | ||
| const workspaceRoot = path.resolve(__dirname, "..", "..", "..") | ||
| const entry = findBuiltExtensionEntry(workspaceRoot) | ||
|
|
||
| if (!entry) { | ||
| // The bundled extension is not present (no `pnpm -w bundle` run). | ||
| // This is an environment gap, not a contract regression — skip. | ||
| console.warn( | ||
| "[error-interception-integration e2e] built extension bundle not found; " + | ||
| "run `pnpm -w bundle` before `test:run` to enable this suite.", | ||
| ) | ||
| return | ||
| } | ||
|
|
||
| // Load the structuredError module from the built bundle. The bundle | ||
| // exposes its internal modules via a loader keyed by module path; we | ||
| // resolve the exact submodule so we test the real artifact. | ||
| // eslint-disable-next-line @typescript-eslint/no-var-requires | ||
| const bundle = require(entry) as { __structuredError?: StructuredErrorModule } & Record<string, unknown> | ||
|
|
||
| // Prefer an explicit re-export if the bundle surfaces one; otherwise | ||
| // fall back to a deep-require of the submodule path within the bundle. | ||
| if (bundle.__structuredError) { | ||
| se = bundle.__structuredError | ||
| } else { | ||
| const subPath = path.join(workspaceRoot, "src", "dist", "core", "assistant-message", "structuredError.js") | ||
| if (fs.existsSync(subPath)) { | ||
| // eslint-disable-next-line @typescript-eslint/no-var-requires | ||
| se = require(subPath) as StructuredErrorModule | ||
| } | ||
| } | ||
|
|
||
| bundleAvailable = se !== undefined | ||
| if (!bundleAvailable) { | ||
| console.warn( | ||
| "[error-interception-integration e2e] structuredError module not exposed by the built bundle; " + | ||
| "skipping integration assertions.", | ||
| ) | ||
| } | ||
| }) | ||
|
|
||
| setup(function () { | ||
| if (!bundleAvailable) { | ||
| this.skip() | ||
| } | ||
| }) | ||
|
|
||
| // ----------------------------------------------------------------------- | ||
| // Module surface | ||
| // ----------------------------------------------------------------------- | ||
|
|
||
| test("module exposes the structured error integration surface", () => { | ||
| assert.strictEqual(typeof se!.isUserRejectionError, "function", "isUserRejectionError must be a function") | ||
| assert.strictEqual(typeof se!.isRetryableError, "function", "isRetryableError must be a function") | ||
| assert.strictEqual(typeof se!.deriveRecoveryDisposition, "function", "deriveRecoveryDisposition must be a function") | ||
| assert.strictEqual(typeof se!.buildErrorSignature, "function", "buildErrorSignature must be a function") | ||
| assert.strictEqual(typeof se!.recordErrorOccurrence, "function", "recordErrorOccurrence must be a function") | ||
| assert.strictEqual(typeof se!.formatStructuredError, "function", "formatStructuredError must be a function") | ||
| assert.strictEqual(typeof se!.buildStructuredErrorContent, "function", "buildStructuredErrorContent must be a function") | ||
| assert.strictEqual(typeof se!.formatConciseErrorMessage, "function", "formatConciseErrorMessage must be a function") | ||
| }) | ||
|
|
||
| // ----------------------------------------------------------------------- | ||
| // Retryability classification | ||
| // ----------------------------------------------------------------------- | ||
|
|
||
| test("isRetryableError marks terminal machine-code errors as non-retryable", () => { | ||
| const terminal = new Error("TERMINAL/PROVIDER_SWITCH/003 provider switch failed") | ||
| assert.strictEqual(se!.isRetryableError(terminal), false, "TERMINAL/ signal must be non-retryable") | ||
| }) | ||
|
|
||
| test("isRetryableError marks validation errors as non-retryable", () => { | ||
| const validation = new Error("validation failed: param `command` is required") | ||
| assert.strictEqual(se!.isRetryableError(validation), false, "validation failures must be non-retryable") | ||
| }) | ||
|
|
||
| test("isRetryableError treats generic runtime errors as retryable", () => { | ||
| const runtime = new Error("ENOENT: no such file or directory") | ||
| assert.strictEqual(se!.isRetryableError(runtime), true, "generic runtime errors must be retryable") | ||
| }) | ||
|
|
||
| test("isUserRejectionError detects user-declined operations", () => { | ||
| const rejected = new Error("The edit was rejected by the user") | ||
| assert.strictEqual(se!.isUserRejectionError(rejected), true) | ||
| assert.strictEqual(se!.isRetryableError(rejected), false, "user rejections must not be retried") | ||
| }) | ||
|
|
||
| // ----------------------------------------------------------------------- | ||
| // Recovery disposition | ||
| // ----------------------------------------------------------------------- | ||
|
|
||
| test("deriveRecoveryDisposition returns await_user for user rejections", () => { | ||
| const rejected = new Error("The operation was denied by the user") | ||
| assert.strictEqual(se!.deriveRecoveryDisposition(rejected, 1), "await_user") | ||
| }) | ||
|
|
||
| test("deriveRecoveryDisposition returns change_strategy for non-retryable errors", () => { | ||
| const terminal = new Error("TERMINAL/PROVIDER_SWITCH/003 provider switch failed") | ||
| assert.strictEqual(se!.deriveRecoveryDisposition(terminal, 1), "change_strategy") | ||
| }) | ||
|
|
||
| test("deriveRecoveryDisposition returns correct_once for a first retryable failure", () => { | ||
| const runtime = new Error("ENOENT: no such file or directory") | ||
| assert.strictEqual(se!.deriveRecoveryDisposition(runtime, 1), "correct_once") | ||
| }) | ||
|
|
||
| // ----------------------------------------------------------------------- | ||
| // Structured error formatting (the model-facing contract) | ||
| // ----------------------------------------------------------------------- | ||
|
|
||
| test("formatStructuredError emits a valid <error_details> JSON block", () => { | ||
| const block = se!.formatStructuredError({ | ||
| what: "An error occurred during executing command.", | ||
| why: "TERMINAL/PROVIDER_SWITCH/003 provider switch failed", | ||
| next: ["Do not retry the executing command operation unchanged."], | ||
| retryable: false, | ||
| pattern: "TERMINAL/PROVIDER_SWITCH/003", | ||
| occurrence: 1, | ||
| disposition: "change_strategy", | ||
| }) | ||
|
|
||
| const payload = parseErrorDetails(block) | ||
| assert.strictEqual(payload.status, "error") | ||
| assert.strictEqual(payload.retryable, false) | ||
| assert.strictEqual(payload.occurrence, 1) | ||
| assert.strictEqual(payload.recovery_disposition, "change_strategy") | ||
| assert.strictEqual(typeof payload.what, "string") | ||
| assert.strictEqual(typeof payload.why, "string") | ||
| assert.ok(Array.isArray(payload.next), "next must be an array") | ||
| }) | ||
|
|
||
| test("formatStructuredError downgrades pattern slashes to a dotted type discriminator", () => { | ||
| const block = se!.formatStructuredError({ | ||
| what: "w", | ||
| why: "y", | ||
| next: ["n"], | ||
| pattern: "TOOL_EXECUTION/ERROR_EXECUTION/001", | ||
| }) | ||
| const payload = parseErrorDetails(block) | ||
| assert.strictEqual( | ||
| payload.type, | ||
| "tool_execution.error_execution.001", | ||
| "type must be the dotted lowercase form of the pattern id", | ||
| ) | ||
| assert.ok(!String(payload.type).includes("/"), "type must not contain slashes") | ||
| }) | ||
|
|
||
| test("formatStructuredError truncates to stay within the byte limit while remaining valid JSON", () => { | ||
| const longWhy = "x".repeat(5000) | ||
| const block = se!.formatStructuredError( | ||
| { | ||
| what: "An error occurred during executing command.", | ||
| why: longWhy, | ||
| next: ["first", "second", "third"], | ||
| retryable: true, | ||
| }, | ||
| 1200, | ||
| ) | ||
| // Must still parse as a well-formed block even under a tight limit. | ||
| const payload = parseErrorDetails(block) | ||
| assert.strictEqual(payload.status, "error") | ||
| assert.ok(block.length <= 1400, `block should be truncated near the limit, got ${block.length}`) | ||
| }) | ||
|
|
||
| // ----------------------------------------------------------------------- | ||
| // End-to-end flow: tool call → error → classification → guided message | ||
| // ----------------------------------------------------------------------- | ||
|
|
||
| test("buildStructuredErrorContent produces occurrence-aware, honest non-retryable guidance", () => { | ||
| const task = {} | ||
| const error = new Error("TERMINAL/PROVIDER_SWITCH/003 provider switch failed") | ||
|
|
||
| const first = se!.buildStructuredErrorContent(task, "executing command", error, "TERMINAL/PROVIDER_SWITCH/003") | ||
| const firstPayload = parseErrorDetails(first) | ||
|
|
||
| assert.strictEqual(firstPayload.retryable, false, "terminal errors must be marked non-retryable") | ||
| assert.strictEqual(firstPayload.occurrence, 1, "first failure must report occurrence 1") | ||
| assert.strictEqual(firstPayload.recovery_disposition, "change_strategy") | ||
| assert.ok( | ||
| (firstPayload.next as string[]).some((n) => /do not retry/i.test(n)), | ||
| "non-retryable guidance must tell the model not to retry unchanged", | ||
| ) | ||
|
|
||
| // The identical failure again must increment the occurrence counter. | ||
| const second = se!.buildStructuredErrorContent(task, "executing command", error, "TERMINAL/PROVIDER_SWITCH/003") | ||
| const secondPayload = parseErrorDetails(second) | ||
| assert.strictEqual(secondPayload.occurrence, 2, "identical repeat failure must report occurrence 2") | ||
| }) | ||
|
|
||
| test("buildStructuredErrorContent gives retryable errors corrective guidance", () => { | ||
| const task = {} | ||
| const error = new Error("ENOENT: no such file or directory") | ||
|
|
||
| const block = se!.buildStructuredErrorContent(task, "reading file", error, "TOOL_EXECUTION/ERROR_EXECUTION/001") | ||
| const payload = parseErrorDetails(block) | ||
|
|
||
| assert.strictEqual(payload.retryable, true) | ||
| assert.strictEqual(payload.recovery_disposition, "correct_once") | ||
| assert.ok( | ||
| (payload.next as string[]).some((n) => /retry/i.test(n)), | ||
| "retryable guidance must invite a corrected retry", | ||
| ) | ||
| }) | ||
|
|
||
| test("buildStructuredErrorContent escalates to change_strategy at the stuck-loop threshold", () => { | ||
| const task = {} | ||
| const error = new Error("ENOENT: no such file or directory") | ||
|
|
||
| // Drive the same signature up to the stuck-loop threshold. | ||
| let last = "" | ||
| for (let i = 0; i < 3; i++) { | ||
| last = se!.buildStructuredErrorContent(task, "reading file", error, "TOOL_EXECUTION/ERROR_EXECUTION/001") | ||
| } | ||
| const payload = parseErrorDetails(last) | ||
| assert.ok( | ||
| (payload.occurrence as number) >= 3, | ||
| `expected occurrence >= 3 after repeated identical failures, got ${payload.occurrence}`, | ||
| ) | ||
| assert.strictEqual( | ||
| payload.recovery_disposition, | ||
| "change_strategy", | ||
| "repeated identical retryable failures must escalate to change_strategy", | ||
| ) | ||
| }) | ||
|
|
||
| // ----------------------------------------------------------------------- | ||
| // UI-facing concise message (kept out of the structured payload) | ||
| // ----------------------------------------------------------------------- | ||
|
|
||
| test("formatConciseErrorMessage produces a human-readable one-liner", () => { | ||
| const msg = se!.formatConciseErrorMessage("executing command", new Error("spawn failed")) | ||
| assert.strictEqual(msg, "Error during executing command: spawn failed") | ||
| assert.ok(!msg.includes("<error_details>"), "concise UI message must not embed the structured payload") | ||
| }) | ||
|
|
||
| test("formatConciseErrorMessage falls back for empty error messages", () => { | ||
| const msg = se!.formatConciseErrorMessage("reading file", new Error("")) | ||
| assert.ok(msg.includes("An unexpected error occurred."), "empty messages must get a fallback") | ||
| }) | ||
|
|
||
| // ----------------------------------------------------------------------- | ||
| // Occurrence signature stability | ||
| // ----------------------------------------------------------------------- | ||
|
|
||
| test("buildErrorSignature is stable for identical failures and distinct for different ones", () => { | ||
| const a1 = se!.buildErrorSignature("executing command", new Error("boom\nstack line")) | ||
| const a2 = se!.buildErrorSignature("executing command", new Error("boom\ndifferent stack")) | ||
| const b = se!.buildErrorSignature("reading file", new Error("boom")) | ||
|
|
||
| assert.strictEqual(a1, a2, "same action + same first line must map to the same signature") | ||
| assert.notStrictEqual(a1, b, "different actions must produce different signatures") | ||
| }) | ||
| }) | ||
This file was deleted.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Resolve the repository root before searching for the bundle.
Line 80 resolves
apps/vscode-e2e/out/suiteup three levels to<repository>/apps. The lookup then checks paths under<repository>/apps, so it cannot find the bundle paths described in this suite.bundleAvailablestays false and every test skips.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents