Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/main/runtime/acp/acpSession.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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`)
Expand Down
13 changes: 12 additions & 1 deletion src/main/runtime/acp/acpSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -90,7 +97,11 @@ export class AcpSession implements TransportSession {
}

private handlePermission(params: unknown): Promise<unknown> {
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
Expand Down
4 changes: 3 additions & 1 deletion src/main/runtime/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
})
Expand Down