diff --git a/src/main/runtime/acp/acpSession.test.ts b/src/main/runtime/acp/acpSession.test.ts index ac02943..bb4ba0e 100644 --- a/src/main/runtime/acp/acpSession.test.ts +++ b/src/main/runtime/acp/acpSession.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import { homedir } from 'os' -import { pickAutoApprove, acpCwd } from './acpSession' +import { pickAutoApprove, acpCwd, shouldAutoCancelPermission } from './acpSession' describe('pickAutoApprove', () => { it('picks the first allow-kind option', () => { @@ -15,6 +15,17 @@ describe('pickAutoApprove', () => { }) }) +describe('shouldAutoCancelPermission', () => { + it('auto-cancels when no run is active or during session/load replay (else the JSON-RPC request deadlocks)', () => { + expect(shouldAutoCancelPermission(false, null)).toBe(true) // no active run + expect(shouldAutoCancelPermission(true, 'run_1')).toBe(true) // replaying loaded history + expect(shouldAutoCancelPermission(true, null)).toBe(true) + }) + it('surfaces the card only during a live, non-replaying run', () => { + expect(shouldAutoCancelPermission(false, 'run_1')).toBe(false) + }) +}) + describe('acpCwd', () => { it('expands a stored ~ workspace path to absolute (copilot session/new rejects non-absolute)', () => { expect(acpCwd('~/Code/nac-code')).toBe(`${homedir()}/Code/nac-code`) diff --git a/src/main/runtime/acp/acpSession.ts b/src/main/runtime/acp/acpSession.ts index d783883..49e72be 100644 --- a/src/main/runtime/acp/acpSession.ts +++ b/src/main/runtime/acp/acpSession.ts @@ -18,6 +18,13 @@ export function pickAutoApprove(options: PermissionOption[]): PermissionOption | return options.find((o) => o.kind === 'allow' || o.kind === 'allow_always') } +/** Pure + exported for testing: a permission request must be auto-cancelled (never queued as a card) + * when no run is active or during session/load history replay — otherwise the pending permission + * can never be resolved and the JSON-RPC request deadlocks the harness. */ +export function shouldAutoCancelPermission(replaying: boolean, currentRunId: string | null): boolean { + return replaying || !currentRunId +} + /** Pure + exported for testing: ACP session cwd. copilot's session/new rejects a non-absolute path * (`-32603 "Directory path must be absolute"`), so a stored `~/…` workspace path MUST be expanded — * the same resolveCwd every one-shot adapter uses. Falls back to process cwd when unset. */ @@ -90,7 +97,11 @@ export class AcpSession implements TransportSession { } private handlePermission(params: unknown): Promise { - const runId = this.currentRunId ?? 'unknown' + // No active run, or session/load history replay: there is no UI turn to surface a card on, so a + // pending permission stored here could never be resolved and would deadlock the JSON-RPC request + // (blocking the harness). Auto-cancel instead. Mirrors the session/update notification guard above. + if (shouldAutoCancelPermission(this.replaying, this.currentRunId)) return Promise.resolve({ outcome: { outcome: 'cancelled' } }) + const runId = this.currentRunId! // guard above guarantees non-null const requestId = `perm_${++this.permissionSeq}` const event = mapPermissionRequest(runId, requestId, params) if (!event) return Promise.resolve({ outcome: { outcome: 'cancelled' } }) // zero options: never hang diff --git a/src/main/runtime/ipc.ts b/src/main/runtime/ipc.ts index 3f40e24..e9c546c 100644 --- a/src/main/runtime/ipc.ts +++ b/src/main/runtime/ipc.ts @@ -77,7 +77,9 @@ export function registerRuntimeIpc(getWindow: () => BrowserWindow | null): void // Interactive-first: persistent ACP session; on { ok: false } fall back to the one-shot path. void promptViaAcp({ chatId: req.chatId ?? runId, runId, prompt: req.prompt, cwd: req.cwd, yolo: req.yolo, sessionId: req.sessionId, onEvent: handler }).then(({ ok }) => { if (!ok) { - handler({ type: 'content.delta', runId, streamKind: 'assistant_text', text: '\n[interactive session unavailable — ran headless]\n' }) + // Render-only notice (a tool.updated row), NOT content.delta: a transport diagnostic must + // not enter turn.text, or buildReplayPrompt would replay it to the next provider as assistant speech. + handler({ type: 'tool.updated', runId, toolCallId: `fallback_${runId}`, title: 'interactive session unavailable — ran headless', kind: 'notice', status: 'failed' }) runs.set(runId, startCopilotRun(runId, { prompt: req.prompt, cwd: req.cwd, yolo: req.yolo, sessionId: req.sessionId, effort: req.effort, model: req.model }, handler)) } })