import assert from "node:assert/strict"; import test from "node:test"; import type { ExtensionAPI, ExtensionContext, } from "@earendil-works/pi-coding-agent"; import type { WorkflowCommand, WorkflowView } from "./engine.ts"; import { compactWorkflowDetails, registerWorkflowTool, workflowCommandFromTool, } from "./tool.ts"; import { WorkspaceValidationError } from "./workspace.ts"; function workflowView(overrides: Partial = {}): WorkflowView { return { runId: "run-1", featureName: "search-page", request: "Build search page", revision: 4, status: "waiting", workspace: { state: "ready", sourceRoot: "/repo", root: "/repo/.worktrees/run-1", branch: "feature/search", baseHead: "abc123", }, uiImpactEvidence: [], affectedUiSurfaces: [], stages: [], assignments: [], mutatorProfileNames: [], reviewCycles: 0, verificationCycles: 0, ...overrides, background: overrides.background ?? false, }; } test("tool commands derive active tools for feature and analysis scripts", () => { assert.deepEqual( workflowCommandFromTool( { action: "start", request: "Build search", featureName: "search-page", idempotencyKey: "feature-key", workflow: { mode: "feature-script", source: "return null", args: '{"scope":"routes"}', insertionPoint: "before-design", }, }, ["read", "grep", "bash"], ), { action: "start", request: "Build search", featureName: "search-page", idempotencyKey: "feature-key", script: { source: "return null", args: { scope: "routes" }, insertionPoint: "before-design", activeToolNames: ["read", "grep", "bash"], }, }, ); const analysis = workflowCommandFromTool( { action: "start", request: "Audit persistence", workflow: { mode: "analysis-script", source: "return await assign('review', 'Inspect')", }, idempotencyKey: "analysis-key", }, ["read", "ls"], ); assert.deepEqual( workflowCommandFromTool( { action: "start", request: "Build search", featureName: "search-page", idempotencyKey: "dirty-feature-key", allowDirtySource: true, }, ["read"], ), { action: "start", request: "Build search", featureName: "search-page", idempotencyKey: "dirty-feature-key", allowDirtySource: true, }, ); assert.equal(analysis.action, "start-analysis"); assert.deepEqual( analysis.action === "start-analysis" ? analysis.activeToolNames : [], ["read", "ls"], ); }); test("tool commands reject open or incomplete action shapes", () => { assert.throws( () => workflowCommandFromTool( { action: "start", request: "Build search", featureName: "search-page", idempotencyKey: "feature-key", workflow: { mode: "feature-script", source: "return null" }, }, ["read"], ), /requires workflow.source and workflow.insertionPoint/, ); assert.throws( () => workflowCommandFromTool( { action: "decide", runId: "run-1", gateId: "gate-1", expectedRevision: 2, idempotencyKey: "decision-key", decision: undefined, }, ["read"], ), /decision is required/, ); }); test("compact tool details expose only valid continuation intents", () => { const details = compactWorkflowDetails( "inspect", workflowView({ openGate: { id: "gate-1", kind: "implementation-context", status: "open", revision: 4, openedAt: 1, }, planArtifact: { path: "/state/plan.md", revision: 2, digest: "digest" }, implementationContext: { gateId: "gate-1", runRevision: 3, planRevision: 2, preparationId: "prep-1", activeParentSessionId: "session-1", strategy: "fresh-session", status: "handoff-pending", method: "fresh-session", }, }), ); assert.deepEqual(details.continuation, { command: "/workflow continue", runId: "run-1", }); assert.deepEqual(details.openGate?.payload, { preparationId: "prep-1", planRevision: 2, contextRevision: 3, strategy: "fresh-session", status: "handoff-pending", method: "fresh-session", outcome: undefined, error: undefined, }); assert.equal( compactWorkflowDetails( "inspect", workflowView({ openGate: { id: "gate-1", kind: "implementation-context", status: "open", revision: 4, openedAt: 1, }, planArtifact: { path: "/state/plan.md", revision: 2, digest: "digest" }, implementationContext: { gateId: "gate-1", runRevision: 3, planRevision: 2, preparationId: "prep-1", activeParentSessionId: "session-1", strategy: "fresh-session", status: "handoff-pending", method: "fresh-session", }, }), "session-2", ).continuation, undefined, ); assert.equal( compactWorkflowDetails("inspect", workflowView()).continuation, undefined, ); }); test("TUI dirty-source starts distinguish omitted, refused, and approved flags", async () => { let definition: Parameters[0] | undefined; const commands: WorkflowCommand[] = []; const pi = { registerTool(candidate: Parameters[0]) { definition = candidate; }, getActiveTools: () => ["read"], } as unknown as ExtensionAPI; registerWorkflowTool(pi, { store: {} as never, async engineForContext() { return { async execute(command: WorkflowCommand) { commands.push(structuredClone(command)); if (command.action === "start" && !command.allowDirtySource) { throw new WorkspaceValidationError( "dirty-source", "source changes are omitted", { sourceChanges: [{ status: "??", path: "local-notes.md" }], sourceChangesOmitted: 2, }, ); } return workflowView(); }, } as never; }, }); assert.ok(definition); let confirmation = ""; await definition.execute( "dirty-call", { action: "start", request: "Build search", featureName: "search-page", idempotencyKey: "dirty-key", }, new AbortController().signal, undefined, { mode: "tui", sessionManager: { getSessionId: () => "session-1" }, ui: { custom: async (factory: ( tui: { requestRender(): void; terminal: { rows: number } }, theme: { fg(color: string, text: string): string; bg(color: string, text: string): string }, keybindings: object, done: (value: boolean) => void, ) => { render(width: number): string[] }) => { const component = factory( { requestRender() {}, terminal: { rows: 24 } }, { fg: (_color, text) => text, bg: (_color, text) => text, }, {}, () => {}, ); confirmation = component.render(100).join("\n"); return true; }, }, } as unknown as ExtensionContext, ); assert.match(confirmation, /local-notes\.md/); assert.match(confirmation, /2 additional paths omitted/); assert.deepEqual( commands.map((command) => command.action === "start" ? command.allowDirtySource : undefined, ), [false, true], ); const explicitContext = { mode: "tui", sessionManager: { getSessionId: () => "session-1" }, ui: { custom: async () => { confirmation = "unexpected prompt"; return false; }, }, } as unknown as ExtensionContext; confirmation = ""; await assert.rejects( definition.execute( "dirty-call-refused", { action: "start", request: "Build search", featureName: "search-page", idempotencyKey: "dirty-key-refused", allowDirtySource: false, }, new AbortController().signal, undefined, explicitContext, ), /source changes are omitted/, ); assert.equal(confirmation, ""); await definition.execute( "dirty-call-approved", { action: "start", request: "Build search", featureName: "search-page", idempotencyKey: "dirty-key-approved", allowDirtySource: true, }, new AbortController().signal, undefined, explicitContext, ); assert.equal(confirmation, ""); assert.deepEqual( commands.map((command) => command.action === "start" ? command.allowDirtySource : undefined, ), [false, true, false, true], ); }); test("RPC dirty-source starts never open TUI overlays and require the flag", async () => { let definition: Parameters[0] | undefined; const commands: WorkflowCommand[] = []; const pi = { registerTool(candidate: Parameters[0]) { definition = candidate; }, getActiveTools: () => ["read"], } as unknown as ExtensionAPI; registerWorkflowTool(pi, { store: {} as never, async engineForContext() { return { async execute(command: WorkflowCommand) { commands.push(structuredClone(command)); if (command.action === "start" && !command.allowDirtySource) { throw new WorkspaceValidationError("dirty-source", "explicit approval required"); } return workflowView(); }, } as never; }, }); assert.ok(definition); const ctx = { mode: "rpc", sessionManager: { getSessionId: () => "session-1" }, ui: { custom: async () => { throw new Error("RPC must not open a custom overlay"); }, }, } as unknown as ExtensionContext; await assert.rejects( definition.execute( "rpc-dirty-rejected", { action: "start", request: "Build search", featureName: "search-page", idempotencyKey: "dirty-key-rejected", }, new AbortController().signal, undefined, ctx, ), /explicit approval required/, ); await assert.rejects( definition.execute( "rpc-dirty-refused", { action: "start", request: "Build search", featureName: "search-page", idempotencyKey: "dirty-key-refused", allowDirtySource: false, }, new AbortController().signal, undefined, ctx, ), /explicit approval required/, ); await definition.execute( "rpc-dirty-approved", { action: "start", request: "Build search", featureName: "search-page", idempotencyKey: "dirty-key-approved", allowDirtySource: true, }, new AbortController().signal, undefined, ctx, ); assert.deepEqual( commands.map((command) => command.action === "start" ? command.allowDirtySource : undefined, ), [undefined, false, true], ); }); test("start tool cancellation waits for engine settlement before rejecting", async () => { let definition: Parameters[0] | undefined; let markStarted = () => {}; let releaseStart = () => {}; const started = new Promise((resolve) => { markStarted = resolve; }); const blocked = new Promise((resolve) => { releaseStart = resolve; }); let cancelled = false; let cancellationReason = new Error("cancelled before mutation"); let laterMutation = false; let engineOperation: Promise | undefined; const pi = { registerTool(candidate: Parameters[0]) { definition = candidate; }, getActiveTools: () => ["read"], } as unknown as ExtensionAPI; registerWorkflowTool(pi, { store: {} as never, async engineForContext() { return { async cancelStart( _idempotencyKey: string, _abortIdempotencyKey: string, reason: Error, ) { cancelled = true; cancellationReason = reason; await engineOperation?.catch(() => {}); }, execute() { engineOperation = (async () => { markStarted(); await blocked; if (cancelled) throw cancellationReason; laterMutation = true; return workflowView(); })(); return engineOperation; }, } as never; }, }); assert.ok(definition); const controller = new AbortController(); const operation = definition.execute( "cancel-slow-start", { action: "start", request: "Build search", featureName: "search-page", idempotencyKey: "cancel-slow-start-key", }, controller.signal, undefined, { mode: "rpc", sessionManager: { getSessionId: () => "session-1" }, } as unknown as ExtensionContext, ); await started; let toolSettled = false; void operation.then( () => { toolSettled = true; }, () => { toolSettled = true; }, ); controller.abort(new Error("cancelled by tool caller")); await Promise.resolve(); assert.equal(toolSettled, false); releaseStart(); await assert.rejects(operation, /cancelled by tool caller/); await new Promise((resolve) => setImmediate(resolve)); assert.equal(laterMutation, false); }); test("cancellation during dirty-source confirmation cannot start a retry", async () => { let definition: Parameters[0] | undefined; const commands: WorkflowCommand[] = []; let worktreeCreations = 0; let showConfirmation = () => {}; const confirmationShown = new Promise((resolve) => { showConfirmation = resolve; }); let finishConfirmation = (_value: boolean) => {}; const confirmation = new Promise((resolve) => { finishConfirmation = resolve; }); const pi = { registerTool(candidate: Parameters[0]) { definition = candidate; }, getActiveTools: () => ["read"], } as unknown as ExtensionAPI; registerWorkflowTool(pi, { store: { async getStart() { return { runId: "not-created" }; }, async loadRun() { throw new Error("not created"); }, } as never, async engineForContext() { return { async cancelStart() {}, async execute(command: WorkflowCommand) { commands.push(structuredClone(command)); if (command.action === "start" && command.allowDirtySource === true) { worktreeCreations++; return workflowView(); } throw new WorkspaceValidationError("dirty-source", "explicit approval required"); }, } as never; }, }); assert.ok(definition); const controller = new AbortController(); const operation = definition.execute( "cancel-dirty-confirmation", { action: "start", request: "Build search", featureName: "search-page", idempotencyKey: "cancel-dirty-key", }, controller.signal, undefined, { mode: "tui", sessionManager: { getSessionId: () => "session-1" }, ui: { custom: async () => { showConfirmation(); return confirmation; }, }, } as unknown as ExtensionContext, ); await confirmationShown; controller.abort(new Error("cancelled while confirming")); await assert.rejects(operation, /cancelled while confirming/); finishConfirmation(true); await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual( commands.map((command) => command.action === "start" ? command.allowDirtySource : undefined, ), [false], ); assert.equal(worktreeCreations, 0); }); test("tool cancellation aborts its active Workflow Run before rejecting", async () => { let definition: Parameters[0] | undefined; const commands: WorkflowCommand[] = []; let finishDecision = (_view: WorkflowView) => {}; const decision = new Promise((resolve) => { finishDecision = resolve; }); const pi = { registerTool(candidate: Parameters[0]) { definition = candidate; }, getActiveTools: () => ["read"], } as unknown as ExtensionAPI; registerWorkflowTool(pi, { store: { async loadRun() { return { runId: "run-1", revision: 4, status: "running" }; }, } as never, async engineForContext() { return { async execute(command: WorkflowCommand) { commands.push(structuredClone(command)); if (command.action === "abort") { const aborted = workflowView({ status: "aborted", revision: 5 }); finishDecision(aborted); return aborted; } return decision; }, } as never; }, }); assert.ok(definition); const controller = new AbortController(); const operation = definition.execute( "cancel-call", { action: "decide", runId: "run-1", gateId: "gate-1", expectedRevision: 4, idempotencyKey: "decision-key", decision: { kind: "recover", resolution: "inspect" }, }, controller.signal, undefined, { sessionManager: { getSessionId: () => "session-1" }, } as unknown as ExtensionContext, ); controller.abort(new Error("cancelled by test")); await assert.rejects(operation, /cancelled by test/); assert.equal(commands.some((command) => command.action === "abort"), true); }); test("inspect errors retain the supplied run ID when no label can be derived", async () => { let definition: Parameters[0] | undefined; const pi = { registerTool(candidate: Parameters[0]) { definition = candidate; }, getActiveTools: () => ["read"], } as unknown as ExtensionAPI; registerWorkflowTool(pi, { store: {} as never, async engineForContext() { return { async execute() { throw new Error("not found"); }, } as never; }, }); assert.ok(definition); await assert.rejects( definition.execute( "inspect-call", { action: "inspect", runId: "missing-run-id" }, new AbortController().signal, undefined, { sessionManager: { getSessionId: () => "session-1" }, } as unknown as ExtensionContext, ), /Workflow Run missing-run-id could not be loaded/, ); }); test("registered tool executes through the context engine and injects host tools", async () => { let definition: Parameters[0] | undefined; const commands: WorkflowCommand[] = []; const pi = { registerTool(candidate: Parameters[0]) { definition = candidate; }, getActiveTools() { return ["read", "smart_compact"]; }, } as unknown as ExtensionAPI; registerWorkflowTool(pi, { store: {} as never, async engineForContext() { return { async execute(command: WorkflowCommand) { commands.push(structuredClone(command)); return workflowView(); }, } as never; }, }); assert.equal(definition?.name, "workflow"); assert.ok(definition); const result = await definition.execute( "call-1", { action: "decide", runId: "run-1", gateId: "gate-1", expectedRevision: 4, idempotencyKey: "context-report", decision: { kind: "report-context-preparation", preparationId: "prep-1", planRevision: 2, method: "smart_compact", outcome: "succeeded", }, }, new AbortController().signal, undefined, { sessionManager: { getSessionId: () => "session-1" }, } as unknown as ExtensionContext, ); assert.deepEqual( commands[0]?.action === "decide" ? commands[0].activeToolNames : undefined, ["read", "smart_compact"], ); assert.match(result.content[0]?.type === "text" ? result.content[0].text : "", /Workflow search-page/); assert.equal((result.details as { runId: string }).runId, "run-1"); });