import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { execFile } from "node:child_process"; import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, isAbsolute, join, relative, resolve } from "node:path"; import baseTest from "node:test"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { createGentleAiExtension } from "../extensions/gentle-ai.ts"; import { GENTLE_AI_VERSION, resolveGentleAiBinary } from "../lib/gentle-ai-binary.ts"; import { NativeReviewCliV216 } from "../lib/native-review-cli.ts"; import { NativeReviewCliV216 as RuntimeNativeReviewCliV216 } from "../runtime/native-review-cli.mjs"; import { CandidateViewRegistry } from "../lib/review-candidate-view.ts"; import { resolveGentleAiReleaseAsset } from "../scripts/gentle-ai-installer.mjs"; import { requireNativeBinary } from "./support/native-binary-gate.ts"; const execFileAsync = promisify(execFile); const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))); // The parity suite exercises the published official binary; it skips while a // re-pinned release's archives and digest table are still pending, because the // pinned package-local binary cannot be installed or integrity-verified yet. const resolvedBinary = (() => { try { return resolveGentleAiBinary(packageRoot, process.platform); } catch { return undefined; } })(); const nativeBinaryGate = requireNativeBinary({ resolvedBinary, digestsPinned: true, env: process.env }); if (!nativeBinaryGate.run) console.log(`native-review-parity-runtime: ${nativeBinaryGate.reason}`); const test = nativeBinaryGate.run ? baseTest : baseTest.skip; const binary = resolvedBinary ?? ""; const OFFICIAL_BINARY_SHA256 = resolveGentleAiReleaseAsset(process.platform, process.arch).binarySha256; const REVIEWED_PATHS = ["tracked.txt", "initially-untracked.txt"] as const; // Golden captured by the clean external-artifact differential fixture for v2.1.3. // This is released runtime output, not a locally reconstructed authority digest. const CLEAN_DIFFERENTIAL_PUBLISHED_PATHS_DIGEST = "sha256:5d91d7650fcbd1165e9cd88c144bf28d82913e3537abd7b4fdc8ad0adb9eab9c"; const REVIEWED_MODES = ["100644", "100644"]; interface CommandResult { exitCode: number; stdout: string; stderr: string; } interface RegisteredController { execute: (toolCallId: string, params: unknown, signal: AbortSignal | undefined, onUpdate: undefined, ctx: ExtensionContext) => Promise<{ details?: unknown }>; } interface ReviewStart { lineage_id: string; selected_lenses: string[]; action?: string; state?: string; } interface ReviewFinalize { lineage_id: string; state: string; store_revision: string; receipt_path: string; } interface ReviewAuthorityEntry { version: string; lineage_id: string; status: string; state: string; revision: string; problems: unknown[]; } interface ReviewGateContext { candidate_tree: string; paths_digest: string; denial?: ReviewDenial; } interface ReviewDenial { stage: string; code: string; } interface ReviewGateResult { result: string; allowed: boolean; context: ReviewGateContext; } // Every test in this file drives the real pinned binary through a full review // lifecycle, and gentle-ai v2.4.0 made receipt-driven development opt-in: an // install whose global mode was never set now resolves to off, and START is // rejected before it reaches any of the behavior these tests exist to check. // // Until that release these tests had a silent dependency on whatever the // operator happened to have configured. That is not a new hazard introduced by // the pin — it was always there — but the flip is what made it visible: the // suite was green on a machine with review enabled and red on a fresh CI // runner, from identical bytes. A test whose result depends on unversioned // machine state is not evidence either way. // // So each test owns a sandbox HOME and opts in the same way a user does, // exactly as gentle-ai did for its own lifecycle fixtures in the commit that // flipped the default. The process-wide HOME is what the extension-registered // controller path needs, because it spawns the CLI with the inherited // environment rather than an explicit one; it is restored afterwards. async function reviewEnabledHome(t: baseTest.TestContext): Promise { const home = await mkdtemp(join(tmpdir(), "gentle-pi-review-home-")); const previousHome = process.env.HOME; process.env.HOME = home; t.after(async () => { if (previousHome === undefined) delete process.env.HOME; else process.env.HOME = previousHome; await rm(home, { recursive: true, force: true }); }); const enabled = await run(binary, ["review", "mode", "enable", "--scope", "global", "--json"], packageRoot, false, { ...process.env, HOME: home }); // Assert the opt-in landed rather than assuming it. A silently ineffective // enable would put these tests straight back to depending on ambient state, // which is the exact failure this helper exists to remove. assert.match(enabled.stdout, /"effective": "on"/, "the sandbox HOME must have receipt-driven development explicitly enabled"); return home; } async function run(command: string, arguments_: readonly string[], cwd: string, allowFailure = false, environment?: NodeJS.ProcessEnv): Promise { try { const result = await execFileAsync(command, [...arguments_], { cwd, encoding: "utf8", shell: false, env: environment }); return { exitCode: 0, stdout: result.stdout, stderr: result.stderr }; } catch (error) { const result = error as NodeJS.ErrnoException & { code?: number; stdout?: string; stderr?: string }; if (allowFailure && typeof result.code === "number") return { exitCode: result.code, stdout: result.stdout ?? "", stderr: result.stderr ?? "" }; throw error; } } async function stagedEntries(repository: string): Promise { const output = (await run("git", ["ls-files", "--stage"], repository)).stdout.trim(); return output === "" ? [] : output.split("\n"); } async function assertPublishedProjection(repository: string, candidateTree: string): Promise { const stagedTree = (await run("git", ["write-tree"], repository)).stdout.trim(); const entries = await stagedEntries(repository); const stagedPaths = entries.map((entry) => entry.split("\t", 2)[1]); const stagedModes = entries.map((entry) => entry.split(" ", 1)[0]); assert.equal(stagedTree, candidateTree, `staged tree must match frozen candidate; entries: ${entries.join(", ")}`); assert.deepEqual(stagedPaths, [...REVIEWED_PATHS].toSorted()); assert.deepEqual(stagedModes, REVIEWED_MODES); } async function assertScopeChanged(repository: string, lineageId: string): Promise { const deniedCommand = await run(binary, ["review", "validate", "--gate", "pre-commit", "--cwd", repository, "--lineage", lineageId], repository, true); const denied = JSON.parse(deniedCommand.stdout) as ReviewGateResult; assert.equal(deniedCommand.exitCode, 1); assert.equal(denied.result, "scope-changed"); assert.equal(denied.allowed, false); assert.deepEqual(denied.context.denial, { stage: "receipt-binding", code: "candidate-or-paths-mismatch" }); } async function restoreCandidate(repository: string, candidateTree: string): Promise { await writeFile(join(repository, "tracked.txt"), "candidate\n"); await rm(join(repository, "extra-reviewed-path.txt"), { force: true }); await run("git", ["reset", "--", "extra-reviewed-path.txt"], repository); await run("git", ["add", "--", ...REVIEWED_PATHS], repository); await assertPublishedProjection(repository, candidateTree); } async function reviewStatus(repository: string): Promise> { return JSON.parse((await run(binary, ["review", "status", "--cwd", repository], repository)).stdout) as Record; } function authorityInventory(status: Record): ReviewAuthorityEntry[] { assert.ok(Array.isArray(status.entries), "review status must expose authority entries"); return status.entries.map((entry) => { assert.ok(entry !== null && typeof entry === "object", "authority entry must be an object"); const candidate = entry as Record; for (const key of ["version", "lineage_id", "status", "state", "revision"] as const) { assert.equal(typeof candidate[key], "string", `authority entry ${key} must be stable text`); } assert.ok(Array.isArray(candidate.problems), "authority entry problems must be an array"); return { version: candidate.version as string, lineage_id: candidate.lineage_id as string, status: candidate.status as string, state: candidate.state as string, revision: candidate.revision as string, problems: candidate.problems, }; }); } // Every reviewer result reaches authority through `review capture-result`. // `review finalize --result` was removed because a result handed over that way // carries no provider-owned admission, so it cannot prove the lens inspected // the frozen candidate. The bindings are never guessed here: each one comes // from the collect transition the provider itself publishes, which is also // what the removal message tells a caller to do. // // Artifacts stay outside the repository on purpose. Writing them inside changes // the workspace snapshot between deriving a target and using it, and authority // correctly rejects the stale target that results. async function nextTransition(repository: string): Promise> { const status = JSON.parse((await run(binary, ["review", "status", "--contract", "gentle-ai.review-integration/v2", "--cwd", repository, "--projection", "workspace", "--next-transition"], repository)).stdout) as Record; return status.next_transition as Record; } async function captureSelectedLenses(repository: string, artifacts: string, evidenceName: string): Promise { for (let guard = 0; guard < 8; guard += 1) { const transition = await nextTransition(repository); const collect = transition.collect as { inputs?: readonly Record[] } | undefined; if (transition.kind !== "collect" || collect?.inputs === undefined) return; for (const [index, input] of collect.inputs.entries()) { const subject = input.artifact_subject as { subject_hash: string }; const manifest = input.changed_path_manifest as readonly { path: string }[]; const result = join(artifacts, `${evidenceName}-capture-${index}.json`); await writeFile(result, JSON.stringify({ subject_hash: subject.subject_hash, inspection: { status: "completed", paths: manifest.map((entry) => entry.path) }, findings: [], evidence: ["reviewed the complete frozen candidate scope"], })); // The published tokens already carry the repository context, and // capture-result accepts that or --cwd, never both. const tokens = (input.arguments as readonly { token: string }[]).map((argument) => argument.token); const captured = await run(binary, ["review", "capture-result", ...tokens, "--input", result], repository); assert.equal(captured.exitCode, 0, `capture-result failed: ${captured.stderr}`); } } assert.fail("collect transitions did not converge"); } async function finalizeEmptyReview(repository: string, artifacts: string, started: ReviewStart, evidenceName: string): Promise { const evidence = join(artifacts, evidenceName); await writeFile(evidence, `evidence for ${started.lineage_id}\n`); await captureSelectedLenses(repository, artifacts, evidenceName); return run(binary, ["review", "finalize", "--cwd", repository, "--lineage", started.lineage_id, "--captured-results=true", "--evidence", evidence], repository); } test("official pinned package runtime authorizes an unchanged linked-view candidate and denies a changed staging tree", async (t) => { await reviewEnabledHome(t); assert.equal(createHash("sha256").update(await readFile(binary)).digest("hex"), OFFICIAL_BINARY_SHA256); assert.deepEqual(await run(binary, ["version"], packageRoot), { exitCode: 0, stdout: `gentle-ai ${GENTLE_AI_VERSION}\n`, stderr: "" }); const workspace = await mkdtemp(join(tmpdir(), "gentle-pi-v216-parity-")); const repository = join(workspace, "repository"); const view = join(workspace, "candidate-view"); const artifacts = join(workspace, "artifacts"); const temporaryIndex = join(workspace, "controller.index"); t.after(async () => rm(workspace, { recursive: true, force: true })); await mkdir(repository); await mkdir(artifacts); await run("git", ["init", "--initial-branch=main"], repository); await run("git", ["config", "user.email", "test@example.invalid"], repository); await run("git", ["config", "user.name", "Gentle Pi test"], repository); await writeFile(join(repository, "tracked.txt"), "base\n"); await run("git", ["add", "--", "tracked.txt"], repository); await run("git", ["commit", "-m", "base"], repository); const native = new RuntimeNativeReviewCliV216(async (request) => { const command = await run(binary, request.arguments, request.cwd, true); return { ...command, signal: null, timedOut: false, outputLimitExceeded: false }; }); assert.equal((await native.reviewStatus({ cwd: repository })).status, "clean"); await writeFile(join(repository, "tracked.txt"), "candidate\n"); await writeFile(join(repository, "initially-untracked.txt"), "included\n"); const temporaryIndexEnvironment = { ...process.env, GIT_INDEX_FILE: temporaryIndex }; await run("git", ["read-tree", "HEAD"], repository, false, temporaryIndexEnvironment); await run("git", ["add", "--", ...REVIEWED_PATHS], repository, false, temporaryIndexEnvironment); const candidateTree = (await run("git", ["write-tree"], repository, false, temporaryIndexEnvironment)).stdout.trim(); await run("git", ["worktree", "add", "--detach", view, "HEAD"], repository); await run("git", ["read-tree", candidateTree], view); await run("git", ["checkout-index", "--all", "--force"], view); assert.equal((await readFile(join(view, "tracked.txt"), "utf8")), "candidate\n"); assert.equal((await readFile(join(view, "initially-untracked.txt"), "utf8")), "included\n"); const started = JSON.parse((await run(binary, ["review", "start", "--cwd", view], view)).stdout) as ReviewStart; const evidence = join(artifacts, "final-evidence.txt"); await writeFile(evidence, "linked-view parity probe\n"); // The linked view is the root authority froze, so its collect bindings are // derived from the view, not from the contributor repository. await captureSelectedLenses(view, artifacts, "final-evidence.txt"); await run(binary, ["review", "finalize", "--cwd", view, "--lineage", started.lineage_id, "--captured-results=true", "--evidence", evidence], view); await run("git", ["add", "--", ...REVIEWED_PATHS], repository); await assertPublishedProjection(repository, candidateTree); const allowed = JSON.parse((await run(binary, ["review", "validate", "--gate", "pre-commit", "--cwd", repository, "--lineage", started.lineage_id], repository)).stdout) as ReviewGateResult; assert.equal(allowed.result, "allow"); assert.equal(allowed.allowed, true); assert.equal(allowed.context.candidate_tree, candidateTree); assert.equal(allowed.context.paths_digest, CLEAN_DIFFERENTIAL_PUBLISHED_PATHS_DIGEST); t.diagnostic(JSON.stringify({ candidateTree, publishedPathsDigest: CLEAN_DIFFERENTIAL_PUBLISHED_PATHS_DIGEST, reviewedPaths: REVIEWED_PATHS, reviewedModes: REVIEWED_MODES, binary, startCwd: view, finalizeCwd: view, artifacts })); await writeFile(join(repository, "tracked.txt"), "changed-after-review\n"); await run("git", ["add", "--", "tracked.txt"], repository); assert.notEqual((await run("git", ["write-tree"], repository)).stdout.trim(), candidateTree); await assertScopeChanged(repository, started.lineage_id); await restoreCandidate(repository, candidateTree); await writeFile(join(repository, "extra-reviewed-path.txt"), "not in the frozen candidate\n"); await run("git", ["add", "extra-reviewed-path.txt"], repository); assert.notEqual((await run("git", ["write-tree"], repository)).stdout.trim(), candidateTree); assert.deepEqual((await stagedEntries(repository)).map((entry) => entry.split("\t", 2)[1]), [...REVIEWED_PATHS, "extra-reviewed-path.txt"].toSorted()); await assertScopeChanged(repository, started.lineage_id); await restoreCandidate(repository, candidateTree); await chmod(join(repository, "tracked.txt"), 0o755); await run("git", ["add", "--", "tracked.txt"], repository); const modeDrifted = (await stagedEntries(repository)).some((entry) => entry === "100755" || entry.startsWith("100755 ")); if (modeDrifted) { assert.notEqual((await run("git", ["write-tree"], repository)).stdout.trim(), candidateTree); await assertScopeChanged(repository, started.lineage_id); } else { t.diagnostic("skipped native mode-drift denial because this repository/platform does not stage executable-bit changes"); } await chmod(join(repository, "tracked.txt"), 0o644); await restoreCandidate(repository, candidateTree); }); test("official pinned package runtime keeps frozen candidate lineages and receipts isolated across replay and replacement", async (t) => { await reviewEnabledHome(t); const workspace = await mkdtemp(join(tmpdir(), "gentle-pi-v215-lineage-")); const repository = join(workspace, "repository"); const artifacts = join(workspace, "artifacts"); t.after(async () => rm(workspace, { recursive: true, force: true })); await mkdir(repository); await mkdir(artifacts); await run("git", ["init", "--initial-branch=main"], repository); await run("git", ["config", "user.email", "test@example.invalid"], repository); await run("git", ["config", "user.name", "Gentle Pi test"], repository); await writeFile(join(repository, "tracked.txt"), "base\n"); await run("git", ["add", "--", "tracked.txt"], repository); await run("git", ["commit", "-m", "base"], repository); await writeFile(join(repository, "tracked.txt"), "candidate one\n"); const first = JSON.parse((await run(binary, ["review", "start", "--cwd", repository], repository)).stdout) as ReviewStart; const firstInventory = authorityInventory(await reviewStatus(repository)); const firstReplay = JSON.parse((await run(binary, ["review", "start", "--cwd", repository], repository)).stdout) as ReviewStart; const firstReplayInventory = authorityInventory(await reviewStatus(repository)); assert.equal(firstReplay.lineage_id, first.lineage_id, "replaying an exact START must reuse the frozen lineage"); assert.deepEqual(firstReplayInventory, firstInventory, "replaying an exact START must not create durable authority"); const firstFinalized = JSON.parse((await finalizeEmptyReview(repository, artifacts, first, "first-evidence.txt")).stdout) as ReviewFinalize; const firstFinalizedInventory = authorityInventory(await reviewStatus(repository)); // v2.1.7 terminal replay contract: replay uses the exact explicit lineage with // no mutation inputs; re-sending reviewer results after approval is rejected. const repeatedResults = await finalizeEmptyReview(repository, artifacts, first, "first-evidence.txt").catch((error: NodeJS.ErrnoException & { stderr?: string }) => error); assert.match((repeatedResults as { stderr?: string }).stderr ?? "", /terminal review finalize accepts no review inputs; exact replay requires only --lineage/, "terminal authority must reject replayed reviewer results"); const firstFinalizeReplay = JSON.parse((await run(binary, ["review", "finalize", "--cwd", repository, "--lineage", first.lineage_id], repository)).stdout) as ReviewFinalize; const firstFinalizeReplayInventory = authorityInventory(await reviewStatus(repository)); assert.equal(firstFinalized.lineage_id, first.lineage_id); assert.equal(firstFinalized.state, "approved"); assert.match(firstFinalized.store_revision, /^sha256:[a-f0-9]{64}$/); assert.equal(firstFinalizeReplay.store_revision, firstFinalized.store_revision, "replaying an exact FINALIZE must reuse its receipt revision"); assert.equal(firstFinalizeReplay.receipt_path, firstFinalized.receipt_path, "replaying an exact FINALIZE must reuse its receipt location"); assert.deepEqual(firstFinalizeReplayInventory, firstFinalizedInventory, "replaying an exact FINALIZE must not create a durable receipt or authority"); assert.deepEqual(firstFinalizedInventory, [{ version: "compact-v2", lineage_id: first.lineage_id, status: "approved", state: "approved", revision: firstFinalized.store_revision, problems: [] }]); await run("git", ["add", "--", "tracked.txt"], repository); const firstCandidateTree = (await run("git", ["write-tree"], repository)).stdout.trim(); const firstAllowed = JSON.parse((await run(binary, ["review", "validate", "--gate", "pre-commit", "--cwd", repository, "--lineage", first.lineage_id], repository)).stdout) as ReviewGateResult; assert.equal(firstAllowed.result, "allow"); assert.equal(firstAllowed.context.candidate_tree, firstCandidateTree); await writeFile(join(repository, "tracked.txt"), "candidate two\n"); await run("git", ["add", "--", "tracked.txt"], repository); const secondCandidateTree = (await run("git", ["write-tree"], repository)).stdout.trim(); assert.notEqual(secondCandidateTree, firstCandidateTree); const authorityBeforeCompetingStart = authorityInventory(await reviewStatus(repository)); const competingStart = await run(binary, ["review", "start", "--cwd", repository, "--lineage", first.lineage_id], repository); const competingStartResult = JSON.parse(competingStart.stdout) as ReviewStart; const authorityAfterCompetingStart = authorityInventory(await reviewStatus(repository)); assert.equal(competingStartResult.action, "blocked-scope-action", "a frozen lineage must return a structured scope-action block for a competing candidate"); assert.equal(competingStartResult.lineage_id, first.lineage_id); assert.equal(competingStartResult.state, "approved"); assert.deepEqual(authorityAfterCompetingStart, authorityBeforeCompetingStart, "a blocked scope action must not mutate approved authority"); // A plain START over the moved candidate does NOT mint a fresh lineage. An // approved lineage whose scope changed is recovered, keeping the operator // pointed at the receipt that already exists instead of quietly abandoning // it, and the recovery itself needs a maintainer to authorize it. Pi mirrors // this because the experience must be the one gentle-ai defines, not a // friendlier one Pi invents. const second = JSON.parse((await run(binary, ["review", "start", "--cwd", repository], repository)).stdout) as ReviewStart; const secondInventory = authorityInventory(await reviewStatus(repository)); assert.equal(second.action, "recover"); assert.equal(second.state, "approved"); assert.equal(second.lineage_id, first.lineage_id, "a moved candidate recovers the approved lineage rather than replacing it"); const secondReplay = JSON.parse((await run(binary, ["review", "start", "--cwd", repository], repository)).stdout) as ReviewStart; const secondReplayInventory = authorityInventory(await reviewStatus(repository)); assert.equal(secondReplay.action, "recover", "recovery is idempotent"); assert.equal(secondReplay.lineage_id, second.lineage_id, "replaying the second START must reuse its lineage"); assert.deepEqual(secondReplayInventory, secondInventory, "replaying the second START must not duplicate durable authority"); const recoveryTransition = await nextTransition(repository); assert.equal(recoveryTransition.kind, "collect"); assert.equal(recoveryTransition.reason_code, "recovery_authorization_required", "recovery must ask a maintainer, never proceed on its own"); // No receipt covers the moved candidate, so delivery is denied rather than // waved through, and the denial names `gentle-ai review recover` with the // inputs it needs. A blocked gate that names its exit is the whole point. await assertScopeChanged(repository, first.lineage_id); await writeFile(join(repository, "tracked.txt"), "candidate one\n"); await run("git", ["add", "--", "tracked.txt"], repository); assert.equal((await run("git", ["write-tree"], repository)).stdout.trim(), firstCandidateTree); const firstRestored = JSON.parse((await run(binary, ["review", "validate", "--gate", "pre-commit", "--cwd", repository, "--lineage", first.lineage_id], repository)).stdout) as ReviewGateResult; assert.equal(firstRestored.result, "allow", "the old receipt must remain valid for its exact frozen candidate"); await writeFile(join(repository, "tracked.txt"), "candidate two\n"); await run("git", ["add", "--", "tracked.txt"], repository); assert.equal((await run("git", ["write-tree"], repository)).stdout.trim(), secondCandidateTree); t.diagnostic("pre-push, pre-pr, and release require remote/publication evidence; their network-aware gate contracts remain covered by dedicated gate integration tests rather than this hermetic binary E2E."); }); test("registered gentle_review START materializes a safe internal skill symlink and cleans pending consent on session shutdown", async (t) => { await reviewEnabledHome(t); const workspace = await mkdtemp(join(tmpdir(), "gentle-pi-v215-symlink-candidate-")); const repository = join(workspace, "repository"); t.after(async () => rm(workspace, { recursive: true, force: true })); await mkdir(join(repository, ".agents", "skills", "example"), { recursive: true }); await mkdir(join(repository, ".agent", "skills"), { recursive: true }); await writeFile(join(repository, "tracked.txt"), "base\n"); await writeFile(join(repository, ".agents", "skills", "example", "SKILL.md"), "---\nname: example\n---\n"); const link = join(repository, ".agent", "skills", "example"); const linkTarget = "../../.agents/skills/example"; await symlink(linkTarget, link); const lexicalTarget = resolve(dirname(link), linkTarget); const lexicalRelative = relative(repository, lexicalTarget); assert.ok(lexicalRelative !== "" && !lexicalRelative.startsWith("..") && !isAbsolute(lexicalRelative), "the internal symlink target must resolve lexically inside the repository"); await run("git", ["init", "--initial-branch=main"], repository); await run("git", ["config", "user.email", "test@example.invalid"], repository); await run("git", ["config", "user.name", "Gentle Pi test"], repository); await run("git", ["add", "--", "tracked.txt", ".agents", ".agent"], repository); await run("git", ["commit", "-m", "base with internal skill symlink"], repository); await writeFile(join(repository, "tracked.txt"), "candidate\n"); const candidateViews = new CandidateViewRegistry(); let nativeStartReached = false; // The production controller pairs with the negotiated client; v2.1.9's ordinary // (non-negotiated) START output carries additional facade fields that the // pinned legacy decoder intentionally rejects. const native = new NativeReviewCliV216(async (request) => { if (request.arguments[0] === "review" && request.arguments[1] === "start") nativeStartReached = true; const command = await run(binary, request.arguments, request.cwd, true); return { ...command, signal: null, timedOut: false, outputLimitExceeded: false }; }); const tools = new Map(); let sessionShutdown: ((event: unknown, context: ExtensionContext) => Promise | void) | undefined; createGentleAiExtension({ nativeReviewCli: native, candidateViews } as Parameters[0])({ on(name: string, handler: (event: unknown, context: ExtensionContext) => Promise | void) { if (name === "session_shutdown") sessionShutdown = handler; }, registerTool(definition: RegisteredController & { name: string }) { tools.set(definition.name, definition); }, registerCommand() {}, } as unknown as ExtensionAPI); const controller = tools.get("gentle_review"); assert.ok(controller); let returned: { details?: unknown } | undefined; let thrown: unknown; try { // A headless context still receives notices, so the stub carries notify. // Without it the consent path throws on a real ExtensionContext member, // and the failure reads as a START problem rather than a stub gap. returned = await controller.execute("issue-146-start", { operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, undefined, undefined, { cwd: repository, hasUI: false, ui: { confirm: async () => true, notify: () => {} } } as unknown as ExtensionContext); } catch (caught) { thrown = caught; } const error = thrown instanceof Error ? { name: thrown.name, message: thrown.message } : thrown === undefined ? undefined : String(thrown); t.diagnostic(JSON.stringify({ returned: returned?.details, error, nativeStartReached })); assert.equal(thrown, undefined, "safe internal symlink materialization must not throw before START"); assert.equal(nativeStartReached, true, "safe internal symlink materialization must reach native START"); const result = returned?.details as Record | undefined; assert.equal(result?.status, "blocked"); assert.equal(result?.outcome, "native-review-consent-required"); assert.equal(typeof result?.consent_binding, "string"); assert.equal(result?.lineage_created, false); assert.ok(sessionShutdown, "extension must register session_shutdown cleanup"); const context = { cwd: repository, hasUI: false, ui: { notify: () => {} } } as unknown as ExtensionContext; await sessionShutdown({}, context); await assert.rejects( controller.execute("answer-after-shutdown", { operation: "answer-consent", input: JSON.stringify({ consentBinding: result!.consent_binding, answer: "granted" }) }, undefined, undefined, context), /unknown, expired, or already consumed/, ); });