diff --git a/packages/coding-agent/.changes/remote-message-single-send.md b/packages/coding-agent/.changes/remote-message-single-send.md new file mode 100644 index 0000000000..6ebe037872 --- /dev/null +++ b/packages/coding-agent/.changes/remote-message-single-send.md @@ -0,0 +1 @@ +- Fixed remote agent messages being delivered twice when the daemon request timed out or the response was lost: the message is now sent exactly once per call, and post-send failures surface as errors instead of triggering a resend. diff --git a/packages/coding-agent/.changes/remove-rpc-blanket-timeouts.md b/packages/coding-agent/.changes/remove-rpc-blanket-timeouts.md new file mode 100644 index 0000000000..b9e15b8d33 --- /dev/null +++ b/packages/coding-agent/.changes/remove-rpc-blanket-timeouts.md @@ -0,0 +1 @@ +- Allowed long-running RPC commands and agent turns to complete without fixed client timeouts. diff --git a/packages/coding-agent/.changes/snimu-tui-queue-single-source.md b/packages/coding-agent/.changes/snimu-tui-queue-single-source.md new file mode 100644 index 0000000000..09067e72b8 --- /dev/null +++ b/packages/coding-agent/.changes/snimu-tui-queue-single-source.md @@ -0,0 +1 @@ +- Fixed queued-message editing so duplicate prompts always target the selected queue entry. diff --git a/packages/coding-agent/.changes/supervised-rename-authority.md b/packages/coding-agent/.changes/supervised-rename-authority.md new file mode 100644 index 0000000000..b56a24e5e5 --- /dev/null +++ b/packages/coding-agent/.changes/supervised-rename-authority.md @@ -0,0 +1 @@ +- Fixed supervised session renames failing after the supervisor approved an available name. diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 0bda7e5ea5..bc6e83cd32 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -3988,7 +3988,7 @@ export class AgentDaemon { if (!name) { throw new Error("Session name cannot be empty"); } - await this.setStateSessionName(state, name); + await this.setStateSessionNameForCommand(state, name); return success(command.id, "rename", summaryForActiveSession(state)); } @@ -4002,7 +4002,7 @@ export class AgentDaemon { throw new Error("Session name cannot be empty"); } if (state) { - await this.setStateSessionName(state, name); + await this.setStateSessionNameForCommand(state, name); } else { const info = await readSessionInfo(command.sessionPath); if (!info) throw new Error(`Session not found: ${command.sessionPath}`); @@ -4898,7 +4898,7 @@ export class AgentDaemon { if (!name) { throw new Error("Session name cannot be empty"); } - await this.setStateSessionName(state, name); + await this.setStateSessionNameForCommand(state, name); return success(command.id, "set_session_name"); } @@ -5572,6 +5572,15 @@ export class AgentDaemon { } } + private setStateSessionNameForCommand(state: ActiveSessionState, name: string): Promise { + return this.options.worker ? this.applyStateSessionName(state, name) : this.setStateSessionName(state, name); + } + + private async applyStateSessionName(state: ActiveSessionState, name: string): Promise { + state.runtime.session.setSessionName(name); + await this.appendRlmLedgerRenameForState(state, name); + } + private async setStateSessionName(state: ActiveSessionState, name: string): Promise { const normalizedName = name.trim(); if (!normalizedName) { @@ -5594,8 +5603,7 @@ export class AgentDaemon { }, async () => { await this.assertStateSessionNameAvailable(state, normalizedName); - state.runtime.session.setSessionName(normalizedName); - await this.appendRlmLedgerRenameForState(state, normalizedName); + await this.applyStateSessionName(state, normalizedName); }, ); } @@ -5905,42 +5913,45 @@ export class AgentDaemon { throw new Error(`Unknown active session: ${targetSelector}`); } const deadline = Date.now() + 30_000; + let client: DaemonClient | undefined; let lastError: unknown; while (Date.now() < deadline && !this.shuttingDown) { - const client = new DaemonClient(supervisorSocketPath); - let receivedResponse = false; + const candidate = new DaemonClient(supervisorSocketPath); try { - await client.connect(1000); - await client.waitForHello(1000); - const response = await client.request( - { - type: "send_message", - targetActiveSessionId: targetSelector, - message, - fromActiveSessionId: fromState.activeSessionId, - agentOrigin: true, - }, - 30_000, - ); - receivedResponse = true; - if (!response.success) { - throw deserializeDaemonError(response); - } - if (!response.data || typeof response.data !== "object") { - throw new Error("Supervisor returned an invalid agent-message receipt"); - } - return response.data as AgentSessionMessageReceipt; + await candidate.connect(1000); + await candidate.waitForHello(1000); + client = candidate; + break; } catch (error) { lastError = error; - if (receivedResponse) { - throw error; - } - } finally { - client.close(); + candidate.close(); } await new Promise((resolveDelay) => setTimeout(resolveDelay, 250)); } - throw lastError instanceof Error ? lastError : new Error(`Unknown active session: ${targetSelector}`); + if (!client) { + throw lastError instanceof Error ? lastError : new Error(`Unknown active session: ${targetSelector}`); + } + try { + const response = await client.request( + { + type: "send_message", + targetActiveSessionId: targetSelector, + message, + fromActiveSessionId: fromState.activeSessionId, + agentOrigin: true, + }, + 30_000, + ); + if (!response.success) { + throw deserializeDaemonError(response); + } + if (!response.data || typeof response.data !== "object") { + throw new Error("Supervisor returned an invalid agent-message receipt"); + } + return response.data as AgentSessionMessageReceipt; + } finally { + client.close(); + } } private async acceptAgentSessionMessage( diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index db5abdb5c6..59de91fc4c 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -252,7 +252,7 @@ import { shouldRunPrimeCliOnboardingSplash, } from "./onboarding.js"; import type { ClientPromptStashStore, PromptStash, PromptStashState } from "./prompt-stash-state.js"; -import { QueueSelection } from "./queue-selection.js"; +import { QueueSelection, type QueueSelectionItem } from "./queue-selection.js"; import { formatResumeHint } from "./resume-hint.js"; import { getAvailableThemes, @@ -1017,12 +1017,11 @@ export class InteractiveMode { private retryCountdown: CountdownTimer | undefined = undefined; private traceUploadAllAbortController: AbortController | undefined = undefined; - // Session-owned queued messages mirrored from connection events. - private connectionQueue: AgentConnectionQueueState = { steering: [], followUp: [] }; private readonly queueSelection = new QueueSelection(); private isApplyingQueueSelectionText = false; private queueMutationChain: Promise = Promise.resolve(); private pendingQueueEdit: symbol | undefined; + private pendingQueueMove = false; private shutdownRequested = false; @@ -2510,25 +2509,11 @@ export class InteractiveMode { } } - private async refreshConnectionQueue(): Promise { - this.replaceConnectionQueue(await this.agentConnection.getQueue()); - } - - private replaceConnectionQueue(queue: AgentConnectionQueueState): void { - this.connectionQueue = { - steering: [...queue.steering], - followUp: [...queue.followUp], + private getConnectionQueue(): AgentConnectionQueueState { + return { + steering: [...(this.connectionState?.sessionActions.steering ?? [])], + followUp: [...(this.connectionState?.sessionActions.followUps ?? [])], }; - const dropped = this.queueSelection.sync(this.connectionQueue); - if (dropped !== undefined) { - const editorText = this.editor.getText(); - if (editorText === dropped) { - this.setEditorTextFromQueueSelection(this.queueSelection.reset()); - } else if (!this.pendingQueueEdit) { - this.queueSelection.replaceDraft(editorText); - } - } - this.updatePendingMessagesDisplay(); } private async refreshConnectionCatalog(): Promise { @@ -2652,6 +2637,13 @@ export class InteractiveMode { this.patchConnectionState({ contextUsage: stats.contextUsage }); } + private refreshQueueSelectionFromState(): void { + const selected = this.queueSelection.selected; + if (selected && !this.pendingQueueEdit && !this.pendingQueueMove) { + this.refreshQueueSelectionAt(this.getConnectionQueue(), selected, selected.index); + } + } + private updateConnectionStateFromEvent(event: AgentConnectionSessionEvent): void { if (!this.connectionState) { return; @@ -2665,6 +2657,7 @@ export class InteractiveMode { break; case "session_action_update": this.patchConnectionState({ sessionActions: event.actions }); + this.refreshQueueSelectionFromState(); break; case "compaction_start": this.patchConnectionState({ isCompacting: true }); @@ -2806,7 +2799,8 @@ export class InteractiveMode { this.showLoadedResources({ force: false, showDiagnosticsWhenQuiet: true }); } this.subscribeToAgent(); - await Promise.all([this.refreshConnectionQueue(), this.refreshHeartbeatCatalog().catch(() => undefined)]); + this.updatePendingMessagesDisplay(); + await this.refreshHeartbeatCatalog().catch(() => undefined); await this.updateAvailableProviderCount(); this.updateEditorBorderColor(); this.updateTerminalTitle(); @@ -2829,8 +2823,8 @@ export class InteractiveMode { this.shortcutGuideContainer.clear(); this.pendingMessagesContainer.clear(); this.queuedMessagesContainer.clear(); - this.connectionQueue = { steering: [], followUp: [] }; this.pendingQueueEdit = undefined; + this.pendingQueueMove = false; // The selection and its stashed draft belong to the previous session; // every editor draft is cleared below, so discard rather than restore. this.queueSelection.reset(); @@ -2888,9 +2882,7 @@ export class InteractiveMode { await this.sessionEventQueue; this.resetCurrentSessionRenderState(); await this.renderInitialMessages(); - // The session transition and transcript are already authoritative here; - // a transient queue read must not turn a successful switch into a fatal error. - await this.refreshConnectionQueue().catch(() => undefined); + this.updatePendingMessagesDisplay(); this.syncWorkingLoader(); } @@ -2906,6 +2898,7 @@ export class InteractiveMode { private async renderResyncedSession(snapshot: AgentConnectionSnapshot): Promise { const bashFinished = this.isBashRunning() && !snapshot.state.isBashRunning; this.applyConnectionStateSnapshot(snapshot.state); + this.refreshQueueSelectionFromState(); this.restoreTurnStartFromMessages(this.getSessionContextFromConnectionSnapshot(snapshot).messages); this.streamingComponent = undefined; this.streamingMessage = undefined; @@ -2916,7 +2909,7 @@ export class InteractiveMode { updateFooter: true, }); await this.restoreStreamingMessageFromSnapshot(snapshot.streamingMessage); - await this.refreshConnectionQueue(); + this.updatePendingMessagesDisplay(); if (bashFinished) { if (this.activeBashComponent) { this.activeBashComponent.setComplete(undefined, false); @@ -4420,7 +4413,8 @@ export class InteractiveMode { for (const entry of this.editor.getHistory?.() ?? []) { add(entry); } - for (const msg of [...this.connectionQueue.steering, ...this.connectionQueue.followUp]) { + const queue = this.getConnectionQueue(); + for (const msg of [...queue.steering, ...queue.followUp]) { add(msg); } return ids; @@ -5363,10 +5357,7 @@ export class InteractiveMode { break; case "session_action_update": { - this.replaceConnectionQueue({ - steering: [...event.actions.steering], - followUp: [...event.actions.followUps], - }); + this.updatePendingMessagesDisplay(); this.ui.requestRender(); break; } @@ -6990,9 +6981,20 @@ export class InteractiveMode { } } + private refreshQueueSelectionAt( + queue: AgentConnectionQueueState, + selected: QueueSelectionItem, + index: number, + ): void { + const dropped = this.queueSelection.refreshAt(queue, selected.lane, index, selected.text); + if (dropped !== undefined && this.editor.getText() === selected.text) { + this.setEditorTextFromQueueSelection(dropped); + } + } + private browseQueueSelection(direction: -1 | 1): void { if (this.pendingQueueEdit) return; - const text = this.queueSelection.move(this.connectionQueue, this.editor.getText(), direction); + const text = this.queueSelection.move(this.getConnectionQueue(), this.editor.getText(), direction); if (text === undefined) return; this.setEditorTextFromQueueSelection(text); this.ui.requestRender(); @@ -7009,48 +7011,37 @@ export class InteractiveMode { } private moveQueueSelection(direction: -1 | 1): void { - if (this.pendingQueueEdit) return; - const submittedSelection = this.queueSelection.selected; - if (!submittedSelection) return; + if (this.pendingQueueEdit || !this.queueSelection.selected) return; const sessionGeneration = this.sessionEventGeneration; void this.enqueueQueueMutation(async () => { if (sessionGeneration !== this.sessionEventGeneration) return; - const lane = this.connectionQueue[submittedSelection.lane]; - const resolvedIndex = - lane[submittedSelection.index] === submittedSelection.text - ? submittedSelection.index - : lane.indexOf(submittedSelection.text); - if (resolvedIndex < 0) { - this.showStatus("Queue changed; reorder not applied"); - return; + const selected = this.queueSelection.selected; + if (!selected) return; + this.pendingQueueMove = true; + try { + const status = await this.agentConnection.mutateQueuedMessage( + selected.lane, + selected.index, + selected.text, + { + type: "move", + direction, + }, + ); + if (sessionGeneration !== this.sessionEventGeneration) return; + await this.sessionEventQueue; + if (sessionGeneration !== this.sessionEventGeneration) return; + this.refreshQueueSelectionAt( + this.getConnectionQueue(), + selected, + status === "applied" ? selected.index + direction : selected.index, + ); + if (status === "applied") this.ui.requestRender(); + else if (status === "unsupported") this.showStatus("Queue editing requires a newer daemon"); + else this.showStatus("Queue changed; reorder not applied"); + } finally { + this.pendingQueueMove = false; } - const selected = { ...submittedSelection, index: resolvedIndex }; - const queueBefore = this.connectionQueue; - const status = await this.agentConnection.mutateQueuedMessage(selected.lane, selected.index, selected.text, { - type: "move", - direction, - }); - if (sessionGeneration !== this.sessionEventGeneration) return; - if (status === "applied") { - // The queue event for this mutation can land before or after the - // response. Patch the mirror only when no event has replaced it - // meanwhile (events always assign a fresh object); patching an - // already-updated mirror would apply the mutation twice. - const lane = this.connectionQueue[selected.lane]; - const target = selected.index + direction; - if ( - this.connectionQueue === queueBefore && - lane[selected.index] === selected.text && - target >= 0 && - target < lane.length - ) { - [lane[selected.index], lane[target]] = [lane[target] as string, selected.text]; - this.queueSelection.sync(this.connectionQueue); - this.updatePendingMessagesDisplay(); - this.ui.requestRender(); - } - } else if (status === "unsupported") this.showStatus("Queue editing requires a newer daemon"); - else this.showStatus("Queue changed; reorder not applied"); }).catch((error) => { if (sessionGeneration === this.sessionEventGeneration) { this.showError(error instanceof Error ? error.message : String(error)); @@ -7064,9 +7055,7 @@ export class InteractiveMode { * Empty text deletes; otherwise replaces, moving the item to `targetLane`. */ private applyQueueSelection(text: string, targetLane: "steering" | "followUp"): Promise { - if (this.pendingQueueEdit) return Promise.resolve(false); - const submittedSelection = this.queueSelection.selected; - if (!submittedSelection) return Promise.resolve(false); + if (this.pendingQueueEdit || !this.queueSelection.selected) return Promise.resolve(false); const pendingQueueEdit = Symbol("pending-queue-edit"); this.pendingQueueEdit = pendingQueueEdit; const sessionGeneration = this.sessionEventGeneration; @@ -7096,68 +7085,40 @@ export class InteractiveMode { }; return this.enqueueQueueMutation(async () => { if (discardStaleSelection()) return true; - // Earlier serialized moves may have changed the selected item's index. - const lane = this.connectionQueue[submittedSelection.lane]; - const resolvedIndex = - lane[submittedSelection.index] === submittedSelection.text - ? submittedSelection.index - : lane.indexOf(submittedSelection.text); - if (resolvedIndex < 0) { - this.queueSelection.sync(this.connectionQueue); - const editorUntouched = - submissionGeneration === this.inputSubmissionGeneration && this.editor.getText() === editorTextBefore; - if (editorUntouched) { - this.setEditorTextFromQueueSelection(text); - } - this.queueSelection.replaceDraft(editorUntouched ? text : this.editor.getText()); - this.showStatus("Queue changed; edit kept in the editor"); - this.updatePendingMessagesDisplay(); - this.ui.requestRender(); - return true; - } - const selected = { ...submittedSelection, index: resolvedIndex }; - const queueBefore = this.connectionQueue; + const selected = this.queueSelection.selected; let status: AgentConnectionQueuedMessageMutationStatus; - try { - status = await this.agentConnection.mutateQueuedMessage( - selected.lane, - selected.index, - selected.text, - mutation, - ); - } catch (error) { - if (discardStaleSelection()) return true; - // The editor was already cleared by Enter; restore the edit before surfacing the error. - const editorUntouched = - submissionGeneration === this.inputSubmissionGeneration && this.editor.getText() === editorTextBefore; - if (editorUntouched) { - this.setEditorTextFromQueueSelection(text); - } - if (!this.queueSelection.isBrowsing) { - this.queueSelection.replaceDraft(editorUntouched ? text : this.editor.getText()); + if (selected) { + try { + status = await this.agentConnection.mutateQueuedMessage( + selected.lane, + selected.index, + selected.text, + mutation, + ); + } catch (error) { + if (discardStaleSelection()) return true; + // The editor was already cleared by Enter; restore the edit before surfacing the error. + const editorUntouched = + submissionGeneration === this.inputSubmissionGeneration && this.editor.getText() === editorTextBefore; + if (editorUntouched) { + this.setEditorTextFromQueueSelection(text); + } + if (!this.queueSelection.isBrowsing) { + this.queueSelection.replaceDraft(editorUntouched ? text : this.editor.getText()); + } + throw error; } - throw error; + } else { + status = "rejected"; } if (discardStaleSelection()) return true; const editorUntouched = submissionGeneration === this.inputSubmissionGeneration && this.editor.getText() === editorTextBefore; if (status === "applied") { - // Same optimistic patch as moveQueueSelection, and the same guard: - // skip when a queue event already replaced the mirror. - const lane = this.connectionQueue[selected.lane]; - if (this.connectionQueue === queueBefore && lane[selected.index] === selected.text) { - if (!trimmed) lane.splice(selected.index, 1); - else if (targetLane === selected.lane) lane[selected.index] = trimmed; - else { - lane.splice(selected.index, 1); - this.connectionQueue[targetLane].push(trimmed); - } - } if (trimmed) this.editor.addToHistory?.(trimmed); const draft = this.queueSelection.reset(); if (editorUntouched) this.setEditorTextFromQueueSelection(draft); } else { - this.queueSelection.sync(this.connectionQueue); // Enter submissions clear the editor before onSubmit runs; restore the // edit so a failed mutation never swallows it. if (editorUntouched) this.setEditorTextFromQueueSelection(text); @@ -7422,10 +7383,7 @@ export class InteractiveMode { } private getAllQueuedMessages(): { steering: string[]; followUp: string[] } { - return { - steering: [...this.connectionQueue.steering], - followUp: [...this.connectionQueue.followUp], - }; + return this.getConnectionQueue(); } private updatePendingMessagesDisplay(): void { diff --git a/packages/coding-agent/src/modes/interactive/queue-selection.ts b/packages/coding-agent/src/modes/interactive/queue-selection.ts index 3954775449..e0f3444531 100644 --- a/packages/coding-agent/src/modes/interactive/queue-selection.ts +++ b/packages/coding-agent/src/modes/interactive/queue-selection.ts @@ -44,8 +44,6 @@ export class QueueSelection { if (direction > 0) return undefined; this.items = flatten(queue); if (this.items.length === 0) return undefined; - // A drop (sync) keeps the previous draft stashed; do not overwrite it - // with the dropped item's text still sitting in the editor. if (!this.hasStashedDraft) { this.draft = draft; this.hasStashedDraft = true; @@ -62,23 +60,17 @@ export class QueueSelection { return this.items[next]?.text; } - /** - * Track queue changes while browsing: keep the selection when its text is - * still present. Returns the dropped item's text when the selection could - * not be kept, so the caller can restore the stashed draft. - */ - sync(queue: AgentConnectionQueueState): string | undefined { - const selected = this.selected; + refreshAt( + queue: AgentConnectionQueueState, + lane: QueueLane, + index: number, + expectedText: string, + ): string | undefined { this.items = flatten(queue); - if (!selected) return undefined; - const exact = this.items[selected.lane === "steering" ? selected.index : queue.steering.length + selected.index]; - if (exact?.lane === selected.lane && exact.text === selected.text) { - this.cursor = this.items.indexOf(exact); - return undefined; - } - const retargeted = this.items.find((item) => item.lane === selected.lane && item.text === selected.text); - this.cursor = retargeted ? this.items.indexOf(retargeted) : -1; - return retargeted ? undefined : selected.text; + const cursor = lane === "steering" ? index : queue.steering.length + index; + const selected = this.items[cursor]; + if (selected?.lane !== lane || selected.index !== index || selected.text !== expectedText) return this.reset(); + this.cursor = cursor; } /** Called after a mutation or submit resolved the selection. Returns the stashed draft. */ diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index 7c32788164..fa3ea7de1a 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -32,9 +32,6 @@ import type { // Types // ============================================================================ -/** Extended response timeout for refine requests, which run an LLM pass. */ -export const REFINE_REQUEST_TIMEOUT_MS = 10 * 60 * 1000; - /** Distributive Omit that works with union types */ type DistributiveOmit = T extends unknown ? Omit : never; @@ -66,6 +63,11 @@ export interface ModelInfo { export type RpcEventListener = (event: AgentEvent) => void; export type RpcObservedSessionListener = (event: RpcObservedSessionEvent) => void; +interface RpcEventCollection { + promise: Promise; + cancel(): void; +} + // ============================================================================ // RPC Client // ============================================================================ @@ -79,6 +81,8 @@ export class RpcClient { new Map(); private requestId = 0; private stderr = ""; + private transportError: Error | null = null; + private pendingEventWaiters = new Set<(error: Error) => void>(); constructor(private options: RpcClientOptions = {}) {} @@ -103,28 +107,40 @@ export class RpcClient { args.push(...this.options.args); } - this.process = spawn("node", [cliPath, ...args], { + this.transportError = null; + const child = spawn("node", [cliPath, ...args], { cwd: this.options.cwd, env: { ...process.env, ...this.options.env }, stdio: ["pipe", "pipe", "pipe"], }); + this.process = child; + child.on("error", (error) => { + this.failPendingOperations(new Error(`RPC process error: ${error.message}. Stderr: ${this.stderr}`)); + }); + child.stdout?.on("close", () => { + this.failPendingOperations(new Error(`RPC process output closed. Stderr: ${this.stderr}`)); + }); + child.on("close", () => { + this.process = null; + }); // Collect stderr for debugging - this.process.stderr?.on("data", (data) => { + child.stderr?.on("data", (data) => { this.stderr += data.toString(); process.stderr.write(data); }); // Set up strict JSONL reader for stdout. - this.stopReadingStdout = attachJsonlLineReader(this.process.stdout!, (line) => { + this.stopReadingStdout = attachJsonlLineReader(child.stdout!, (line) => { this.handleLine(line); }); // Wait a moment for process to initialize await new Promise((resolve) => setTimeout(resolve, 100)); - if (this.process.exitCode !== null) { - throw new Error(`Agent process exited immediately with code ${this.process.exitCode}. Stderr: ${this.stderr}`); + if (this.transportError) throw this.transportError; + if (child.exitCode !== null) { + throw new Error(`Agent process exited immediately with code ${child.exitCode}. Stderr: ${this.stderr}`); } } @@ -132,27 +148,20 @@ export class RpcClient { * Stop the RPC agent process. */ async stop(): Promise { - if (!this.process) return; + const child = this.process; + if (!child) return; this.stopReadingStdout?.(); this.stopReadingStdout = null; - this.process.kill("SIGTERM"); - - // Wait for process to exit + this.failPendingOperations(new Error(`RPC client stopped. Stderr: ${this.stderr}`)); await new Promise((resolve) => { - const timeout = setTimeout(() => { - this.process?.kill("SIGKILL"); - resolve(); - }, 1000); - - this.process?.on("exit", () => { + const timeout = setTimeout(() => child.kill("SIGKILL"), 1000); + child.once("close", () => { clearTimeout(timeout); resolve(); }); + child.kill("SIGTERM"); }); - - this.process = null; - this.pendingRequests.clear(); } /** @@ -195,7 +204,8 @@ export class RpcClient { * Use waitForIdle() to wait for completion. */ async prompt(message: string, images?: ImageContent[]): Promise { - await this.send({ type: "prompt", message, images }); + const response = await this.send({ type: "prompt", message, images }); + this.getData(response); } /** @@ -308,8 +318,6 @@ export class RpcClient { async refine( options: { instructions?: string; rollbackId?: string; global?: boolean } = {}, ): Promise { - // Refinement runs an LLM pass that routinely exceeds the default 30s response - // timeout, so use the same extended window as the daemon refine path. const command = { type: "refine", instructions: options.instructions, rollbackId: options.rollbackId } as { type: "refine"; instructions?: string; @@ -319,7 +327,7 @@ export class RpcClient { if (options.global !== undefined) { command.global = options.global; } - const response = await this.send(command, REFINE_REQUEST_TIMEOUT_MS); + const response = await this.send(command); return this.getData(response); } @@ -540,58 +548,98 @@ export class RpcClient { * Wait for agent to become idle (no streaming). * Resolves when agent_end event is received. */ - waitForIdle(timeout = 60000): Promise { + waitForIdle(timeout?: number): Promise { + if (this.transportError) return Promise.reject(this.transportError); return new Promise((resolve, reject) => { - const timer = setTimeout(() => { + let timer: ReturnType | undefined; + const cleanup = () => { + if (timer) clearTimeout(timer); unsubscribe(); - reject(new Error(`Timeout waiting for agent to become idle. Stderr: ${this.stderr}`)); - }, timeout); - + this.pendingEventWaiters.delete(onFailure); + }; + const onFailure = (error: Error) => { + cleanup(); + reject(error); + }; const unsubscribe = this.onEvent((event) => { if (event.type === "agent_end") { - clearTimeout(timer); - unsubscribe(); + cleanup(); resolve(); } }); + this.pendingEventWaiters.add(onFailure); + if (timeout !== undefined) { + timer = setTimeout(() => { + cleanup(); + reject(new Error(`Timeout waiting for agent to become idle. Stderr: ${this.stderr}`)); + }, timeout); + } }); } /** * Collect events until agent becomes idle. */ - collectEvents(timeout = 60000): Promise { - return new Promise((resolve, reject) => { - const events: AgentEvent[] = []; - const timer = setTimeout(() => { - unsubscribe(); - reject(new Error(`Timeout collecting events. Stderr: ${this.stderr}`)); - }, timeout); - - const unsubscribe = this.onEvent((event) => { - events.push(event); - if (event.type === "agent_end") { - clearTimeout(timer); - unsubscribe(); - resolve(events); - } - }); - }); + collectEvents(timeout?: number): Promise { + return this.startEventCollection(timeout).promise; } /** * Send prompt and wait for completion, returning all events. */ - async promptAndWait(message: string, images?: ImageContent[], timeout = 60000): Promise { - const eventsPromise = this.collectEvents(timeout); - await this.prompt(message, images); - return eventsPromise; + async promptAndWait(message: string, images?: ImageContent[], timeout?: number): Promise { + const collection = this.startEventCollection(timeout); + try { + const [events] = await Promise.all([collection.promise, this.prompt(message, images)]); + return events; + } finally { + collection.cancel(); + } } // ========================================================================= // Internal // ========================================================================= + private startEventCollection(timeout?: number): RpcEventCollection { + if (this.transportError) { + return { promise: Promise.reject(this.transportError), cancel: () => undefined }; + } + let cancel = () => undefined; + const promise = new Promise((resolve, reject) => { + const events: AgentEvent[] = []; + let timer: ReturnType | undefined; + const cleanup = () => { + if (timer) clearTimeout(timer); + unsubscribe(); + this.pendingEventWaiters.delete(onFailure); + }; + const onFailure = (error: Error) => { + cleanup(); + reject(error); + }; + const unsubscribe = this.onEvent((event) => { + events.push(event); + if (event.type === "agent_end") { + cleanup(); + resolve(events); + } + }); + cancel = () => { + cleanup(); + resolve(events); + }; + this.pendingEventWaiters.add(onFailure); + if (timeout !== undefined) { + timer = setTimeout(() => { + cleanup(); + reject(new Error(`Timeout collecting events. Stderr: ${this.stderr}`)); + }, timeout); + } + }); + return { promise, cancel }; + } + private handleLine(line: string): void { try { const data = JSON.parse(line); @@ -627,7 +675,8 @@ export class RpcClient { } } - private async send(command: RpcCommandBody, timeoutMs = 30000): Promise { + private async send(command: RpcCommandBody): Promise { + if (this.transportError) throw this.transportError; if (!this.process?.stdin) { throw new Error("Client not started"); } @@ -637,27 +686,21 @@ export class RpcClient { return new Promise((resolve, reject) => { this.pendingRequests.set(id, { resolve, reject }); - - const timeout = setTimeout(() => { - this.pendingRequests.delete(id); - reject(new Error(`Timeout waiting for response to ${command.type}. Stderr: ${this.stderr}`)); - }, timeoutMs); - - this.pendingRequests.set(id, { - resolve: (response) => { - clearTimeout(timeout); - resolve(response); - }, - reject: (error) => { - clearTimeout(timeout); - reject(error); - }, - }); - this.process!.stdin!.write(serializeJsonLine(fullCommand)); }); } + private failPendingOperations(error: Error): void { + this.transportError ??= error; + for (const [id, pending] of this.pendingRequests) { + pending.reject(this.transportError); + this.pendingRequests.delete(id); + } + for (const reject of [...this.pendingEventWaiters]) { + reject(this.transportError); + } + } + private getData(response: RpcResponse): T { if (!response.success) { const errorResponse = response as Extract; diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index 8f690b2642..64c8886339 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -97,6 +97,40 @@ describe("daemon mode helpers", () => { expect(setSessionName).toHaveBeenCalledOnce(); }); + it("uses a supervisor-approved worker session name without validating it again", async () => { + const daemon = new AgentDaemon("/tmp/unused-worker.sock", { + defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, + createRuntime: vi.fn(), + worker: { authenticationToken: "token" }, + }); + const setSessionName = vi.fn(); + const state = makeState("active"); + state.runtime = { + ...state.runtime, + session: { setSessionName }, + } as never; + const assertStateSessionNameAvailable = vi.fn(async () => { + throw new Error("stale peer name"); + }); + const internals = daemon as unknown as { + sessions: Map; + assertStateSessionNameAvailable: typeof assertStateSessionNameAvailable; + handleCommand(client: DaemonSocketClient, command: DaemonCommand): Promise; + }; + internals.sessions.set(state.activeSessionId, state); + internals.assertStateSessionNameAvailable = assertStateSessionNameAvailable; + + await expect( + internals.handleCommand(makeClient("supervisor", state.activeSessionId), { + type: "set_session_name", + activeSessionId: state.activeSessionId, + name: "approved", + }), + ).resolves.toMatchObject({ success: true }); + expect(assertStateSessionNameAvailable).not.toHaveBeenCalled(); + expect(setSessionName).toHaveBeenCalledWith("approved"); + }); + it("treats a depth-zero fork as a sibling of another root", () => { const daemon = new AgentDaemon("/tmp/prime-agent-fork-family.sock", { defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, @@ -1681,6 +1715,85 @@ describe("daemon mode helpers", () => { } }); + it("does not retry after the supervisor receives an agent message", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "pa-msg-disconnect-")); + const socketPath = join(tempDir, "d.sock"); + let requestCount = 0; + const server: Server = createServer((socket) => { + socket.on("error", () => undefined); + socket.write( + `${JSON.stringify({ + type: "daemon_hello", + socketPath, + protocol: DAEMON_PROTOCOL_INFO, + schemaId: DAEMON_SCHEMA_ID, + clientId: "supervisor", + serverCapabilities: [], + })}\n`, + ); + let buffer = ""; + socket.on("data", (chunk) => { + buffer += chunk.toString(); + const newline = buffer.indexOf("\n"); + if (newline === -1) return; + const wire = JSON.parse(buffer.slice(0, newline)) as { + id: string; + command?: { type: string }; + type: string; + }; + const command = wire.command ?? wire; + requestCount++; + if (requestCount === 1) { + socket.destroy(); + return; + } + socket.write( + `${JSON.stringify({ + type: "response", + id: wire.id, + command: command.type, + success: true, + data: {}, + })}\n`, + ); + }); + }); + const previousSupervisorSocket = process.env[DAEMON_WORKER_SUPERVISOR_SOCKET_ENV]; + try { + await new Promise((resolve) => server.listen(socketPath, resolve)); + process.env[DAEMON_WORKER_SUPERVISOR_SOCKET_ENV] = socketPath; + const daemon = new AgentDaemon("/tmp/prime-agent-worker-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + worker: { authenticationToken: "worker-token" }, + }); + const sendRemoteAgentSessionMessage = ( + daemon as unknown as { + sendRemoteAgentSessionMessage( + fromState: ActiveSessionState, + targetSelector: string, + message: string, + ): Promise; + } + ).sendRemoteAgentSessionMessage.bind(daemon); + + await expect(sendRemoteAgentSessionMessage(makeState("source"), "remote", "continue")).rejects.toThrow( + "Connection to the Prime Agent daemon closed", + ); + expect(requestCount).toBe(1); + } finally { + if (previousSupervisorSocket === undefined) { + delete process.env[DAEMON_WORKER_SUPERVISOR_SOCKET_ENV]; + } else { + process.env[DAEMON_WORKER_SUPERVISOR_SOCKET_ENV] = previousSupervisorSocket; + } + await new Promise((resolve) => server.close(() => resolve())); + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("routes worker-local session renames through the supervisor", async () => { const tempDir = mkdtempSync(join(tmpdir(), "pa-worker-rename-")); const socketPath = join(tempDir, "s"); diff --git a/packages/coding-agent/test/fixtures/rpc-client-hanging-fixture.mjs b/packages/coding-agent/test/fixtures/rpc-client-hanging-fixture.mjs new file mode 100644 index 0000000000..72ca140967 --- /dev/null +++ b/packages/coding-agent/test/fixtures/rpc-client-hanging-fixture.mjs @@ -0,0 +1 @@ +process.stdin.resume(); diff --git a/packages/coding-agent/test/interactive-mode-ctrl-c.test.ts b/packages/coding-agent/test/interactive-mode-ctrl-c.test.ts index 21fa99890e..caa8a1a8e8 100644 --- a/packages/coding-agent/test/interactive-mode-ctrl-c.test.ts +++ b/packages/coding-agent/test/interactive-mode-ctrl-c.test.ts @@ -26,7 +26,6 @@ type FakeInteractiveMode = { retryAttempt: number; sessionActions: { queuedCount: number; steering: readonly string[]; followUps: readonly string[] }; }; - connectionQueue: { steering: string[]; followUp: string[] }; agentConnection: { abort: Mock; clearQueue: Mock; @@ -98,7 +97,6 @@ function createInteractiveFake(options: { retryAttempt: options.retryAttempt ?? 0, sessionActions: { queuedCount: 0, steering: [], followUps: [] }, }, - connectionQueue: { steering: [], followUp: [] }, agentConnection: { abort: vi.fn().mockResolvedValue(undefined), clearQueue: vi.fn().mockResolvedValue({ steering: [], followUp: [] }), @@ -172,7 +170,7 @@ describe("InteractiveMode interrupt shortcuts", () => { it("preserves the queue and the draft when interrupting streaming", () => { const mode = createInteractiveFake({ editorText: "draft", streaming: true }); - mode.connectionQueue = { steering: ["steer"], followUp: ["follow"] }; + mode.connectionState.sessionActions = { queuedCount: 2, steering: ["steer"], followUps: ["follow"] }; Reflect.get(InteractiveMode.prototype, "handleCtrlC").call(mode); @@ -180,7 +178,11 @@ describe("InteractiveMode interrupt shortcuts", () => { expect(mode.agentConnection.abortAndClearQueue).not.toHaveBeenCalled(); expect(mode.agentConnection.clearQueue).not.toHaveBeenCalled(); expect(mode.editor.getText()).toBe("draft"); - expect(mode.connectionQueue).toEqual({ steering: ["steer"], followUp: ["follow"] }); + expect(mode.connectionState.sessionActions).toEqual({ + queuedCount: 2, + steering: ["steer"], + followUps: ["follow"], + }); }); it("exits on the second Ctrl+C while the hint is visible", () => { diff --git a/packages/coding-agent/test/interactive-mode-feature-hints.test.ts b/packages/coding-agent/test/interactive-mode-feature-hints.test.ts index 3fa36d9fc3..12d5a7e0bb 100644 --- a/packages/coding-agent/test/interactive-mode-feature-hints.test.ts +++ b/packages/coding-agent/test/interactive-mode-feature-hints.test.ts @@ -33,7 +33,10 @@ function createMode() { featureHintContainer, loadingAnimation: loader, workingVisible: true, - connectionState: { isStreaming: true }, + connectionState: { + isStreaming: true, + sessionActions: { queuedCount: 0, steering: [], followUps: [] }, + }, workingTimer: undefined, workingStartedAt: 0, featureHintDeck, @@ -43,7 +46,6 @@ function createMode() { featureHintAnimationTimer: undefined, featureHintComponent: undefined, featureHintRunPending: false, - connectionQueue: { steering: [], followUp: [] }, compactionQueuedMessages: [], options: { returnToAgentsView: true }, ui: { requestRender }, diff --git a/packages/coding-agent/test/interactive-mode-prompt-stash.test.ts b/packages/coding-agent/test/interactive-mode-prompt-stash.test.ts index 9c033e8c26..eb7b746497 100644 --- a/packages/coding-agent/test/interactive-mode-prompt-stash.test.ts +++ b/packages/coding-agent/test/interactive-mode-prompt-stash.test.ts @@ -44,7 +44,9 @@ type PromptStashHarness = { }; type PromptStashLiveMarkerHarness = PromptStashHarness & { - connectionQueue: { steering: string[]; followUp: string[] }; + connectionState: { + sessionActions: { queuedCount: number; steering: readonly string[]; followUps: readonly string[] }; + }; }; type SharedPromptStashHarness = PromptStashHarness & { @@ -628,13 +630,13 @@ describe("InteractiveMode prompt stash", () => { expect(mode.editor.getText()).toBe("half-written draft"); }); - it("drops queued image references from old sessions while keeping stashed images", () => { + it("drops old-session images while keeping stashed images", () => { const base = createPromptStashHarness({ stash: "keep [image #1]" }); const mode: ResetHarness = { ...base, defaultEditor: base.editor, queueSelection: new QueueSelection(), - connectionQueue: { steering: ["old [image #2]"], followUp: [] }, + connectionState: { sessionActions: { queuedCount: 0, steering: [], followUps: [] } }, chatContainer: { clear: vi.fn() }, shortcutGuideContainer: { clear: vi.fn() }, pendingMessagesContainer: { clear: vi.fn() }, @@ -661,7 +663,6 @@ describe("InteractiveMode prompt stash", () => { interactiveModeMethods.resetCurrentSessionRenderState.call(mode); - expect(mode.connectionQueue).toEqual({ steering: [], followUp: [] }); expect(mode.promptStash?.text).toBe("keep [image #1]"); expect(mode.pastedImages.has(1)).toBe(true); expect(mode.pastedImages.has(2)).toBe(false); @@ -673,7 +674,7 @@ describe("InteractiveMode prompt stash", () => { ...base, defaultEditor: base.editor, queueSelection: new QueueSelection(), - connectionQueue: { steering: [], followUp: [] }, + connectionState: { sessionActions: { queuedCount: 0, steering: [], followUps: [] } }, chatContainer: { clear: vi.fn() }, shortcutGuideContainer: { clear: vi.fn() }, pendingMessagesContainer: { clear: vi.fn() }, @@ -818,7 +819,7 @@ describe("InteractiveMode prompt stash", () => { it("keeps image markers in a stashed prompt live", () => { const mode: PromptStashLiveMarkerHarness = { ...createPromptStashHarness({ stash: "look at [image #7]" }), - connectionQueue: { steering: [], followUp: [] }, + connectionState: { sessionActions: { queuedCount: 0, steering: [], followUps: [] } }, }; Object.setPrototypeOf(mode, InteractiveMode.prototype); diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 4ef691e164..c9cac26fce 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -1265,7 +1265,7 @@ describe("InteractiveMode pending bash components", () => { const bashComponent = () => ({ render: () => [], invalidate: () => {} }); - test("keeps pending bash components visible across queue refreshes", () => { + test("keeps pending bash components visible across queue display updates", () => { const pendingMessagesContainer = new Container(); const component = bashComponent(); const fakeThis = { @@ -1435,74 +1435,51 @@ describe("InteractiveMode pending bash components", () => { }); describe("InteractiveMode connection events", () => { - test("rendering a switched session tolerates a transient queue refresh failure", async () => { + test("rendering a switched session updates the pending display from its snapshot", async () => { const harness = { resetCurrentSessionRenderState: vi.fn(), renderInitialMessages: vi.fn(async () => {}), - refreshConnectionQueue: vi.fn(async () => { - throw new Error("queue unavailable"); - }), + updatePendingMessagesDisplay: vi.fn(), syncWorkingLoader: vi.fn(), }; - await expect( - ( - InteractiveMode.prototype as unknown as { - renderCurrentSessionState(this: typeof harness): Promise; - } - ).renderCurrentSessionState.call(harness), - ).resolves.toBeUndefined(); + await ( + InteractiveMode.prototype as unknown as { + renderCurrentSessionState(this: typeof harness): Promise; + } + ).renderCurrentSessionState.call(harness); + expect(harness.updatePendingMessagesDisplay).toHaveBeenCalledOnce(); expect(harness.syncWorkingLoader).toHaveBeenCalledOnce(); }); - test("degrades heartbeat refresh failures without hiding queue refresh failures during rebind", async () => { + test("degrades heartbeat refresh failures while updating the pending display during rebind", async () => { const rebindCurrentSession = ( InteractiveMode.prototype as unknown as { rebindCurrentSession(this: InteractiveMode): Promise } ).rebindCurrentSession; - const createHarness = ( - refreshConnectionQueue: () => Promise, - refreshHeartbeatCatalog: () => Promise, - ) => - ({ - unsubscribe: undefined, - localSessionHost: undefined, - toolDefinitionCache: { clear: vi.fn() }, - applyRuntimeSettings: vi.fn(), - bindLocalSessionExtensions: true, - bindCurrentSessionExtensions: vi.fn(async () => {}), - subscribeToAgent: vi.fn(), - refreshConnectionQueue, - refreshHeartbeatCatalog, - updateAvailableProviderCount: vi.fn(async () => {}), - updateEditorBorderColor: vi.fn(), - updateTerminalTitle: vi.fn(), - setGoalAnnouncementBaseline: vi.fn(), - syncGoalTray: vi.fn(), - syncWorkingLoader: vi.fn(), - getGoalState: () => emptyGoalState(), - }) as unknown as InteractiveMode; - - await expect( - rebindCurrentSession.call( - createHarness( - vi.fn(async () => {}), - vi.fn(async () => { - throw new Error("heartbeat unavailable"); - }), - ), - ), - ).resolves.toBeUndefined(); + const updatePendingMessagesDisplay = vi.fn(); + const harness = { + unsubscribe: undefined, + localSessionHost: undefined, + toolDefinitionCache: { clear: vi.fn() }, + applyRuntimeSettings: vi.fn(), + bindLocalSessionExtensions: true, + bindCurrentSessionExtensions: vi.fn(async () => {}), + subscribeToAgent: vi.fn(), + updatePendingMessagesDisplay, + refreshHeartbeatCatalog: vi.fn(async () => { + throw new Error("heartbeat unavailable"); + }), + updateAvailableProviderCount: vi.fn(async () => {}), + updateEditorBorderColor: vi.fn(), + updateTerminalTitle: vi.fn(), + setGoalAnnouncementBaseline: vi.fn(), + syncGoalTray: vi.fn(), + syncWorkingLoader: vi.fn(), + getGoalState: () => emptyGoalState(), + } as unknown as InteractiveMode; - await expect( - rebindCurrentSession.call( - createHarness( - vi.fn(async () => { - throw new Error("queue unavailable"); - }), - vi.fn(async () => {}), - ), - ), - ).rejects.toThrow("queue unavailable"); + await expect(rebindCurrentSession.call(harness)).resolves.toBeUndefined(); + expect(updatePendingMessagesDisplay).toHaveBeenCalledOnce(); }); test("restores in-flight assistant state on every session render", async () => { @@ -1696,6 +1673,64 @@ describe("InteractiveMode connection events", () => { expect(fakeThis.renderInitialMessages).toHaveBeenCalledOnce(); }); + test("exits stale queue browsing when a resync replaces the queue snapshot", async () => { + const queueSelection = new QueueSelection(); + let editorText = "draft"; + queueSelection.move({ steering: [], followUp: ["queued"] }, editorText, -1); + editorText = "queued"; + const snapshot: AgentConnectionSnapshot = { + state: createConnectionState({ + sessionActions: { queuedCount: 0, steering: [], followUps: [] }, + }), + messages: [], + }; + const fakeThis = { + connectionState: createConnectionState({ + sessionActions: { queuedCount: 1, steering: [], followUps: ["queued"] }, + }), + queueSelection, + pendingQueueEdit: undefined, + pendingQueueMove: false, + isApplyingQueueSelectionText: false, + editor: { + getText: () => editorText, + setText: (text: string) => { + editorText = text; + }, + }, + isBashRunning: () => false, + applyConnectionStateSnapshot: vi.fn(), + restoreTurnStartFromMessages: vi.fn(), + replaceSubagentSummary: vi.fn(), + getSessionContextFromConnectionSnapshot: vi.fn(() => ({ + messages: [], + thinkingLevel: "medium", + model: null, + })), + renderSessionContext: vi.fn(async () => {}), + restoreStreamingMessageFromSnapshot: vi.fn(), + updatePendingMessagesDisplay: vi.fn(), + updateTerminalTitle: vi.fn(), + setGoalAnnouncementBaseline: vi.fn(), + syncGoalTray: vi.fn(), + syncWorkingLoader: vi.fn(), + getGoalState: () => emptyGoalState(), + }; + fakeThis.applyConnectionStateSnapshot.mockImplementation((state: AgentConnectionState) => { + fakeThis.connectionState = state; + }); + Object.setPrototypeOf(fakeThis, InteractiveMode.prototype); + + await ( + InteractiveMode.prototype as unknown as { + renderResyncedSession(this: unknown, value: AgentConnectionSnapshot): Promise; + } + ).renderResyncedSession.call(fakeThis, snapshot); + + expect(queueSelection.isBrowsing).toBe(false); + expect(editorText).toBe("draft"); + }); + test("preserves client-local work while rendering a resynchronized snapshot", async () => { const sideQuestion = { id: "side-1", status: "running" }; const extensionRequests = new Map([["request-1", { cancelLocal: vi.fn() }]]); @@ -1729,6 +1764,7 @@ describe("InteractiveMode connection events", () => { streamingComponent: {}, streamingMessage: {}, applyConnectionStateSnapshot: vi.fn(), + refreshQueueSelectionFromState: vi.fn(), updateWorkingLoaderMessage: vi.fn(), replaceSubagentSummary: vi.fn(), getSessionContextFromConnectionSnapshot: vi.fn(() => ({ @@ -1738,7 +1774,7 @@ describe("InteractiveMode connection events", () => { })), renderSessionContext: vi.fn(async () => {}), restoreStreamingMessageFromSnapshot, - refreshConnectionQueue: vi.fn(async () => {}), + updatePendingMessagesDisplay: vi.fn(), flushPendingBashComponents: vi.fn(), updateTerminalTitle: vi.fn(), setGoalAnnouncementBaseline: vi.fn(), @@ -1785,6 +1821,7 @@ describe("InteractiveMode connection events", () => { isAgentCompacting: () => true, isBashRunning: () => true, applyConnectionStateSnapshot: vi.fn(), + refreshQueueSelectionFromState: vi.fn(), restoreTurnStartFromMessages: vi.fn(), replaceSubagentSummary: vi.fn(), getSessionContextFromConnectionSnapshot: vi.fn(() => ({ @@ -1794,7 +1831,7 @@ describe("InteractiveMode connection events", () => { })), renderSessionContext: vi.fn(async () => {}), restoreStreamingMessageFromSnapshot: vi.fn(), - refreshConnectionQueue: vi.fn(async () => {}), + updatePendingMessagesDisplay: vi.fn(), flushPendingBashComponents, updateTerminalTitle: vi.fn(), setGoalAnnouncementBaseline: vi.fn(), @@ -1832,7 +1869,7 @@ describe("InteractiveMode connection events", () => { }), resetCurrentSessionRenderState: () => calls.push("reset"), renderInitialMessages: async () => calls.push("messages"), - refreshConnectionQueue: async () => calls.push("queue"), + updatePendingMessagesDisplay: () => calls.push("display"), syncWorkingLoader: () => calls.push("loader"), }; @@ -1842,7 +1879,7 @@ describe("InteractiveMode connection events", () => { } ).renderCurrentSessionState.call(fakeThis); - expect(calls).toEqual(["replacement", "reset", "messages", "queue", "loader"]); + expect(calls).toEqual(["replacement", "reset", "messages", "display", "loader"]); }); test("drops a queued source event after the session is replaced", async () => { @@ -3124,7 +3161,7 @@ class EventEmittingReplacementRuntime { describe("InteractiveMode session switch command catalog", () => { test.each(["switchSession", "newSession", "fork"] as const)( - "refreshes an event-emitting in-process %s replacement exactly once before replay", + "refreshes the command catalog for an event-emitting in-process %s replacement exactly once before replay", async (operation) => { const sourceSession = createFakeConnectionSession("source-command"); const targetSession = createFakeConnectionSession("target-command"); @@ -3160,7 +3197,7 @@ describe("InteractiveMode session switch command catalog", () => { calls.push("render"); expect(fakeThis.connectionCommands.map((command) => command.name)).toEqual(["target-command"]); }), - refreshConnectionQueue: vi.fn(async () => {}), + updatePendingMessagesDisplay: vi.fn(), syncWorkingLoader: vi.fn(), ui: { requestRender: vi.fn() }, handleEvent: vi.fn(), diff --git a/packages/coding-agent/test/interactive-queue-edit.test.ts b/packages/coding-agent/test/interactive-queue-edit.test.ts index b53b21c975..67519c288d 100644 --- a/packages/coding-agent/test/interactive-queue-edit.test.ts +++ b/packages/coding-agent/test/interactive-queue-edit.test.ts @@ -1,10 +1,20 @@ import { describe, expect, it, vi } from "vitest"; +import type { QueuedMessageMutation } from "../src/core/session-action-store.js"; +import type { AgentConnectionSessionEvent } from "../src/modes/agent-connection/index.js"; import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js"; import { QueueSelection } from "../src/modes/interactive/queue-selection.js"; +type QueueState = { steering: string[]; followUp: string[] }; + type Harness = { queueSelection: QueueSelection; - connectionQueue: { steering: string[]; followUp: string[] }; + connectionState: { + sessionActions: { + queuedCount: number; + steering: readonly string[]; + followUps: readonly string[]; + }; + }; editor: { getText: () => string; setText: (text: string) => void; addToHistory?: (text: string) => void }; isApplyingQueueSelectionText: boolean; pastedImages: Map; @@ -14,19 +24,27 @@ type Harness = { ui: { requestRender: () => void }; agentConnection: { mutateQueuedMessage: ReturnType; - getQueue: ReturnType; abort?: ReturnType; }; sessionEventGeneration: number; + sessionEventQueue: Promise; inputSubmissionGeneration: number; pendingQueueEdit: symbol | undefined; + pendingQueueMove: boolean; queueMutationChain: Promise; enqueueQueueMutation: (run: () => Promise) => Promise; applyQueueSelection: (text: string, targetLane: "steering" | "followUp") => Promise; browseQueueSelection: (direction: -1 | 1) => void; moveQueueSelection: (direction: -1 | 1) => void; - refreshConnectionQueue: () => Promise; - replaceConnectionQueue: (queue: { steering: string[]; followUp: string[] }) => void; + getConnectionQueue: () => QueueState; + refreshQueueSelectionAt: ( + queue: QueueState, + selected: { lane: "steering" | "followUp"; index: number; text: string }, + index: number, + ) => void; + refreshQueueSelectionFromState: () => void; + updateConnectionStateFromEvent: (event: AgentConnectionSessionEvent) => void; + patchConnectionState: (patch: Partial) => void; setEditorTextFromQueueSelection: (text: string) => void; collectQueueReplaceImages: (text: string) => unknown; }; @@ -37,7 +55,13 @@ function createHarness(queue: { steering: string[]; followUp: string[] }, mutate let editorText = ""; const harness = { queueSelection: new QueueSelection(), - connectionQueue: queue, + connectionState: { + sessionActions: { + queuedCount: queue.steering.length + queue.followUp.length, + steering: queue.steering, + followUps: queue.followUp, + }, + }, editor: { getText: () => editorText, setText: (text: string) => { @@ -53,25 +77,52 @@ function createHarness(queue: { steering: string[]; followUp: string[] }, mutate ui: { requestRender: vi.fn() }, agentConnection: { mutateQueuedMessage: vi.fn(async () => mutateResult), - getQueue: vi.fn(async () => ({ steering: [], followUp: [] })), abort: vi.fn(async () => {}), }, sessionEventGeneration: 0, + sessionEventQueue: Promise.resolve(), inputSubmissionGeneration: 0, pendingQueueEdit: undefined, + pendingQueueMove: false, queueMutationChain: Promise.resolve(), enqueueQueueMutation: proto.enqueueQueueMutation, applyQueueSelection: proto.applyQueueSelection, browseQueueSelection: proto.browseQueueSelection, moveQueueSelection: proto.moveQueueSelection, - refreshConnectionQueue: proto.refreshConnectionQueue, - replaceConnectionQueue: proto.replaceConnectionQueue, + getConnectionQueue: proto.getConnectionQueue, + refreshQueueSelectionAt: proto.refreshQueueSelectionAt, + refreshQueueSelectionFromState: proto.refreshQueueSelectionFromState, + updateConnectionStateFromEvent: proto.updateConnectionStateFromEvent, + patchConnectionState: () => {}, setEditorTextFromQueueSelection: proto.setEditorTextFromQueueSelection, collectQueueReplaceImages: proto.collectQueueReplaceImages, } as unknown as Harness; + harness.patchConnectionState = (patch) => { + harness.connectionState = { ...harness.connectionState, ...patch }; + }; return harness; } +function setQueue(harness: Harness, queue: QueueState): void { + harness.connectionState.sessionActions = { + ...harness.connectionState.sessionActions, + queuedCount: queue.steering.length + queue.followUp.length, + steering: queue.steering, + followUps: queue.followUp, + }; +} + +function emitQueueUpdate(harness: Harness, queue: QueueState): void { + harness.updateConnectionStateFromEvent({ + type: "session_action_update", + actions: { + queuedCount: queue.steering.length + queue.followUp.length, + steering: queue.steering, + followUps: queue.followUp, + }, + }); +} + describe("interactive queued-message editing", () => { it("browses into the queue and applies an enter edit as steering", async () => { const harness = createHarness({ steering: ["s1"], followUp: ["f1"] }); @@ -102,7 +153,7 @@ describe("interactive queued-message editing", () => { lane: "followUp", }); - harness.connectionQueue = { steering: ["s1"], followUp: [] }; + setQueue(harness, { steering: ["s1"], followUp: [] }); harness.browseQueueSelection(-1); await harness.applyQueueSelection(" ", "steering"); expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenLastCalledWith("steering", 0, "s1", { @@ -183,7 +234,7 @@ describe("interactive queued-message editing", () => { const pending = harness.applyQueueSelection(text, "steering"); await vi.waitFor(() => expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenCalledOnce()); - harness.replaceConnectionQueue({ + setQueue(harness, { steering: text.trim() ? [text.trim()] : [], followUp: [], }); @@ -244,30 +295,6 @@ describe("interactive queued-message editing", () => { expect(harness.editor.getText()).toBe("edited"); }); - it("restores the submitted edit when its queue item vanishes before the mutation starts", async () => { - let releaseMutationChain: () => void = () => {}; - const harness = createHarness({ steering: ["queued"], followUp: [] }); - harness.queueMutationChain = new Promise((resolve) => { - releaseMutationChain = resolve; - }); - harness.editor.setText("draft"); - harness.browseQueueSelection(-1); - harness.editor.setText(""); - const pending = harness.applyQueueSelection("edited", "steering"); - harness.replaceConnectionQueue({ steering: ["remaining"], followUp: [] }); - releaseMutationChain(); - await pending; - expect(harness.agentConnection.mutateQueuedMessage).not.toHaveBeenCalled(); - expect(harness.editor.getText()).toBe("edited"); - expect(harness.queueSelection.hasDraft).toBe(true); - expect(harness.showStatus).toHaveBeenCalledWith("Queue changed; edit kept in the editor"); - - harness.browseQueueSelection(-1); - expect(harness.editor.getText()).toBe("remaining"); - harness.browseQueueSelection(1); - expect(harness.editor.getText()).toBe("edited"); - }); - it("does not reset queue browsing in a replacement session when an old mutation completes", async () => { let resolveMutation: (status: string) => void = () => {}; const harness = createHarness({ steering: ["old queued"], followUp: [] }); @@ -288,7 +315,7 @@ describe("interactive queued-message editing", () => { harness.sessionEventGeneration++; harness.pendingQueueEdit = undefined; harness.queueSelection.reset(); - harness.connectionQueue = { steering: ["new queued"], followUp: [] }; + setQueue(harness, { steering: ["new queued"], followUp: [] }); harness.editor.setText("new draft"); harness.browseQueueSelection(-1); @@ -323,104 +350,156 @@ describe("interactive queued-message editing", () => { expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenCalledOnce(); }); - it("serializes rapid moves and addresses the second with the post-move index before any queue event", async () => { - // The daemon's session_action_update can arrive after the mutation response, - // so the local mirror must be updated optimistically between chained moves. - const harness = createHarness({ steering: ["s1", "s2", "s3"], followUp: [] }); - harness.browseQueueSelection(-1); // s3 at index 2 - harness.moveQueueSelection(-1); + it("exits browsing when an external event removes the selected item", async () => { + const harness = createHarness({ steering: [], followUp: ["queued"] }); + harness.editor.setText("draft"); + harness.browseQueueSelection(-1); + + emitQueueUpdate(harness, { steering: [], followUp: [] }); + + expect(harness.queueSelection.isBrowsing).toBe(false); + expect(harness.editor.getText()).toBe("draft"); + await expect(harness.applyQueueSelection("draft", "steering")).resolves.toBe(false); + expect(harness.agentConnection.mutateQueuedMessage).not.toHaveBeenCalled(); + }); + + it("refreshes browse navigation from external queue events", () => { + const harness = createHarness({ steering: ["s1"], followUp: ["f1", "f2"] }); + harness.browseQueueSelection(-1); + + emitQueueUpdate(harness, { steering: ["s1"], followUp: ["f0", "f2", "f3"] }); + harness.browseQueueSelection(-1); + + expect(harness.editor.getText()).toBe("f0"); + }); + + it("refreshes selection from event-driven queue state after a move", async () => { + const harness = createHarness({ steering: ["s1", "s2"], followUp: [] }); + harness.agentConnection.mutateQueuedMessage.mockImplementation(async () => { + emitQueueUpdate(harness, { steering: ["s2", "s1"], followUp: [] }); + return "applied"; + }); + harness.browseQueueSelection(-1); harness.moveQueueSelection(-1); await harness.queueMutationChain; - expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenNthCalledWith(1, "steering", 2, "s3", { - type: "move", - direction: -1, - }); - expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenNthCalledWith(2, "steering", 1, "s3", { - type: "move", - direction: -1, - }); - expect(harness.connectionQueue.steering).toEqual(["s3", "s1", "s2"]); + + expect(harness.getConnectionQueue()).toEqual({ steering: ["s2", "s1"], followUp: [] }); + expect(harness.queueSelection.selected).toEqual({ lane: "steering", index: 0, text: "s2" }); }); - it("preserves a queued reorder when an edit immediately exits browse mode", async () => { + it("leaves browse mode when the moved tuple is absent from the event snapshot", async () => { const harness = createHarness({ steering: ["s1", "s2"], followUp: [] }); + harness.agentConnection.mutateQueuedMessage.mockImplementation(async () => { + emitQueueUpdate(harness, { steering: ["s1"], followUp: [] }); + return "applied"; + }); + harness.editor.setText("draft"); harness.browseQueueSelection(-1); harness.moveQueueSelection(-1); - const edited = harness.applyQueueSelection("s2 edited", "steering"); await harness.queueMutationChain; - await edited; - expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenNthCalledWith(1, "steering", 1, "s2", { - type: "move", - direction: -1, - }); - expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenNthCalledWith(2, "steering", 0, "s2", { - type: "replace", - text: "s2 edited", - images: [], - lane: "steering", - }); + expect(harness.queueSelection.isBrowsing).toBe(false); + expect(harness.editor.getText()).toBe("draft"); }); - it("optimistically updates the local queue mirror on replace and delete", async () => { - const harness = createHarness({ steering: ["s1", "s2"], followUp: ["f1"] }); - harness.browseQueueSelection(-1); // f1 - await harness.applyQueueSelection("f1 edited", "followUp"); - // An immediate browse must see the new text before the queue event arrives. - expect(harness.connectionQueue).toEqual({ steering: ["s1", "s2"], followUp: ["f1 edited"] }); + it("refreshes selection after a failed move suppresses an external event", async () => { + const harness = createHarness({ steering: ["s1", "s2"], followUp: [] }, "rejected"); + harness.agentConnection.mutateQueuedMessage.mockImplementation(async () => { + emitQueueUpdate(harness, { steering: ["s1"], followUp: [] }); + return "rejected"; + }); + harness.editor.setText("draft"); + harness.browseQueueSelection(-1); + harness.moveQueueSelection(-1); + await harness.queueMutationChain; - harness.browseQueueSelection(-1); // f1 edited - await harness.applyQueueSelection(" ", "followUp"); - expect(harness.connectionQueue).toEqual({ steering: ["s1", "s2"], followUp: [] }); + expect(harness.queueSelection.isBrowsing).toBe(false); + expect(harness.editor.getText()).toBe("draft"); }); - it("does not double-apply a delete when the queue event lands before the response", async () => { - const harness = createHarness({ steering: [], followUp: ["dup", "dup"] }); + it("keeps a chained edit when the preceding move loses its selection", async () => { + const harness = createHarness({ steering: ["s1", "s2"], followUp: [] }); harness.agentConnection.mutateQueuedMessage.mockImplementation(async () => { - // The server's session_action_update arrives before the response - // resolves: the mirror is replaced and the selection retargets to - // the remaining same-text item. - harness.connectionQueue = { steering: [], followUp: ["dup"] }; - harness.queueSelection.sync(harness.connectionQueue); + emitQueueUpdate(harness, { steering: ["s1"], followUp: [] }); return "applied"; }); - harness.browseQueueSelection(-1); // dup at followUp index 1 - await harness.applyQueueSelection(" ", "followUp"); - expect(harness.connectionQueue).toEqual({ steering: [], followUp: ["dup"] }); - }); + harness.editor.setText("draft"); + harness.browseQueueSelection(-1); + harness.moveQueueSelection(-1); + harness.editor.setText(""); + await harness.applyQueueSelection("s2 edited", "steering"); - it("moves the item across lanes in the local mirror on a lane-changing replace", async () => { - const harness = createHarness({ steering: ["s1"], followUp: [] }); - harness.browseQueueSelection(-1); // s1 - await harness.applyQueueSelection("now follow-up", "followUp"); - expect(harness.connectionQueue).toEqual({ steering: [], followUp: ["now follow-up"] }); + expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenCalledOnce(); + expect(harness.editor.getText()).toBe("s2 edited"); + expect(harness.showStatus).toHaveBeenCalledWith("Queue changed; edit kept in the editor"); }); - it("restores the stashed draft when the browsed item is consumed externally", () => { - const harness = createHarness({ steering: [], followUp: ["f1"] }); - harness.editor.setText("draft"); + it("uses canonical post-move positions for consecutive moves and an edit", async () => { + const queue = ["s1", "s2", "s3"]; + const harness = createHarness({ steering: queue, followUp: [] }); + harness.agentConnection.mutateQueuedMessage.mockImplementation( + async ( + _lane: "steering" | "followUp", + index: number, + expectedText: string, + mutation: QueuedMessageMutation, + ) => { + const item = queue[index]; + if (item !== expectedText) return "rejected"; + if (mutation.type === "move") { + const target = index + mutation.direction; + const neighbor = queue[target]; + if (neighbor === undefined) return "rejected"; + queue[index] = neighbor; + queue[target] = item; + } else if (mutation.type === "replace") { + queue[index] = mutation.text; + } + emitQueueUpdate(harness, { steering: [...queue], followUp: [] }); + return "applied"; + }, + ); harness.browseQueueSelection(-1); - expect(harness.editor.getText()).toBe("f1"); - // The item is delivered: the queue update drops the selection. - harness.connectionQueue = { steering: [], followUp: [] }; - const dropped = harness.queueSelection.sync(harness.connectionQueue); - expect(dropped).toBe("f1"); - if (dropped !== undefined && harness.editor.getText() === dropped) { - harness.setEditorTextFromQueueSelection(harness.queueSelection.reset()); - } - expect(harness.editor.getText()).toBe("draft"); + harness.moveQueueSelection(-1); + harness.moveQueueSelection(-1); + const edited = harness.applyQueueSelection("s3 edited", "steering"); + await edited; + + expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenNthCalledWith(1, "steering", 2, "s3", { + type: "move", + direction: -1, + }); + expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenNthCalledWith(2, "steering", 1, "s3", { + type: "move", + direction: -1, + }); + expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenNthCalledWith(3, "steering", 0, "s3", { + type: "replace", + text: "s3 edited", + images: [], + lane: "steering", + }); + expect(harness.getConnectionQueue()).toEqual({ steering: ["s3 edited", "s1", "s2"], followUp: [] }); }); - it("synchronizes queue browsing when a reconnect refresh replaces the queue", async () => { - const harness = createHarness({ steering: [], followUp: ["queued"] }); - harness.editor.setText("draft"); + it("keeps the selected index when duplicate text shifts before an edit", async () => { + let releaseMutationChain: () => void = () => {}; + const harness = createHarness({ steering: [], followUp: ["dup", "dup"] }, "rejected"); + harness.queueMutationChain = new Promise((resolve) => { + releaseMutationChain = resolve; + }); harness.browseQueueSelection(-1); - harness.agentConnection.getQueue.mockResolvedValue({ steering: [], followUp: [] }); - - await harness.refreshConnectionQueue(); + const pending = harness.applyQueueSelection("edited", "followUp"); + setQueue(harness, { steering: [], followUp: ["dup"] }); + releaseMutationChain(); + await pending; - expect(harness.queueSelection.isBrowsing).toBe(false); - expect(harness.editor.getText()).toBe("draft"); + expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenCalledWith("followUp", 1, "dup", { + type: "replace", + text: "edited", + images: [], + lane: "followUp", + }); }); it("deduplicates repeated image markers in a replace", () => { diff --git a/packages/coding-agent/test/queue-selection.test.ts b/packages/coding-agent/test/queue-selection.test.ts index c45d453bdd..68ebe27e61 100644 --- a/packages/coding-agent/test/queue-selection.test.ts +++ b/packages/coding-agent/test/queue-selection.test.ts @@ -26,28 +26,6 @@ describe("QueueSelection", () => { expect(selection.isBrowsing).toBe(false); }); - it("keeps, retargets, or drops the selection when the queue changes", () => { - const selection = new QueueSelection(); - selection.move(queue, "draft", -1); - selection.move(queue, "", -1); - selection.move(queue, "", -1); // s2 - expect(selection.sync({ steering: ["s1", "s2"], followUp: ["f2"] })).toBeUndefined(); - expect(selection.selected).toEqual({ lane: "steering", index: 1, text: "s2" }); - expect(selection.sync({ steering: ["s0", "s2"], followUp: [] })).toBeUndefined(); // retarget by text - expect(selection.selected).toEqual({ lane: "steering", index: 1, text: "s2" }); - expect(selection.sync({ steering: ["s0"], followUp: ["s2"] })).toBe("s2"); // same text, other lane: drop - expect(selection.isBrowsing).toBe(false); - }); - - it("keeps the stashed draft across an external selection drop", () => { - const selection = new QueueSelection(); - selection.move(queue, "my draft", -1); // editing f2 - selection.sync({ steering: [], followUp: [] }); // f2 delivered: selection dropped - expect(selection.isBrowsing).toBe(false); - selection.move({ steering: ["s9"], followUp: [] }, "f2 leftover text", -1); - expect(selection.reset()).toBe("my draft"); - }); - it("reset returns the stashed draft once", () => { const selection = new QueueSelection(); selection.move(queue, "my draft", -1); diff --git a/packages/coding-agent/test/rpc-client-refine.test.ts b/packages/coding-agent/test/rpc-client-refine.test.ts deleted file mode 100644 index 098293bef8..0000000000 --- a/packages/coding-agent/test/rpc-client-refine.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { REFINE_REQUEST_TIMEOUT_MS, RpcClient } from "../src/modes/rpc/rpc-client.js"; - -type RpcClientPrivate = { - send: (command: { type: string }, timeoutMs?: number) => Promise; - getData: (response: unknown) => T; -}; - -describe("RpcClient refine", () => { - it("sends the refine command with the extended timeout", async () => { - const client = new RpcClient(); - const privateClient = client as unknown as RpcClientPrivate; - const send = vi.fn(async () => ({ - type: "response", - command: "refine", - success: true, - data: { id: "refine_1", appliedEdits: [], harnessStatePath: "/tmp/harness_state.json" }, - })); - privateClient.send = send; - privateClient.getData = (response: unknown): T => { - return (response as { data: T }).data; - }; - - const result = await client.refine({ instructions: "tighten validation" }); - - expect(send).toHaveBeenCalledWith( - { type: "refine", instructions: "tighten validation" }, - REFINE_REQUEST_TIMEOUT_MS, - ); - expect(REFINE_REQUEST_TIMEOUT_MS).toBeGreaterThan(30000); - expect(result).toMatchObject({ id: "refine_1" }); - }); -}); diff --git a/packages/coding-agent/test/rpc-client-timeout.test.ts b/packages/coding-agent/test/rpc-client-timeout.test.ts new file mode 100644 index 0000000000..f505a53b2c --- /dev/null +++ b/packages/coding-agent/test/rpc-client-timeout.test.ts @@ -0,0 +1,107 @@ +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { RpcClient } from "../src/modes/rpc/rpc-client.js"; + +const fixturePath = fileURLToPath(new URL("./fixtures/rpc-client-hanging-fixture.mjs", import.meta.url)); +const clients = new Set(); + +async function createClient(): Promise { + const client = new RpcClient({ cliPath: fixturePath }); + await client.start(); + clients.add(client); + return client; +} + +describe("RpcClient operation completion", () => { + afterEach(async () => { + vi.useRealTimers(); + await Promise.all([...clients].map((client) => client.stop())); + clients.clear(); + }); + + it("does not time out a long RPC command by default", async () => { + const client = await createClient(); + vi.useFakeTimers(); + const result = client.bash("sleep 120"); + + await vi.advanceTimersByTimeAsync(120_000); + expect(await Promise.race([result, Promise.resolve("pending")])).toBe("pending"); + + client["handleLine"]( + JSON.stringify({ + id: "req_1", + type: "response", + command: "bash", + success: true, + data: { output: "done", exitCode: 0, cancelled: false, truncated: false }, + }), + ); + await expect(result).resolves.toMatchObject({ output: "done", exitCode: 0 }); + }); + + it("rejects promptAndWait when the prompt response fails", async () => { + const client = await createClient(); + const result = expect(client.promptAndWait("rejected prompt")).rejects.toThrow("prompt rejected"); + + client["handleLine"]( + JSON.stringify({ + id: "req_1", + type: "response", + command: "prompt", + success: false, + error: "prompt rejected", + }), + ); + + await result; + expect(client["pendingEventWaiters"].size).toBe(0); + }); + + it("does not time out agent completion by default", async () => { + const client = await createClient(); + vi.useFakeTimers(); + const idle = client.waitForIdle(); + const events = client.collectEvents(); + + await vi.advanceTimersByTimeAsync(120_000); + expect(await Promise.race([idle, Promise.resolve("pending")])).toBe("pending"); + expect(await Promise.race([events, Promise.resolve("pending")])).toBe("pending"); + + client["handleLine"](JSON.stringify({ type: "agent_end" })); + await expect(idle).resolves.toBeUndefined(); + await expect(events).resolves.toEqual([{ type: "agent_end" }]); + }); + + it("waits for child close before restarting", async () => { + const client = await createClient(); + + await client.stop(); + await client.start(); + const state = client.getState(); + client["handleLine"]( + JSON.stringify({ id: "req_1", type: "response", command: "get_state", success: true, data: {} }), + ); + + await expect(state).resolves.toEqual({}); + }); + + it("rejects start when the child cannot spawn", async () => { + const client = new RpcClient({ cliPath: fixturePath, env: { PATH: "" } }); + + await expect(client.start()).rejects.toThrow("RPC process error"); + }); + + it("rejects pending commands and completion waits when the child output closes", async () => { + const client = await createClient(); + const child = client["process"]; + if (!child) throw new Error("RPC child did not start"); + const command = expect(client.getState()).rejects.toThrow("RPC process output closed"); + const idle = expect(client.waitForIdle()).rejects.toThrow("RPC process output closed"); + const events = expect(client.collectEvents()).rejects.toThrow("RPC process output closed"); + + child.kill("SIGTERM"); + + await Promise.all([command, idle, events]); + clients.delete(client); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/4509-side-questions.test.ts b/packages/coding-agent/test/suite/regressions/4509-side-questions.test.ts index bf8de05144..e10da95722 100644 --- a/packages/coding-agent/test/suite/regressions/4509-side-questions.test.ts +++ b/packages/coding-agent/test/suite/regressions/4509-side-questions.test.ts @@ -872,6 +872,7 @@ describe("ENG-4509 side questions", () => { isAgentCompacting: () => false, isBashRunning: () => true, applyConnectionStateSnapshot: vi.fn(), + refreshQueueSelectionFromState: vi.fn(), replaceSubagentSummary: vi.fn(), getSessionContextFromConnectionSnapshot: vi.fn(() => ({ messages: [], @@ -880,7 +881,7 @@ describe("ENG-4509 side questions", () => { })), renderSessionContext: vi.fn(async () => {}), restoreStreamingMessageFromSnapshot: vi.fn(async () => {}), - refreshConnectionQueue: vi.fn(async () => {}), + updatePendingMessagesDisplay: vi.fn(), flushCompactionQueue: vi.fn(async () => {}), flushPendingBashComponents: vi.fn(), updateTerminalTitle: vi.fn(), @@ -906,6 +907,7 @@ describe("ENG-4509 side questions", () => { messages: [], }); + expect(fakeThis.updatePendingMessagesDisplay).toHaveBeenCalledOnce(); expect(bashComponent.setComplete).toHaveBeenCalledWith(undefined, false); expect(finishBash).toHaveBeenCalledOnce(); expect(fakeThis.activeBashComponent).toBeUndefined(); diff --git a/packages/coding-agent/test/suite/regressions/4741-hint-placement.test.ts b/packages/coding-agent/test/suite/regressions/4741-hint-placement.test.ts index ccbe74f109..6d19c16a66 100644 --- a/packages/coding-agent/test/suite/regressions/4741-hint-placement.test.ts +++ b/packages/coding-agent/test/suite/regressions/4741-hint-placement.test.ts @@ -26,11 +26,13 @@ function createFeatureHintMode() { pendingMessagesContainer: new Container(), pendingBashComponents: [], queuedMessagesContainer: new Container(), - connectionQueue: { steering: [] as string[], followUp: [] as string[] }, compactionQueuedMessages: [], loadingAnimation: loader, workingVisible: true, - connectionState: { isStreaming: true }, + connectionState: { + isStreaming: true, + sessionActions: { queuedCount: 0, steering: [] as string[], followUps: [] as string[] }, + }, featureHintDeck: { next: vi.fn(() => ({ id: "test", text: "A useful feature hint." })) }, currentFeatureHint: undefined, featureHintEligibleAt: 0, @@ -148,11 +150,11 @@ describe("ENG-4741 hint placement", () => { vi.advanceTimersByTime(5_000); expect(featureHintContainer.children).toHaveLength(1); - mode.connectionQueue.followUp = ["Continue after this turn"]; + mode.connectionState.sessionActions.followUps = ["Continue after this turn"]; callPrivate(mode, "updatePendingMessagesDisplay"); expect(featureHintContainer.children).toHaveLength(0); - mode.connectionQueue.followUp = []; + mode.connectionState.sessionActions.followUps = []; callPrivate(mode, "updatePendingMessagesDisplay"); expect(featureHintContainer.children).toHaveLength(1);