From 40fac3ff56a76b677b2220041f6bbfbb994d8f60 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 11:17:34 +0200 Subject: [PATCH 01/39] fix(coding-agent): avoid retrying delivered agent messages --- .../src/modes/daemon/daemon-mode.ts | 59 +++++++------- .../coding-agent/test/daemon-mode.test.ts | 79 +++++++++++++++++++ 2 files changed, 110 insertions(+), 28 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 0bda7e5ea5..5b905d2c4b 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -5905,42 +5905,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/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index bc5d92e722..c57fe604d3 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -1671,6 +1671,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"); From 980d22d1980b3ff3e3daaa4d452634f3bf3a0691 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 12:14:34 +0200 Subject: [PATCH 02/39] Add the changelog fragment --- packages/coding-agent/.changes/remote-message-single-send.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/coding-agent/.changes/remote-message-single-send.md 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. From 4d9aa20dfeaf2342debd6cb0e5f5c3b383385117 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 12:29:57 +0200 Subject: [PATCH 03/39] fix(coding-agent): remove blanket RPC timeouts --- .../.changes/remove-rpc-blanket-timeouts.md | 1 + .../coding-agent/src/modes/rpc/rpc-client.ts | 112 ++++++++++++------ .../fixtures/rpc-client-hanging-fixture.mjs | 1 + .../test/rpc-client-refine.test.ts | 33 ------ .../test/rpc-client-timeout.test.ts | 70 +++++++++++ 5 files changed, 150 insertions(+), 67 deletions(-) create mode 100644 packages/coding-agent/.changes/remove-rpc-blanket-timeouts.md create mode 100644 packages/coding-agent/test/fixtures/rpc-client-hanging-fixture.mjs delete mode 100644 packages/coding-agent/test/rpc-client-refine.test.ts create mode 100644 packages/coding-agent/test/rpc-client-timeout.test.ts 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/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index 7c32788164..d17ba78a17 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; @@ -79,6 +76,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,11 +102,22 @@ export class RpcClient { args.push(...this.options.args); } + this.transportError = null; this.process = spawn("node", [cliPath, ...args], { cwd: this.options.cwd, env: { ...process.env, ...this.options.env }, stdio: ["pipe", "pipe", "pipe"], }); + this.process.on("error", (error) => { + this.failPendingOperations(new Error(`RPC process error: ${error.message}. Stderr: ${this.stderr}`)); + }); + this.process.on("exit", (code, signal) => { + const status = signal ? `signal ${signal}` : `code ${code}`; + this.failPendingOperations(new Error(`RPC process exited with ${status}. Stderr: ${this.stderr}`)); + }); + this.process.stdout?.on("close", () => { + this.failPendingOperations(new Error(`RPC process output closed. Stderr: ${this.stderr}`)); + }); // Collect stderr for debugging this.process.stderr?.on("data", (data) => { @@ -136,6 +146,7 @@ export class RpcClient { this.stopReadingStdout?.(); this.stopReadingStdout = null; + this.failPendingOperations(new Error(`RPC client stopped. Stderr: ${this.stderr}`)); this.process.kill("SIGTERM"); // Wait for process to exit @@ -152,7 +163,6 @@ export class RpcClient { }); this.process = null; - this.pendingRequests.clear(); } /** @@ -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,52 +548,76 @@ 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 { + collectEvents(timeout?: number): Promise { + if (this.transportError) return Promise.reject(this.transportError); return new Promise((resolve, reject) => { const events: AgentEvent[] = []; - const timer = setTimeout(() => { + let timer: ReturnType | undefined; + const cleanup = () => { + if (timer) clearTimeout(timer); unsubscribe(); - reject(new Error(`Timeout collecting events. Stderr: ${this.stderr}`)); - }, timeout); - + this.pendingEventWaiters.delete(onFailure); + }; + const onFailure = (error: Error) => { + cleanup(); + reject(error); + }; const unsubscribe = this.onEvent((event) => { events.push(event); if (event.type === "agent_end") { - clearTimeout(timer); - unsubscribe(); + cleanup(); resolve(events); } }); + this.pendingEventWaiters.add(onFailure); + if (timeout !== undefined) { + timer = setTimeout(() => { + cleanup(); + reject(new Error(`Timeout collecting events. Stderr: ${this.stderr}`)); + }, timeout); + } }); } /** * Send prompt and wait for completion, returning all events. */ - async promptAndWait(message: string, images?: ImageContent[], timeout = 60000): Promise { + async promptAndWait(message: string, images?: ImageContent[], timeout?: number): Promise { const eventsPromise = this.collectEvents(timeout); - await this.prompt(message, images); - return eventsPromise; + const [events] = await Promise.all([eventsPromise, this.prompt(message, images)]); + return events; } // ========================================================================= @@ -627,7 +659,8 @@ export class RpcClient { } } - private async send(command: RpcCommandBody, timeoutMs = 30000): Promise { + private async send(command: RpcCommandBody, timeoutMs?: number): Promise { + if (this.transportError) throw this.transportError; if (!this.process?.stdin) { throw new Error("Client not started"); } @@ -636,28 +669,39 @@ export class RpcClient { const fullCommand = { ...command, id } as RpcCommand; 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); - + let timeout: ReturnType | undefined; this.pendingRequests.set(id, { resolve: (response) => { - clearTimeout(timeout); + if (timeout) clearTimeout(timeout); resolve(response); }, reject: (error) => { - clearTimeout(timeout); + if (timeout) clearTimeout(timeout); reject(error); }, }); + if (timeoutMs !== undefined) { + timeout = setTimeout(() => { + this.pendingRequests.delete(id); + reject(new Error(`Timeout waiting for response to ${command.type}. Stderr: ${this.stderr}`)); + }, timeoutMs); + } 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/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/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..1a03e45e79 --- /dev/null +++ b/packages/coding-agent/test/rpc-client-timeout.test.ts @@ -0,0 +1,70 @@ +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("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("rejects pending commands and completion waits when the child exits", 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 (exited|output closed)/); + const idle = expect(client.waitForIdle()).rejects.toThrow(/RPC process (exited|output closed)/); + const events = expect(client.collectEvents()).rejects.toThrow(/RPC process (exited|output closed)/); + + child.kill("SIGTERM"); + + await Promise.all([command, idle, events]); + clients.delete(client); + }); +}); From 8e57ca7f5c8674a29b66721396d42fde5e3b4cc0 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 12:58:00 +0200 Subject: [PATCH 04/39] fix(coding-agent): trust supervised session renames --- .../.changes/supervised-rename-authority.md | 1 + .../src/modes/daemon/daemon-mode.ts | 18 +++++++--- .../coding-agent/test/daemon-mode.test.ts | 34 +++++++++++++++++++ 3 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 packages/coding-agent/.changes/supervised-rename-authority.md 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..ce35ab2ca3 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); }, ); } diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index bc5d92e722..b9382ce9ef 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -96,6 +96,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" }, From 6fd14f2e75ddf544a566340914e3c50913974390 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 13:02:07 +0200 Subject: [PATCH 05/39] fix(coding-agent): align RPC child lifecycle --- .../coding-agent/src/modes/rpc/rpc-client.ts | 61 ++++++------------- .../test/rpc-client-timeout.test.ts | 27 ++++++-- 2 files changed, 42 insertions(+), 46 deletions(-) diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index d17ba78a17..2a10f0182c 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -103,38 +103,39 @@ export class RpcClient { } this.transportError = null; - this.process = spawn("node", [cliPath, ...args], { + const child = spawn("node", [cliPath, ...args], { cwd: this.options.cwd, env: { ...process.env, ...this.options.env }, stdio: ["pipe", "pipe", "pipe"], }); - this.process.on("error", (error) => { + this.process = child; + child.on("error", (error) => { this.failPendingOperations(new Error(`RPC process error: ${error.message}. Stderr: ${this.stderr}`)); }); - this.process.on("exit", (code, signal) => { - const status = signal ? `signal ${signal}` : `code ${code}`; - this.failPendingOperations(new Error(`RPC process exited with ${status}. Stderr: ${this.stderr}`)); - }); - this.process.stdout?.on("close", () => { + 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}`); } } @@ -142,27 +143,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.failPendingOperations(new Error(`RPC client stopped. Stderr: ${this.stderr}`)); - this.process.kill("SIGTERM"); - - // Wait for process to exit 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; } /** @@ -659,7 +653,7 @@ export class RpcClient { } } - private async send(command: RpcCommandBody, timeoutMs?: number): Promise { + private async send(command: RpcCommandBody): Promise { if (this.transportError) throw this.transportError; if (!this.process?.stdin) { throw new Error("Client not started"); @@ -669,24 +663,7 @@ export class RpcClient { const fullCommand = { ...command, id } as RpcCommand; return new Promise((resolve, reject) => { - let timeout: ReturnType | undefined; - this.pendingRequests.set(id, { - resolve: (response) => { - if (timeout) clearTimeout(timeout); - resolve(response); - }, - reject: (error) => { - if (timeout) clearTimeout(timeout); - reject(error); - }, - }); - if (timeoutMs !== undefined) { - 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, reject }); this.process!.stdin!.write(serializeJsonLine(fullCommand)); }); } diff --git a/packages/coding-agent/test/rpc-client-timeout.test.ts b/packages/coding-agent/test/rpc-client-timeout.test.ts index 1a03e45e79..480f5a7278 100644 --- a/packages/coding-agent/test/rpc-client-timeout.test.ts +++ b/packages/coding-agent/test/rpc-client-timeout.test.ts @@ -54,13 +54,32 @@ describe("RpcClient operation completion", () => { await expect(events).resolves.toEqual([{ type: "agent_end" }]); }); - it("rejects pending commands and completion waits when the child exits", async () => { + 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 (exited|output closed)/); - const idle = expect(client.waitForIdle()).rejects.toThrow(/RPC process (exited|output closed)/); - const events = expect(client.collectEvents()).rejects.toThrow(/RPC process (exited|output closed)/); + 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"); From 7369191192b85c96326fe7ca6c3b47f667e750c9 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 13:31:10 +0200 Subject: [PATCH 06/39] fix(coding-agent): single-source interactive queue state --- .../.changes/snimu-tui-queue-single-source.md | 1 + .../src/modes/interactive/interactive-mode.ts | 128 ++++---------- .../src/modes/interactive/queue-selection.ts | 21 --- .../test/interactive-mode-ctrl-c.test.ts | 10 +- .../interactive-mode-feature-hints.test.ts | 6 +- .../interactive-mode-prompt-stash.test.ts | 13 +- .../test/interactive-queue-edit.test.ts | 162 +++++------------- .../coding-agent/test/queue-selection.test.ts | 22 --- .../regressions/4741-hint-placement.test.ts | 10 +- 9 files changed, 103 insertions(+), 270 deletions(-) create mode 100644 packages/coding-agent/.changes/snimu-tui-queue-single-source.md 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/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index db5abdb5c6..8d4f4f34c9 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -1017,8 +1017,6 @@ 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(); @@ -2510,24 +2508,28 @@ export class InteractiveMode { } } + private getConnectionQueue(): AgentConnectionQueueState { + return { + steering: [...(this.connectionState?.sessionActions.steering ?? [])], + followUp: [...(this.connectionState?.sessionActions.followUps ?? [])], + }; + } + private async refreshConnectionQueue(): Promise { this.replaceConnectionQueue(await this.agentConnection.getQueue()); } private replaceConnectionQueue(queue: AgentConnectionQueueState): void { - this.connectionQueue = { - steering: [...queue.steering], - followUp: [...queue.followUp], - }; - 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); - } - } + const sessionActions = this.connectionState?.sessionActions; + if (!sessionActions) return; + this.patchConnectionState({ + sessionActions: { + ...sessionActions, + queuedCount: queue.steering.length + queue.followUp.length, + steering: [...queue.steering], + followUps: [...queue.followUp], + }, + }); this.updatePendingMessagesDisplay(); } @@ -2829,7 +2831,6 @@ export class InteractiveMode { this.shortcutGuideContainer.clear(); this.pendingMessagesContainer.clear(); this.queuedMessagesContainer.clear(); - this.connectionQueue = { steering: [], followUp: [] }; this.pendingQueueEdit = undefined; // The selection and its stashed draft belong to the previous session; // every editor draft is cleared below, so discard rather than restore. @@ -4420,7 +4421,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 +5365,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; } @@ -6992,7 +6991,7 @@ export class InteractiveMode { 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(); @@ -7015,40 +7014,16 @@ export class InteractiveMode { 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 = { ...submittedSelection, index: resolvedIndex }; - const queueBefore = this.connectionQueue; - const status = await this.agentConnection.mutateQueuedMessage(selected.lane, selected.index, selected.text, { - type: "move", - direction, - }); + const status = await this.agentConnection.mutateQueuedMessage( + submittedSelection.lane, + submittedSelection.index, + submittedSelection.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(); - } + await this.refreshConnectionQueue(); + 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) => { @@ -7096,33 +7071,12 @@ 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; let status: AgentConnectionQueuedMessageMutationStatus; try { status = await this.agentConnection.mutateQueuedMessage( - selected.lane, - selected.index, - selected.text, + submittedSelection.lane, + submittedSelection.index, + submittedSelection.text, mutation, ); } catch (error) { @@ -7142,22 +7096,11 @@ export class InteractiveMode { 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); - } - } + await this.refreshConnectionQueue(); 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 +7365,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..6701006ae5 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,25 +60,6 @@ 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; - 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; - } - /** Called after a mutation or submit resolved the selection. Returns the stashed draft. */ reset(): string { this.cursor = -1; 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 d37628873f..4f69ab90be 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-queue-edit.test.ts b/packages/coding-agent/test/interactive-queue-edit.test.ts index b53b21c975..cef6039b34 100644 --- a/packages/coding-agent/test/interactive-queue-edit.test.ts +++ b/packages/coding-agent/test/interactive-queue-edit.test.ts @@ -2,9 +2,17 @@ import { describe, expect, it, vi } from "vitest"; 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; @@ -25,8 +33,10 @@ type Harness = { applyQueueSelection: (text: string, targetLane: "steering" | "followUp") => Promise; browseQueueSelection: (direction: -1 | 1) => void; moveQueueSelection: (direction: -1 | 1) => void; + getConnectionQueue: () => QueueState; refreshConnectionQueue: () => Promise; - replaceConnectionQueue: (queue: { steering: string[]; followUp: string[] }) => void; + replaceConnectionQueue: (queue: QueueState) => void; + patchConnectionState: (patch: Partial) => void; setEditorTextFromQueueSelection: (text: string) => void; collectQueueReplaceImages: (text: string) => unknown; }; @@ -37,7 +47,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) => { @@ -64,11 +80,16 @@ function createHarness(queue: { steering: string[]; followUp: string[] }, mutate applyQueueSelection: proto.applyQueueSelection, browseQueueSelection: proto.browseQueueSelection, moveQueueSelection: proto.moveQueueSelection, + getConnectionQueue: proto.getConnectionQueue, refreshConnectionQueue: proto.refreshConnectionQueue, replaceConnectionQueue: proto.replaceConnectionQueue, + patchConnectionState: () => {}, setEditorTextFromQueueSelection: proto.setEditorTextFromQueueSelection, collectQueueReplaceImages: proto.collectQueueReplaceImages, } as unknown as Harness; + harness.patchConnectionState = (patch) => { + harness.connectionState = { ...harness.connectionState, ...patch }; + }; return harness; } @@ -102,7 +123,7 @@ describe("interactive queued-message editing", () => { lane: "followUp", }); - harness.connectionQueue = { steering: ["s1"], followUp: [] }; + harness.replaceConnectionQueue({ steering: ["s1"], followUp: [] }); harness.browseQueueSelection(-1); await harness.applyQueueSelection(" ", "steering"); expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenLastCalledWith("steering", 0, "s1", { @@ -244,30 +265,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 +285,7 @@ describe("interactive queued-message editing", () => { harness.sessionEventGeneration++; harness.pendingQueueEdit = undefined; harness.queueSelection.reset(); - harness.connectionQueue = { steering: ["new queued"], followUp: [] }; + harness.replaceConnectionQueue({ steering: ["new queued"], followUp: [] }); harness.editor.setText("new draft"); harness.browseQueueSelection(-1); @@ -323,104 +320,35 @@ 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); - 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"]); - }); - - it("preserves a queued reorder when an edit immediately exits browse mode", async () => { + it("refreshes queue state from the connection after a move", async () => { const harness = createHarness({ steering: ["s1", "s2"], followUp: [] }); + harness.agentConnection.getQueue.mockResolvedValue({ steering: ["s2", "s1"], followUp: [] }); 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", - }); - }); - - 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"] }); - - harness.browseQueueSelection(-1); // f1 edited - await harness.applyQueueSelection(" ", "followUp"); - expect(harness.connectionQueue).toEqual({ steering: ["s1", "s2"], followUp: [] }); + expect(harness.agentConnection.getQueue).toHaveBeenCalledOnce(); + expect(harness.getConnectionQueue()).toEqual({ steering: ["s2", "s1"], followUp: [] }); }); - it("does not double-apply a delete when the queue event lands before the response", async () => { - const harness = createHarness({ steering: [], followUp: ["dup", "dup"] }); - 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); - return "applied"; + 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); // dup at followUp index 1 - await harness.applyQueueSelection(" ", "followUp"); - expect(harness.connectionQueue).toEqual({ steering: [], followUp: ["dup"] }); - }); - - 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"] }); - }); - - it("restores the stashed draft when the browsed item is consumed externally", () => { - const harness = createHarness({ steering: [], followUp: ["f1"] }); - harness.editor.setText("draft"); - 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"); - }); - - it("synchronizes queue browsing when a reconnect refresh replaces the queue", async () => { - const harness = createHarness({ steering: [], followUp: ["queued"] }); - harness.editor.setText("draft"); harness.browseQueueSelection(-1); - harness.agentConnection.getQueue.mockResolvedValue({ steering: [], followUp: [] }); - - await harness.refreshConnectionQueue(); + const pending = harness.applyQueueSelection("edited", "followUp"); + harness.replaceConnectionQueue({ 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/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); From 3881a53bede2b4a2cc303bdd237d2f60067165ca Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 13:53:22 +0200 Subject: [PATCH 07/39] fix(coding-agent): preserve queue selection after moves --- .../src/modes/interactive/interactive-mode.ts | 29 ++++++----- .../src/modes/interactive/queue-selection.ts | 5 ++ .../test/interactive-queue-edit.test.ts | 50 +++++++++++++++++++ 3 files changed, 69 insertions(+), 15 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 8d4f4f34c9..b13a259aab 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -7008,21 +7008,20 @@ 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 status = await this.agentConnection.mutateQueuedMessage( - submittedSelection.lane, - submittedSelection.index, - submittedSelection.text, - { type: "move", direction }, - ); + const selected = this.queueSelection.selected; + if (!selected) return; + const status = await this.agentConnection.mutateQueuedMessage(selected.lane, selected.index, selected.text, { + type: "move", + direction, + }); if (sessionGeneration !== this.sessionEventGeneration) return; if (status === "applied") { await this.refreshConnectionQueue(); + this.queueSelection.refreshAfterMove(this.getConnectionQueue(), selected.lane, selected.index + direction); this.ui.requestRender(); } else if (status === "unsupported") this.showStatus("Queue editing requires a newer daemon"); else this.showStatus("Queue changed; reorder not applied"); @@ -7039,9 +7038,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; @@ -7071,12 +7068,14 @@ export class InteractiveMode { }; return this.enqueueQueueMutation(async () => { if (discardStaleSelection()) return true; + const selected = this.queueSelection.selected; + if (!selected) return true; let status: AgentConnectionQueuedMessageMutationStatus; try { status = await this.agentConnection.mutateQueuedMessage( - submittedSelection.lane, - submittedSelection.index, - submittedSelection.text, + selected.lane, + selected.index, + selected.text, mutation, ); } catch (error) { diff --git a/packages/coding-agent/src/modes/interactive/queue-selection.ts b/packages/coding-agent/src/modes/interactive/queue-selection.ts index 6701006ae5..cebc520ea4 100644 --- a/packages/coding-agent/src/modes/interactive/queue-selection.ts +++ b/packages/coding-agent/src/modes/interactive/queue-selection.ts @@ -60,6 +60,11 @@ export class QueueSelection { return this.items[next]?.text; } + refreshAfterMove(queue: AgentConnectionQueueState, lane: QueueLane, index: number): void { + this.items = flatten(queue); + this.cursor = lane === "steering" ? index : queue.steering.length + index; + } + /** Called after a mutation or submit resolved the selection. Returns the stashed draft. */ reset(): string { this.cursor = -1; diff --git a/packages/coding-agent/test/interactive-queue-edit.test.ts b/packages/coding-agent/test/interactive-queue-edit.test.ts index cef6039b34..3c5f9a15be 100644 --- a/packages/coding-agent/test/interactive-queue-edit.test.ts +++ b/packages/coding-agent/test/interactive-queue-edit.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import type { QueuedMessageMutation } from "../src/core/session-action-store.js"; import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js"; import { QueueSelection } from "../src/modes/interactive/queue-selection.js"; @@ -329,6 +330,55 @@ describe("interactive queued-message editing", () => { expect(harness.agentConnection.getQueue).toHaveBeenCalledOnce(); expect(harness.getConnectionQueue()).toEqual({ steering: ["s2", "s1"], followUp: [] }); + expect(harness.queueSelection.selected).toEqual({ lane: "steering", index: 0, text: "s2" }); + }); + + 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.getQueue.mockImplementation(async () => ({ 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; + } + return "applied"; + }, + ); + harness.browseQueueSelection(-1); + 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("keeps the selected index when duplicate text shifts before an edit", async () => { From 8891bfa609481abb85f5d14c9dba4b9680a684fe Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 14:07:01 +0200 Subject: [PATCH 08/39] fix(coding-agent): cancel final snapshot before disposal --- .../.changes/kernel-snapshot-dispose-timeout.md | 1 + packages/coding-agent/src/core/kernel/index.ts | 16 +--------------- packages/coding-agent/test/kernel-abort.test.ts | 16 ++++++++-------- 3 files changed, 10 insertions(+), 23 deletions(-) create mode 100644 packages/coding-agent/.changes/kernel-snapshot-dispose-timeout.md diff --git a/packages/coding-agent/.changes/kernel-snapshot-dispose-timeout.md b/packages/coding-agent/.changes/kernel-snapshot-dispose-timeout.md new file mode 100644 index 0000000000..995a26377c --- /dev/null +++ b/packages/coding-agent/.changes/kernel-snapshot-dispose-timeout.md @@ -0,0 +1 @@ +- Fixed graceful IPython kernel disposal so timed-out final snapshots are cancelled before socket teardown. diff --git a/packages/coding-agent/src/core/kernel/index.ts b/packages/coding-agent/src/core/kernel/index.ts index e12cde055d..dd28fb0654 100644 --- a/packages/coding-agent/src/core/kernel/index.ts +++ b/packages/coding-agent/src/core/kernel/index.ts @@ -41,9 +41,6 @@ const DEFAULT_SNAPSHOT_DEBOUNCE_MS = 1500; const FORKED_LIVENESS_POLL_MS = 1000; // Snapshot/restore cells can be large to (de)serialize; give them room beyond the user cap. const SNAPSHOT_MAX_OUTPUT_CHARS = 1_000_000; -// Cap how long a graceful dispose waits on the final snapshot; the debounced -// on-disk copy is the fallback if this is exceeded. -const SNAPSHOT_DISPOSE_TIMEOUT_MS = 5000; const SNAPSHOT_EXECUTION_TIMEOUT_MS = 5000; const KERNEL_ABORT_GRACE_MS = 1000; const KERNEL_BUSY_REUSE_WAIT_MS = 5000; @@ -1794,19 +1791,8 @@ export class KernelManager { } } - /** Best-effort final snapshot before a graceful dispose, bounded by a timeout. */ private async flushSnapshotForDispose(): Promise { - if (!this.options.snapshot || !this.isRunning) return; - let timeout: ReturnType | undefined; - const guard = new Promise((resolve) => { - timeout = globalThis.setTimeout(resolve, SNAPSHOT_DISPOSE_TIMEOUT_MS); - if (timeout && typeof timeout === "object" && "unref" in timeout) timeout.unref(); - }); - try { - await Promise.race([this.snapshotState().then(() => undefined), guard]); - } finally { - if (timeout) clearTimeout(timeout); - } + await this.captureSnapshot({ executionTimeoutMs: SNAPSHOT_EXECUTION_TIMEOUT_MS }); } /** Graceful cleanup. Waits briefly for in-flight host request handlers before closing sockets. */ diff --git a/packages/coding-agent/test/kernel-abort.test.ts b/packages/coding-agent/test/kernel-abort.test.ts index 4afe24d364..9742c2f20e 100644 --- a/packages/coding-agent/test/kernel-abort.test.ts +++ b/packages/coding-agent/test/kernel-abort.test.ts @@ -314,7 +314,7 @@ describe("KernelManager abort handling", () => { manager.disposeSync(); }); - it("starts the snapshot timeout after earlier kernel work finishes", async () => { + it("starts the final snapshot timeout after earlier kernel work finishes", async () => { vi.useFakeTimers(); const manager = new KernelManager({ cwd: process.cwd(), @@ -334,23 +334,22 @@ describe("KernelManager abort handling", () => { ); }), ); + const cleanupResources = vi.fn(); Object.assign( manager as unknown as { state: "running"; executionQueue: Promise; executeInner: typeof executeInner; start: () => Promise; + cleanupResources: () => void; }, - { state: "running", executionQueue: previousExecution, executeInner, start: async () => {} }, + { state: "running", executionQueue: previousExecution, executeInner, start: async () => {}, cleanupResources }, ); - const snapshot = ( - manager as unknown as { - captureSnapshot: (options?: { executionTimeoutMs?: number }) => Promise; - } - ).captureSnapshot({ executionTimeoutMs: 5000 }); + const disposal = manager.dispose(); await vi.advanceTimersByTimeAsync(5000); expect(executeInner).not.toHaveBeenCalled(); + expect(cleanupResources).not.toHaveBeenCalled(); releaseQueue(); await waitForCalls(executeInner, 1); @@ -360,6 +359,7 @@ describe("KernelManager abort handling", () => { expect(signal?.aborted).toBe(false); await vi.advanceTimersByTimeAsync(1); expect(signal?.aborted).toBe(true); - await expect(snapshot).resolves.toBeNull(); + await expect(disposal).resolves.toBeUndefined(); + expect(cleanupResources).toHaveBeenCalledOnce(); }); }); From 0bb6e44077a5c6e7ac680d2ef5b4b2661e86749b Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 14:07:47 +0200 Subject: [PATCH 09/39] fix(coding-agent): reject failed RPC prompts --- .../coding-agent/src/modes/rpc/rpc-client.ts | 3 ++- .../test/rpc-client-timeout.test.ts | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index 2a10f0182c..b403f58912 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -199,7 +199,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); } /** diff --git a/packages/coding-agent/test/rpc-client-timeout.test.ts b/packages/coding-agent/test/rpc-client-timeout.test.ts index 480f5a7278..2646e603e7 100644 --- a/packages/coding-agent/test/rpc-client-timeout.test.ts +++ b/packages/coding-agent/test/rpc-client-timeout.test.ts @@ -39,6 +39,23 @@ describe("RpcClient operation completion", () => { 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; + }); + it("does not time out agent completion by default", async () => { const client = await createClient(); vi.useFakeTimers(); From cf8fda37b045ac2e1a3dc1e281903404632ddf7b Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 14:11:21 +0200 Subject: [PATCH 10/39] fix(coding-agent): rely on queue events after mutations --- .../src/modes/interactive/interactive-mode.ts | 41 ++++++--------- .../src/modes/interactive/queue-selection.ts | 12 ++++- .../test/interactive-queue-edit.test.ts | 50 +++++++++++++------ 3 files changed, 61 insertions(+), 42 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index b13a259aab..4461e95ad0 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -2515,24 +2515,6 @@ export class InteractiveMode { }; } - private async refreshConnectionQueue(): Promise { - this.replaceConnectionQueue(await this.agentConnection.getQueue()); - } - - private replaceConnectionQueue(queue: AgentConnectionQueueState): void { - const sessionActions = this.connectionState?.sessionActions; - if (!sessionActions) return; - this.patchConnectionState({ - sessionActions: { - ...sessionActions, - queuedCount: queue.steering.length + queue.followUp.length, - steering: [...queue.steering], - followUps: [...queue.followUp], - }, - }); - this.updatePendingMessagesDisplay(); - } - private async refreshConnectionCatalog(): Promise { this.invalidateConnectionModelRefresh(); const [state, commands, modelCatalog, resources] = await Promise.all([ @@ -2808,7 +2790,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(); @@ -2889,9 +2872,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(); } @@ -2917,7 +2898,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); @@ -7020,8 +7001,17 @@ export class InteractiveMode { }); if (sessionGeneration !== this.sessionEventGeneration) return; if (status === "applied") { - await this.refreshConnectionQueue(); - this.queueSelection.refreshAfterMove(this.getConnectionQueue(), selected.lane, selected.index + direction); + await this.sessionEventQueue; + if (sessionGeneration !== this.sessionEventGeneration) return; + const dropped = this.queueSelection.refreshAfterMove( + this.getConnectionQueue(), + selected.lane, + selected.index + direction, + selected.text, + ); + if (dropped !== undefined && this.editor.getText() === selected.text) { + this.setEditorTextFromQueueSelection(dropped); + } this.ui.requestRender(); } else if (status === "unsupported") this.showStatus("Queue editing requires a newer daemon"); else this.showStatus("Queue changed; reorder not applied"); @@ -7095,7 +7085,6 @@ export class InteractiveMode { const editorUntouched = submissionGeneration === this.inputSubmissionGeneration && this.editor.getText() === editorTextBefore; if (status === "applied") { - await this.refreshConnectionQueue(); if (trimmed) this.editor.addToHistory?.(trimmed); const draft = this.queueSelection.reset(); if (editorUntouched) this.setEditorTextFromQueueSelection(draft); diff --git a/packages/coding-agent/src/modes/interactive/queue-selection.ts b/packages/coding-agent/src/modes/interactive/queue-selection.ts index cebc520ea4..736cd369c1 100644 --- a/packages/coding-agent/src/modes/interactive/queue-selection.ts +++ b/packages/coding-agent/src/modes/interactive/queue-selection.ts @@ -60,9 +60,17 @@ export class QueueSelection { return this.items[next]?.text; } - refreshAfterMove(queue: AgentConnectionQueueState, lane: QueueLane, index: number): void { + refreshAfterMove( + queue: AgentConnectionQueueState, + lane: QueueLane, + index: number, + expectedText: string, + ): string | undefined { this.items = flatten(queue); - this.cursor = lane === "steering" ? index : queue.steering.length + index; + 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/test/interactive-queue-edit.test.ts b/packages/coding-agent/test/interactive-queue-edit.test.ts index 3c5f9a15be..8d8b51232b 100644 --- a/packages/coding-agent/test/interactive-queue-edit.test.ts +++ b/packages/coding-agent/test/interactive-queue-edit.test.ts @@ -23,10 +23,10 @@ type Harness = { ui: { requestRender: () => void }; agentConnection: { mutateQueuedMessage: ReturnType; - getQueue: ReturnType; abort?: ReturnType; }; sessionEventGeneration: number; + sessionEventQueue: Promise; inputSubmissionGeneration: number; pendingQueueEdit: symbol | undefined; queueMutationChain: Promise; @@ -35,8 +35,6 @@ type Harness = { browseQueueSelection: (direction: -1 | 1) => void; moveQueueSelection: (direction: -1 | 1) => void; getConnectionQueue: () => QueueState; - refreshConnectionQueue: () => Promise; - replaceConnectionQueue: (queue: QueueState) => void; patchConnectionState: (patch: Partial) => void; setEditorTextFromQueueSelection: (text: string) => void; collectQueueReplaceImages: (text: string) => unknown; @@ -70,10 +68,10 @@ 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, queueMutationChain: Promise.resolve(), @@ -82,8 +80,6 @@ function createHarness(queue: { steering: string[]; followUp: string[] }, mutate browseQueueSelection: proto.browseQueueSelection, moveQueueSelection: proto.moveQueueSelection, getConnectionQueue: proto.getConnectionQueue, - refreshConnectionQueue: proto.refreshConnectionQueue, - replaceConnectionQueue: proto.replaceConnectionQueue, patchConnectionState: () => {}, setEditorTextFromQueueSelection: proto.setEditorTextFromQueueSelection, collectQueueReplaceImages: proto.collectQueueReplaceImages, @@ -94,6 +90,15 @@ function createHarness(queue: { steering: string[]; followUp: string[] }, mutate 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, + }; +} + 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"] }); @@ -124,7 +129,7 @@ describe("interactive queued-message editing", () => { lane: "followUp", }); - harness.replaceConnectionQueue({ steering: ["s1"], followUp: [] }); + setQueue(harness, { steering: ["s1"], followUp: [] }); harness.browseQueueSelection(-1); await harness.applyQueueSelection(" ", "steering"); expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenLastCalledWith("steering", 0, "s1", { @@ -205,7 +210,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: [], }); @@ -286,7 +291,7 @@ describe("interactive queued-message editing", () => { harness.sessionEventGeneration++; harness.pendingQueueEdit = undefined; harness.queueSelection.reset(); - harness.replaceConnectionQueue({ steering: ["new queued"], followUp: [] }); + setQueue(harness, { steering: ["new queued"], followUp: [] }); harness.editor.setText("new draft"); harness.browseQueueSelection(-1); @@ -321,22 +326,38 @@ describe("interactive queued-message editing", () => { expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenCalledOnce(); }); - it("refreshes queue state from the connection after a move", async () => { + it("refreshes selection from event-driven queue state after a move", async () => { const harness = createHarness({ steering: ["s1", "s2"], followUp: [] }); - harness.agentConnection.getQueue.mockResolvedValue({ steering: ["s2", "s1"], followUp: [] }); + harness.agentConnection.mutateQueuedMessage.mockImplementation(async () => { + setQueue(harness, { steering: ["s2", "s1"], followUp: [] }); + return "applied"; + }); harness.browseQueueSelection(-1); harness.moveQueueSelection(-1); await harness.queueMutationChain; - expect(harness.agentConnection.getQueue).toHaveBeenCalledOnce(); expect(harness.getConnectionQueue()).toEqual({ steering: ["s2", "s1"], followUp: [] }); expect(harness.queueSelection.selected).toEqual({ lane: "steering", index: 0, text: "s2" }); }); + 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 () => { + setQueue(harness, { steering: ["s1"], followUp: [] }); + return "applied"; + }); + harness.editor.setText("draft"); + harness.browseQueueSelection(-1); + harness.moveQueueSelection(-1); + await harness.queueMutationChain; + + expect(harness.queueSelection.isBrowsing).toBe(false); + expect(harness.editor.getText()).toBe("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.getQueue.mockImplementation(async () => ({ steering: [...queue], followUp: [] })); harness.agentConnection.mutateQueuedMessage.mockImplementation( async ( _lane: "steering" | "followUp", @@ -355,6 +376,7 @@ describe("interactive queued-message editing", () => { } else if (mutation.type === "replace") { queue[index] = mutation.text; } + setQueue(harness, { steering: [...queue], followUp: [] }); return "applied"; }, ); @@ -389,7 +411,7 @@ describe("interactive queued-message editing", () => { }); harness.browseQueueSelection(-1); const pending = harness.applyQueueSelection("edited", "followUp"); - harness.replaceConnectionQueue({ steering: [], followUp: ["dup"] }); + setQueue(harness, { steering: [], followUp: ["dup"] }); releaseMutationChain(); await pending; From bf98b51949c383ab25b83e9eb39e41fc70c518e6 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 14:58:34 +0200 Subject: [PATCH 11/39] test(coding-agent): update resync queue display harness --- .../test/suite/regressions/4509-side-questions.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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..aff5396798 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 @@ -880,7 +880,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 +906,7 @@ describe("ENG-4509 side questions", () => { messages: [], }); + expect(fakeThis.updatePendingMessagesDisplay).toHaveBeenCalledOnce(); expect(bashComponent.setComplete).toHaveBeenCalledWith(undefined, false); expect(finishBash).toHaveBeenCalledOnce(); expect(fakeThis.activeBashComponent).toBeUndefined(); From 3836e61da173b98b23e1a97d2f6d2e0c14daaa5c Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 15:28:31 +0200 Subject: [PATCH 12/39] test(coding-agent): update snapshot queue display seams --- .../test/interactive-mode-status.test.ts | 101 +++++++----------- 1 file changed, 39 insertions(+), 62 deletions(-) diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 4ef691e164..b7aad8620f 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 () => { @@ -1738,7 +1715,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(), @@ -1794,7 +1771,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 +1809,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 +1819,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 +3101,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 +3137,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(), From 0f70c78bff7bddfe692744dc05bd4d5a343a4e58 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 15:58:44 +0200 Subject: [PATCH 13/39] fix(coding-agent): bound snapshot queue wait during teardown --- .../coding-agent/src/core/kernel/index.ts | 13 ++++++ .../coding-agent/test/kernel-abort.test.ts | 44 +++++++------------ 2 files changed, 30 insertions(+), 27 deletions(-) diff --git a/packages/coding-agent/src/core/kernel/index.ts b/packages/coding-agent/src/core/kernel/index.ts index dd28fb0654..4b86dd79fa 100644 --- a/packages/coding-agent/src/core/kernel/index.ts +++ b/packages/coding-agent/src/core/kernel/index.ts @@ -1792,6 +1792,19 @@ export class KernelManager { } private async flushSnapshotForDispose(): Promise { + if (!this.options.snapshot || !this.isRunning) return; + const pendingExecutions = this.executionQueue; + if (this.activeExecution) void this.interrupt().catch(() => undefined); + let timeout: ReturnType | undefined; + const queueSettled = await Promise.race([ + pendingExecutions.then(() => true), + new Promise((resolve) => { + timeout = globalThis.setTimeout(() => resolve(false), SNAPSHOT_EXECUTION_TIMEOUT_MS); + timeout.unref?.(); + }), + ]); + if (timeout) globalThis.clearTimeout(timeout); + if (!queueSettled) return; await this.captureSnapshot({ executionTimeoutMs: SNAPSHOT_EXECUTION_TIMEOUT_MS }); } diff --git a/packages/coding-agent/test/kernel-abort.test.ts b/packages/coding-agent/test/kernel-abort.test.ts index 9742c2f20e..4a4a133738 100644 --- a/packages/coding-agent/test/kernel-abort.test.ts +++ b/packages/coding-agent/test/kernel-abort.test.ts @@ -314,52 +314,42 @@ describe("KernelManager abort handling", () => { manager.disposeSync(); }); - it("starts the final snapshot timeout after earlier kernel work finishes", async () => { + it("tears down when the final snapshot is blocked behind a hung execution", async () => { vi.useFakeTimers(); const manager = new KernelManager({ cwd: process.cwd(), snapshot: { path: "/tmp/test-state.dill", manifestPath: "/tmp/test-state.json" }, }); - let releaseQueue: () => void = () => {}; - const previousExecution = new Promise((resolve) => { - releaseQueue = resolve; - }); - const executeInner = vi.fn( - async (_code: string, opts: { signal?: AbortSignal }) => - await new Promise<{ stdout: string; stderr: string; status: "aborted"; durationMs: number }>((resolve) => { - opts.signal?.addEventListener( - "abort", - () => resolve({ stdout: "", stderr: "", status: "aborted", durationMs: 5000 }), - { once: true }, - ); - }), - ); + const executeInner = vi.fn(); + const interrupt = vi.fn(async () => {}); const cleanupResources = vi.fn(); Object.assign( manager as unknown as { state: "running"; executionQueue: Promise; + activeExecution: object; executeInner: typeof executeInner; - start: () => Promise; + interrupt: typeof interrupt; cleanupResources: () => void; }, - { state: "running", executionQueue: previousExecution, executeInner, start: async () => {}, cleanupResources }, + { + state: "running", + executionQueue: new Promise(() => {}), + activeExecution: {}, + executeInner, + interrupt, + cleanupResources, + }, ); const disposal = manager.dispose(); - await vi.advanceTimersByTimeAsync(5000); - expect(executeInner).not.toHaveBeenCalled(); - expect(cleanupResources).not.toHaveBeenCalled(); - - releaseQueue(); - await waitForCalls(executeInner, 1); - const signal = executeInner.mock.calls[0]?.[1].signal; - expect(signal?.aborted).toBe(false); + expect(interrupt).toHaveBeenCalledOnce(); await vi.advanceTimersByTimeAsync(4999); - expect(signal?.aborted).toBe(false); + expect(cleanupResources).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); - expect(signal?.aborted).toBe(true); + await expect(disposal).resolves.toBeUndefined(); + expect(executeInner).not.toHaveBeenCalled(); expect(cleanupResources).toHaveBeenCalledOnce(); }); }); From 0338d3211f1fbfb42d0c29a9231e3474a634d044 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 16:00:54 +0200 Subject: [PATCH 14/39] fix(coding-agent): reconcile queue browsing on events --- .../src/modes/interactive/interactive-mode.ts | 58 +++++++++++++------ .../src/modes/interactive/queue-selection.ts | 2 +- .../test/interactive-queue-edit.test.ts | 51 +++++++++++++++- 3 files changed, 88 insertions(+), 23 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 4461e95ad0..5f912e3446 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, @@ -1021,6 +1021,7 @@ export class InteractiveMode { private isApplyingQueueSelectionText = false; private queueMutationChain: Promise = Promise.resolve(); private pendingQueueEdit: symbol | undefined; + private pendingQueueMove = false; private shutdownRequested = false; @@ -2647,9 +2648,14 @@ export class InteractiveMode { case "agent_end": this.patchConnectionState({ isStreaming: false, activeToolNames: [] }); break; - case "session_action_update": + case "session_action_update": { this.patchConnectionState({ sessionActions: event.actions }); + const selected = this.queueSelection.selected; + if (selected && !this.pendingQueueEdit && !this.pendingQueueMove) { + this.refreshQueueSelectionAt(this.getConnectionQueue(), selected, selected.index); + } break; + } case "compaction_start": this.patchConnectionState({ isCompacting: true }); break; @@ -2815,6 +2821,7 @@ export class InteractiveMode { this.pendingMessagesContainer.clear(); this.queuedMessagesContainer.clear(); 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(); @@ -6970,6 +6977,17 @@ 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.getConnectionQueue(), this.editor.getText(), direction); @@ -6995,26 +7013,28 @@ export class InteractiveMode { if (sessionGeneration !== this.sessionEventGeneration) return; const selected = this.queueSelection.selected; if (!selected) return; - const status = await this.agentConnection.mutateQueuedMessage(selected.lane, selected.index, selected.text, { - type: "move", - direction, - }); - if (sessionGeneration !== this.sessionEventGeneration) return; - if (status === "applied") { - await this.sessionEventQueue; - if (sessionGeneration !== this.sessionEventGeneration) return; - const dropped = this.queueSelection.refreshAfterMove( - this.getConnectionQueue(), + this.pendingQueueMove = true; + try { + const status = await this.agentConnection.mutateQueuedMessage( selected.lane, - selected.index + direction, + selected.index, selected.text, + { + type: "move", + direction, + }, ); - if (dropped !== undefined && this.editor.getText() === selected.text) { - this.setEditorTextFromQueueSelection(dropped); - } - this.ui.requestRender(); - } else if (status === "unsupported") this.showStatus("Queue editing requires a newer daemon"); - else this.showStatus("Queue changed; reorder not applied"); + if (sessionGeneration !== this.sessionEventGeneration) return; + if (status === "applied") { + await this.sessionEventQueue; + if (sessionGeneration !== this.sessionEventGeneration) return; + this.refreshQueueSelectionAt(this.getConnectionQueue(), selected, selected.index + direction); + 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; + } }).catch((error) => { if (sessionGeneration === this.sessionEventGeneration) { this.showError(error instanceof Error ? error.message : String(error)); diff --git a/packages/coding-agent/src/modes/interactive/queue-selection.ts b/packages/coding-agent/src/modes/interactive/queue-selection.ts index 736cd369c1..e0f3444531 100644 --- a/packages/coding-agent/src/modes/interactive/queue-selection.ts +++ b/packages/coding-agent/src/modes/interactive/queue-selection.ts @@ -60,7 +60,7 @@ export class QueueSelection { return this.items[next]?.text; } - refreshAfterMove( + refreshAt( queue: AgentConnectionQueueState, lane: QueueLane, index: number, diff --git a/packages/coding-agent/test/interactive-queue-edit.test.ts b/packages/coding-agent/test/interactive-queue-edit.test.ts index 8d8b51232b..761bb043e8 100644 --- a/packages/coding-agent/test/interactive-queue-edit.test.ts +++ b/packages/coding-agent/test/interactive-queue-edit.test.ts @@ -1,5 +1,6 @@ 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"; @@ -29,12 +30,19 @@ type Harness = { 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; getConnectionQueue: () => QueueState; + refreshQueueSelectionAt: ( + queue: QueueState, + selected: { lane: "steering" | "followUp"; index: number; text: string }, + index: number, + ) => void; + updateConnectionStateFromEvent: (event: AgentConnectionSessionEvent) => void; patchConnectionState: (patch: Partial) => void; setEditorTextFromQueueSelection: (text: string) => void; collectQueueReplaceImages: (text: string) => unknown; @@ -74,12 +82,15 @@ function createHarness(queue: { steering: string[]; followUp: string[] }, mutate sessionEventQueue: Promise.resolve(), inputSubmissionGeneration: 0, pendingQueueEdit: undefined, + pendingQueueMove: false, queueMutationChain: Promise.resolve(), enqueueQueueMutation: proto.enqueueQueueMutation, applyQueueSelection: proto.applyQueueSelection, browseQueueSelection: proto.browseQueueSelection, moveQueueSelection: proto.moveQueueSelection, getConnectionQueue: proto.getConnectionQueue, + refreshQueueSelectionAt: proto.refreshQueueSelectionAt, + updateConnectionStateFromEvent: proto.updateConnectionStateFromEvent, patchConnectionState: () => {}, setEditorTextFromQueueSelection: proto.setEditorTextFromQueueSelection, collectQueueReplaceImages: proto.collectQueueReplaceImages, @@ -99,6 +110,17 @@ function setQueue(harness: Harness, queue: QueueState): void { }; } +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"] }); @@ -326,10 +348,33 @@ describe("interactive queued-message editing", () => { expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenCalledOnce(); }); + 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 () => { - setQueue(harness, { steering: ["s2", "s1"], followUp: [] }); + emitQueueUpdate(harness, { steering: ["s2", "s1"], followUp: [] }); return "applied"; }); harness.browseQueueSelection(-1); @@ -343,7 +388,7 @@ describe("interactive queued-message editing", () => { 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 () => { - setQueue(harness, { steering: ["s1"], followUp: [] }); + emitQueueUpdate(harness, { steering: ["s1"], followUp: [] }); return "applied"; }); harness.editor.setText("draft"); @@ -376,7 +421,7 @@ describe("interactive queued-message editing", () => { } else if (mutation.type === "replace") { queue[index] = mutation.text; } - setQueue(harness, { steering: [...queue], followUp: [] }); + emitQueueUpdate(harness, { steering: [...queue], followUp: [] }); return "applied"; }, ); From f34b7135de8fb2a0d80ecbeafaa3305fe7484981 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 16:03:29 +0200 Subject: [PATCH 15/39] fix(coding-agent): cancel failed RPC event waits --- .../coding-agent/src/modes/rpc/rpc-client.ts | 51 +++++++++++++------ .../test/rpc-client-timeout.test.ts | 1 + 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index b403f58912..fa3ea7de1a 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -63,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 // ============================================================================ @@ -576,8 +581,32 @@ export class RpcClient { * Collect events until agent becomes idle. */ collectEvents(timeout?: number): Promise { - if (this.transportError) return Promise.reject(this.transportError); - return new Promise((resolve, reject) => { + return this.startEventCollection(timeout).promise; + } + + /** + * Send prompt and wait for completion, returning all events. + */ + 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 = () => { @@ -596,6 +625,10 @@ export class RpcClient { resolve(events); } }); + cancel = () => { + cleanup(); + resolve(events); + }; this.pendingEventWaiters.add(onFailure); if (timeout !== undefined) { timer = setTimeout(() => { @@ -604,21 +637,9 @@ export class RpcClient { }, timeout); } }); + return { promise, cancel }; } - /** - * Send prompt and wait for completion, returning all events. - */ - async promptAndWait(message: string, images?: ImageContent[], timeout?: number): Promise { - const eventsPromise = this.collectEvents(timeout); - const [events] = await Promise.all([eventsPromise, this.prompt(message, images)]); - return events; - } - - // ========================================================================= - // Internal - // ========================================================================= - private handleLine(line: string): void { try { const data = JSON.parse(line); diff --git a/packages/coding-agent/test/rpc-client-timeout.test.ts b/packages/coding-agent/test/rpc-client-timeout.test.ts index 2646e603e7..f505a53b2c 100644 --- a/packages/coding-agent/test/rpc-client-timeout.test.ts +++ b/packages/coding-agent/test/rpc-client-timeout.test.ts @@ -54,6 +54,7 @@ describe("RpcClient operation completion", () => { ); await result; + expect(client["pendingEventWaiters"].size).toBe(0); }); it("does not time out agent completion by default", async () => { From ce60aac42a66d01fba4f5ae1243856f9bab03190 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 16:26:47 +0200 Subject: [PATCH 16/39] test(coding-agent): cover final snapshot execution timeout --- .../coding-agent/test/kernel-abort.test.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packages/coding-agent/test/kernel-abort.test.ts b/packages/coding-agent/test/kernel-abort.test.ts index 4a4a133738..78da1f27a0 100644 --- a/packages/coding-agent/test/kernel-abort.test.ts +++ b/packages/coding-agent/test/kernel-abort.test.ts @@ -314,6 +314,54 @@ describe("KernelManager abort handling", () => { manager.disposeSync(); }); + it("cancels a hung final snapshot execution before teardown", async () => { + vi.useFakeTimers(); + const manager = new KernelManager({ + cwd: process.cwd(), + snapshot: { path: "/tmp/test-state.dill", manifestPath: "/tmp/test-state.json" }, + }); + let releaseQueue: () => void = () => {}; + const previousExecution = new Promise((resolve) => { + releaseQueue = resolve; + }); + const executeInner = vi.fn( + async (_code: string, opts: { signal?: AbortSignal }) => + await new Promise<{ stdout: string; stderr: string; status: "aborted"; durationMs: number }>((resolve) => { + opts.signal?.addEventListener( + "abort", + () => resolve({ stdout: "", stderr: "", status: "aborted", durationMs: 5000 }), + { once: true }, + ); + }), + ); + const cleanupResources = vi.fn(); + Object.assign( + manager as unknown as { + state: "running"; + executionQueue: Promise; + executeInner: typeof executeInner; + start: () => Promise; + cleanupResources: () => void; + }, + { state: "running", executionQueue: previousExecution, executeInner, start: async () => {}, cleanupResources }, + ); + + const disposal = manager.dispose(); + expect(executeInner).not.toHaveBeenCalled(); + releaseQueue(); + await waitForCalls(executeInner, 1); + const signal = executeInner.mock.calls[0]?.[1].signal; + expect(signal?.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(4999); + expect(signal?.aborted).toBe(false); + expect(cleanupResources).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + + expect(signal?.aborted).toBe(true); + await expect(disposal).resolves.toBeUndefined(); + expect(cleanupResources).toHaveBeenCalledOnce(); + }); + it("tears down when the final snapshot is blocked behind a hung execution", async () => { vi.useFakeTimers(); const manager = new KernelManager({ From 7e4f95a86f06572482160a850478a42dc6257d3d Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 16:44:22 +0200 Subject: [PATCH 17/39] fix(coding-agent): serialize post-compaction continuation --- .../.changes/post-compaction-idle.md | 1 + .../coding-agent/src/core/agent-session.ts | 123 ++++++++++-------- .../test/suite/agent-session-queue.test.ts | 48 ++++--- 3 files changed, 93 insertions(+), 79 deletions(-) create mode 100644 packages/coding-agent/.changes/post-compaction-idle.md diff --git a/packages/coding-agent/.changes/post-compaction-idle.md b/packages/coding-agent/.changes/post-compaction-idle.md new file mode 100644 index 0000000000..c059f46e0b --- /dev/null +++ b/packages/coding-agent/.changes/post-compaction-idle.md @@ -0,0 +1 @@ +- Removed the delay before continuing sessions after compaction. diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 96a4dce54d..0a6cce31df 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1198,7 +1198,6 @@ export class AgentSession { private _compactAutoRefinePending = false; private _turnIntervalAutoRefinePending = false; private _postCompactionContinuationScheduled = false; - private _postCompactionContinuationTimer: ReturnType | undefined; private _postCompactionContinuationSettlement: PostCompactionContinuationSettlement | undefined; private _postCompactionContinuationMessages: AgentMessage[] = []; private _scheduledPostCompactionContinuationMessages: AgentMessage[] = []; @@ -7498,7 +7497,7 @@ export class AgentSession { } private _settlePostCompactionContinue(error?: Error): void { - if (!error && (this._postCompactionContinuationScheduled || this._postCompactionContinuationTimer)) return; + if (!error && this._postCompactionContinuationScheduled) return; const settlement = this._postCompactionContinuationSettlement; if (!settlement || settlement.settled) return; settlement.settled = true; @@ -7508,10 +7507,6 @@ export class AgentSession { } private _cancelPostCompactionContinue(): void { - if (this._postCompactionContinuationTimer) { - clearTimeout(this._postCompactionContinuationTimer); - this._postCompactionContinuationTimer = undefined; - } this._postCompactionContinuationScheduled = false; this._scheduledPostCompactionContinuationMessages = []; this._settlePostCompactionContinue(); @@ -7614,68 +7609,90 @@ export class AgentSession { if (!this._postCompactionContinuationSettlement || this._postCompactionContinuationSettlement.settled) { this._postCompactionContinuationSettlement = createPostCompactionContinuationSettlement(); } + const settlement = this._postCompactionContinuationSettlement; this._postCompactionContinuationScheduled = true; this._scheduledPostCompactionContinuationMessages = [...this._postCompactionContinuationMessages]; - this._postCompactionContinuationTimer = setTimeout(() => { - this._postCompactionContinuationTimer = undefined; - void this._runScheduledPostCompactionContinue() - .catch(() => undefined) - .finally(() => this._settlePostCompactionContinue()); - }, 100); + void this._runScheduledPostCompactionContinue(settlement) + .catch(() => undefined) + .finally(() => { + if (this._postCompactionContinuationSettlement === settlement) { + this._settlePostCompactionContinue(); + } + }); } private _sessionOwnsScheduledContinuations(continuationMessages: AgentMessage[]): boolean { return continuationMessages.some((message) => this._postCompactionContinuationMessages.includes(message)); } - private async _runScheduledPostCompactionContinue(): Promise { - await this._waitForRefineIdle(); - if (!this._postCompactionContinuationScheduled) { - return; - } - if (this.isStreaming || this.isCompacting || this.isRetrying || this._queuedWorkPauses.size > 0) { - this._postCompactionContinuationScheduled = false; - this._schedulePostCompactionContinue(); - return; - } + private async _runScheduledPostCompactionContinue(settlement: PostCompactionContinuationSettlement): Promise { + while (this._postCompactionContinuationScheduled && this._postCompactionContinuationSettlement === settlement) { + await this.agent.waitForIdle(); + await this.waitForRetry(); + await this._waitForRefineIdle(); - const continuationMessages = [...this._scheduledPostCompactionContinuationMessages]; - if (continuationMessages.length > 0 && !this._sessionOwnsScheduledContinuations(continuationMessages)) { - this._cancelPostCompactionContinue(); - this._scheduleAutoRefineAfterAgentEnd(); - return; - } - // An empty queue is not idle while the scheduler still owns active work. - if (this.unfinishedActionCount > 0 || this._sessionInputPumpRequested) { - this._scheduleSessionInputPump(); - await this._sessionInputPump; - if (this._postCompactionContinuationScheduled) { - this._postCompactionContinuationScheduled = false; - const shouldReschedule = + const commitFence = await this._acquireSessionActionCommitFence(); + let continuation: Promise | undefined; + let continuationMessages: AgentMessage[] = []; + let waitForSessionInput = false; + try { + await this.agent.waitForIdle(); + if ( + !this._postCompactionContinuationScheduled || + this._postCompactionContinuationSettlement !== settlement + ) { + return; + } + + continuationMessages = [...this._scheduledPostCompactionContinuationMessages]; + if (continuationMessages.length > 0 && !this._sessionOwnsScheduledContinuations(continuationMessages)) { + this._cancelPostCompactionContinue(); + this._scheduleAutoRefineAfterAgentEnd(); + return; + } + if (this.unfinishedActionCount > 0 || this._sessionInputPumpRequested) { + this._scheduleSessionInputPump(); + waitForSessionInput = true; + } else { + this._postCompactionContinuationScheduled = false; + continuation = this.agent.continue(); + } + } finally { + commitFence.release(); + } + + if (waitForSessionInput) { + await this.waitForIdle(); + if (this._postCompactionContinuationSettlement !== settlement) return; + const shouldContinue = continuationMessages.length === 0 ? this.unfinishedActionCount > 0 : this._sessionOwnsScheduledContinuations(continuationMessages); - if (shouldReschedule) { - this._schedulePostCompactionContinue(); - } else { - this._scheduledPostCompactionContinuationMessages = []; - this._scheduleAutoRefineAfterAgentEnd(); + if (shouldContinue) { + this._scheduledPostCompactionContinuationMessages = [...this._postCompactionContinuationMessages]; + continue; } + this._postCompactionContinuationScheduled = false; + this._scheduledPostCompactionContinuationMessages = []; + this._scheduleAutoRefineAfterAgentEnd(); + return; } - return; - } - this._postCompactionContinuationScheduled = false; - try { - await this.agent.continue(); - this._forgetConsumedPostCompactionContinuations(continuationMessages); - } catch (error) { - const code = error instanceof AgentContinueError ? error.code : undefined; - if (code === "busy") { - this._schedulePostCompactionContinue(); - } else if (code !== "nothing-to-continue") { - // "nothing-to-continue" means the turn already completed; anything else must reject headless idle waiters. - this._settlePostCompactionContinue(this._asError(error)); + try { + await continuation; + this._forgetConsumedPostCompactionContinuations(continuationMessages); + return; + } catch (error) { + const code = error instanceof AgentContinueError ? error.code : undefined; + if (code === "busy") { + this._postCompactionContinuationScheduled = true; + this._scheduledPostCompactionContinuationMessages = [...this._postCompactionContinuationMessages]; + continue; + } + if (code !== "nothing-to-continue") { + this._settlePostCompactionContinue(this._asError(error)); + } + return; } } } diff --git a/packages/coding-agent/test/suite/agent-session-queue.test.ts b/packages/coding-agent/test/suite/agent-session-queue.test.ts index 3c19d7cfe3..e61e6a8d5b 100644 --- a/packages/coding-agent/test/suite/agent-session-queue.test.ts +++ b/packages/coding-agent/test/suite/agent-session-queue.test.ts @@ -327,13 +327,18 @@ describe("AgentSession queue characterization", () => { } }); - it("retries a scheduled post-compaction continuation when another run starts first", async () => { - vi.useFakeTimers(); + it("waits for the active run to settle before retrying a post-compaction continuation", async () => { const harness = await createAutoRefineHarness({ settings: { autoRefine: { enabled: true, turnInterval: 25, cooldownMs: 0 } }, }); harnesses.push(harness); const internals = harness.session as unknown as AutoRefineInternals; + const activeRunSettled = createDeferred(); + vi.spyOn(harness.session.agent, "waitForIdle") + .mockResolvedValueOnce() + .mockResolvedValueOnce() + .mockReturnValueOnce(activeRunSettled.promise) + .mockResolvedValue(); const continueAgent = vi .spyOn(harness.session.agent, "continue") .mockRejectedValueOnce( @@ -341,20 +346,13 @@ describe("AgentSession queue characterization", () => { ) .mockResolvedValueOnce(); - try { - internals._schedulePostCompactionContinue(); - await vi.advanceTimersByTimeAsync(100); - - expect(continueAgent).toHaveBeenCalledTimes(1); - expect(internals._postCompactionContinuationScheduled).toBe(true); + internals._schedulePostCompactionContinue(); + await vi.waitFor(() => expect(continueAgent).toHaveBeenCalledTimes(1)); + expect(internals._postCompactionContinuationScheduled).toBe(true); - await vi.advanceTimersByTimeAsync(100); - - expect(continueAgent).toHaveBeenCalledTimes(2); - expect(internals._postCompactionContinuationScheduled).toBe(false); - } finally { - vi.useRealTimers(); - } + activeRunSettled.resolve(); + await vi.waitFor(() => expect(continueAgent).toHaveBeenCalledTimes(2)); + expect(internals._postCompactionContinuationScheduled).toBe(false); }); it("cancels scheduled post-compaction continuation on branch changes", async () => { @@ -407,24 +405,22 @@ describe("AgentSession queue characterization", () => { }); it("keeps scheduled post-compaction continuation when session-input pump compaction skips without aborting", async () => { - vi.useFakeTimers(); const harness = await createAutoRefineHarness({ settings: { autoRefine: { enabled: true, turnInterval: 25, cooldownMs: 0 } }, }); harnesses.push(harness); const internals = harness.session as unknown as AutoRefineInternals; - try { - internals._schedulePostCompactionContinue(); + const idle = createDeferred(); + vi.spyOn(harness.session.agent, "waitForIdle").mockReturnValue(idle.promise); + internals._schedulePostCompactionContinue(); - await expect(harness.session.compact(undefined, { skipAbort: true })).rejects.toThrow( - "Session is too short to compact", - ); + await expect(harness.session.compact(undefined, { skipAbort: true })).rejects.toThrow( + "Session is too short to compact", + ); - expect(internals._postCompactionContinuationScheduled).toBe(true); - } finally { - internals._cancelPostCompactionContinue(); - vi.useRealTimers(); - } + expect(internals._postCompactionContinuationScheduled).toBe(true); + internals._cancelPostCompactionContinue(); + idle.resolve(); }); it("auto-refine pending review uses the in-progress guard and catches refine failures", async () => { From 5c9cb2474390e96a8c5947e20c7e133f4055265b Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 16:51:46 +0200 Subject: [PATCH 18/39] test(coding-agent): await post-compaction retry settlement --- .../test/suite/agent-session-compaction.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/test/suite/agent-session-compaction.test.ts b/packages/coding-agent/test/suite/agent-session-compaction.test.ts index 234179b9ec..58b20bb71a 100644 --- a/packages/coding-agent/test/suite/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/suite/agent-session-compaction.test.ts @@ -4,6 +4,7 @@ import { type AssistantMessage, fauxAssistantMessage, type Model, type ToolResul import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SessionManager } from "../../src/core/session-manager.js"; import { createHarness, getMessageText, type Harness } from "./harness.js"; +import { createDeferred } from "./scheduling.js"; type SessionWithCompactionInternals = { _checkCompaction: ( @@ -919,7 +920,6 @@ describe("AgentSession compaction characterization", () => { }); it("keeps autonomous threshold continuations when post-compaction continue must retry", async () => { - vi.useFakeTimers(); const harness = await createHarness({ autonomous: { enabled: true, @@ -933,6 +933,7 @@ describe("AgentSession compaction characterization", () => { harnesses.push(harness); const sessionInternals = harness.session as unknown as { _schedulePostCompactionContinue(): void; + _cancelPostCompactionContinue(): void; _postCompactionContinuationMessages: AgentMessage[]; _postCompactionContinuationScheduled: boolean; }; @@ -945,16 +946,23 @@ describe("AgentSession compaction characterization", () => { harness.session.agent.state.messages = [ { role: "user", content: [{ type: "text", text: "hello" }], timestamp: Date.now() - 1000 }, ]; + const activeRunSettled = createDeferred(); + vi.spyOn(harness.session.agent, "waitForIdle") + .mockResolvedValueOnce() + .mockResolvedValueOnce() + .mockReturnValueOnce(activeRunSettled.promise) + .mockResolvedValue(); const continueSpy = vi .spyOn(harness.session.agent, "continue") .mockRejectedValueOnce(new AgentContinueError("busy", "already processing")); sessionInternals._schedulePostCompactionContinue(); - await vi.advanceTimersByTimeAsync(100); + await vi.waitFor(() => expect(continueSpy).toHaveBeenCalledTimes(1)); - expect(continueSpy).toHaveBeenCalledTimes(1); expect(sessionInternals._postCompactionContinuationMessages).toEqual([queuedMessage]); expect(sessionInternals._postCompactionContinuationScheduled).toBe(true); + sessionInternals._cancelPostCompactionContinue(); + activeRunSettled.resolve(); }); it("clears queued autonomous threshold continuations when autonomous mode is disabled", async () => { From 93bb54862dd4988957dfcb56a929b0bb3ba16844 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 16:52:25 +0200 Subject: [PATCH 19/39] test(coding-agent): avoid retry call-order assumptions --- .../test/suite/agent-session-compaction.test.ts | 8 +++----- .../coding-agent/test/suite/agent-session-queue.test.ts | 8 +++----- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/test/suite/agent-session-compaction.test.ts b/packages/coding-agent/test/suite/agent-session-compaction.test.ts index 58b20bb71a..ddef4a8265 100644 --- a/packages/coding-agent/test/suite/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/suite/agent-session-compaction.test.ts @@ -947,14 +947,12 @@ describe("AgentSession compaction characterization", () => { { role: "user", content: [{ type: "text", text: "hello" }], timestamp: Date.now() - 1000 }, ]; const activeRunSettled = createDeferred(); - vi.spyOn(harness.session.agent, "waitForIdle") - .mockResolvedValueOnce() - .mockResolvedValueOnce() - .mockReturnValueOnce(activeRunSettled.promise) - .mockResolvedValue(); const continueSpy = vi .spyOn(harness.session.agent, "continue") .mockRejectedValueOnce(new AgentContinueError("busy", "already processing")); + vi.spyOn(harness.session.agent, "waitForIdle").mockImplementation(() => + continueSpy.mock.calls.length === 0 ? Promise.resolve() : activeRunSettled.promise, + ); sessionInternals._schedulePostCompactionContinue(); await vi.waitFor(() => expect(continueSpy).toHaveBeenCalledTimes(1)); diff --git a/packages/coding-agent/test/suite/agent-session-queue.test.ts b/packages/coding-agent/test/suite/agent-session-queue.test.ts index e61e6a8d5b..d1c708253f 100644 --- a/packages/coding-agent/test/suite/agent-session-queue.test.ts +++ b/packages/coding-agent/test/suite/agent-session-queue.test.ts @@ -334,17 +334,15 @@ describe("AgentSession queue characterization", () => { harnesses.push(harness); const internals = harness.session as unknown as AutoRefineInternals; const activeRunSettled = createDeferred(); - vi.spyOn(harness.session.agent, "waitForIdle") - .mockResolvedValueOnce() - .mockResolvedValueOnce() - .mockReturnValueOnce(activeRunSettled.promise) - .mockResolvedValue(); const continueAgent = vi .spyOn(harness.session.agent, "continue") .mockRejectedValueOnce( new AgentContinueError("busy", "Agent is already processing. Wait for completion before continuing."), ) .mockResolvedValueOnce(); + vi.spyOn(harness.session.agent, "waitForIdle").mockImplementation(() => + continueAgent.mock.calls.length === 0 ? Promise.resolve() : activeRunSettled.promise, + ); internals._schedulePostCompactionContinue(); await vi.waitFor(() => expect(continueAgent).toHaveBeenCalledTimes(1)); From 8449cb206233a70f5a3330fbc7c87b57c55e11f1 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 18:38:28 +0200 Subject: [PATCH 20/39] fix(coding-agent): guard stale continuation retry --- packages/coding-agent/src/core/agent-session.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 0a6cce31df..8e1738b0eb 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -7685,8 +7685,10 @@ export class AgentSession { } catch (error) { const code = error instanceof AgentContinueError ? error.code : undefined; if (code === "busy") { - this._postCompactionContinuationScheduled = true; - this._scheduledPostCompactionContinuationMessages = [...this._postCompactionContinuationMessages]; + if (this._postCompactionContinuationSettlement === settlement) { + this._postCompactionContinuationScheduled = true; + this._scheduledPostCompactionContinuationMessages = [...this._postCompactionContinuationMessages]; + } continue; } if (code !== "nothing-to-continue") { From 58989de5f1498ebe91115c00d436c5b8f46e8217 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 18:53:08 +0200 Subject: [PATCH 21/39] fix(coding-agent): unify RLM child snapshots --- .../.changes/rlm-child-snapshot.md | 1 + .../coding-agent/src/core/agent-session.ts | 145 ++++++++-------- .../src/modes/daemon/daemon-session-list.ts | 91 ++-------- .../test/agent-session-recursion.test.ts | 17 +- .../test/daemon-session-list.test.ts | 159 +++--------------- 5 files changed, 128 insertions(+), 285 deletions(-) create mode 100644 packages/coding-agent/.changes/rlm-child-snapshot.md diff --git a/packages/coding-agent/.changes/rlm-child-snapshot.md b/packages/coding-agent/.changes/rlm-child-snapshot.md new file mode 100644 index 0000000000..ce9aa258aa --- /dev/null +++ b/packages/coding-agent/.changes/rlm-child-snapshot.md @@ -0,0 +1 @@ +- Fixed reattached sessions omitting queued child agents or showing the wrong child activity. diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 96a4dce54d..337262df16 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -882,6 +882,10 @@ interface RlmChildRun { sessionDir: string; model: Model; status: RlmChildAgentStatus; + durationMs?: number; + answerPreview?: string; + toolUseCount: number; + activity?: RlmChildAgentActivity; error?: string; abort: () => void; publication: AgentMessageDeferred; @@ -911,6 +915,11 @@ interface RlmChildRun { unsubscribe?: () => void; } +interface RetainedRlmChild { + session: AgentSession; + run?: RlmChildRun; +} + interface RlmSubagentModelSelection { model: Model; } @@ -1163,7 +1172,7 @@ export class AgentSession { private _pendingRlmSubagentSessionNames = new Set(); // Inline mode keeps finished child sessions so the inspector can still read them; // the daemon does the same by leaving the child session resident in its registry. - private _rlmChildSessions = new Map(); + private _rlmChildSessions = new Map(); private _deletedRlmChildIds = new Set(); // Failed explicit deletes stay hidden from listings but retain their original // selector so a later delete can retry cleanup without orphaning the runtime. @@ -4040,7 +4049,7 @@ export class AgentSession { unsubscribe(); } this._rlmChildUnsubscribes.clear(); - for (const session of this._rlmChildSessions.values()) { + for (const { session } of this._rlmChildSessions.values()) { await session.disposeAsync().catch(() => undefined); } this._rlmChildSessions.clear(); @@ -4102,7 +4111,7 @@ export class AgentSession { unsubscribe(); } this._rlmChildUnsubscribes.clear(); - for (const session of this._rlmChildSessions.values()) { + for (const { session } of this._rlmChildSessions.values()) { session.dispose(); } this._rlmChildSessions.clear(); @@ -9503,7 +9512,7 @@ export class AgentSession { }); recorded.add(run.id); } - for (const [childId, childSession] of this._rlmChildSessions) { + for (const [childId, { session: childSession }] of this._rlmChildSessions) { if ( this._deletingRlmChildren.has(childId) || recorded.has(childId) || @@ -9591,7 +9600,7 @@ export class AgentSession { return result; } } - for (const retained of this._rlmChildSessions.values()) { + for (const { session: retained } of this._rlmChildSessions.values()) { const result = await retained.deleteInactiveRlmSubagent(childId, isExternallyRunning); if (result !== "not_found") { return result; @@ -9868,7 +9877,7 @@ export class AgentSession { } this._emitRlmSubagentRemoval(subagent); - const retained = this._rlmChildSessions.get(childId); + const retained = this._rlmChildSessions.get(childId)?.session; try { await this._deleteRlmSubagentSession(childId, retained); } catch (error) { @@ -9904,7 +9913,7 @@ export class AgentSession { void session.disposeAsync().catch(() => undefined); return false; } - this._rlmChildSessions.set(childId, session); + this._rlmChildSessions.set(childId, { session, run: this._activeRlmChildRuns.get(childId) }); if (unsubscribe) { this._rlmChildUnsubscribes.set(childId, unsubscribe); } @@ -9919,13 +9928,34 @@ export class AgentSession { this._activeRlmChildRuns.delete(childId); return unsubscribe; } - if (this._rlmChildSessions.get(childId) !== session) return false; + if (this._rlmChildSessions.get(childId)?.session !== session) return false; const unsubscribe = this._rlmChildUnsubscribes.get(childId) ?? noopRlmChildEventUnsubscribe; this._rlmChildUnsubscribes.delete(childId); this._rlmChildSessions.delete(childId); return unsubscribe; } + private _rlmChildSnapshotForRun(run: RlmChildRun, child = run.session): RlmChildAgentSnapshot { + const model = child?.model ?? run.model; + return { + id: run.id, + parentId: this._rlmParentNodeId, + sessionName: child?.sessionName ?? run.sessionName, + model: `${model.provider}/${model.id}`, + label: rlmChildLabel(run.prompt), + status: run.status, + durationMs: run.durationMs, + answerPreview: run.answerPreview, + toolUseCount: run.toolUseCount > 0 ? run.toolUseCount : undefined, + tokenCount: child?._contextTokensForCurrentMessages(), + recap: child?.getCurrentRecap(), + sessionDir: run.sessionDir, + activity: run.activity, + repliedSinceTask: child?._repliedToParentSinceTask, + error: run.error, + }; + } + /** Live recursive child roster from lifecycle state, including nested work under retained parents. */ getRlmChildSnapshots(): RlmChildAgentSnapshot[] { const snapshots: RlmChildAgentSnapshot[] = []; @@ -9936,16 +9966,7 @@ export class AgentSession { run.detachedDeletion || this._deletingRlmChildren.has(run.id) || this._deletedRlmChildIds.has(run.id); const child = run.session; if (!hidden) { - const model = child?.model ?? run.model; - snapshots.push({ - id: run.id, - parentId: this._rlmParentNodeId, - sessionName: child?.sessionName ?? run.sessionName, - model: `${model.provider}/${model.id}`, - label: rlmChildLabel(run.prompt), - status: run.status, - sessionDir: run.sessionDir, - }); + snapshots.push(this._rlmChildSnapshotForRun(run)); recorded.add(run.id); } if (child) { @@ -9953,20 +9974,24 @@ export class AgentSession { snapshots.push(...child.getRlmChildSnapshots()); } } - for (const [childId, child] of this._rlmChildSessions) { + for (const [childId, { session: child, run }] of this._rlmChildSessions) { if (recorded.has(childId) || traversed.has(childId)) continue; const hidden = this._deletingRlmChildren.has(childId) || this._deletedRlmChildIds.has(childId); if (!hidden) { + const snapshot: RlmChildAgentSnapshot = run + ? this._rlmChildSnapshotForRun(run, child) + : { + id: childId, + parentId: this._rlmParentNodeId, + sessionName: child.sessionName, + model: child.model ? `${child.model.provider}/${child.model.id}` : undefined, + label: child.sessionName ?? "child agent", + status: "done", + sessionDir: child._rlmSessionDir ?? child.sessionManager.getSessionDir(), + }; snapshots.push({ - id: childId, - parentId: this._rlmParentNodeId, - sessionName: child.sessionName, - model: child.model ? `${child.model.provider}/${child.model.id}` : undefined, - label: child.sessionName ?? "child agent", - // A failed delete retains the session solely for cleanup retry. Preserve - // its cancellation truth in snapshots rather than reviving it as done. - status: this._rlmChildCleanupFailures.has(childId) ? "cancelled" : "done", - sessionDir: child._rlmSessionDir ?? child.sessionManager.getSessionDir(), + ...snapshot, + status: this._rlmChildCleanupFailures.has(childId) ? "cancelled" : snapshot.status, }); } snapshots.push(...child.getRlmChildSnapshots()); @@ -9985,7 +10010,7 @@ export class AgentSession { } } // A finished direct child can still have a running nested subagent. - for (const session of this._rlmChildSessions.values()) { + for (const { session } of this._rlmChildSessions.values()) { if (session.hasRunningRlmChildren()) { return true; } @@ -9995,7 +10020,7 @@ export class AgentSession { private _rlmChildSessionSnapshot(): AgentSession[] { const sessions = new Set(); - for (const [childId, session] of this._rlmChildSessions) { + for (const [childId, { session }] of this._rlmChildSessions) { if (!this._abandonedRlmQuiescenceChildIds.has(childId)) sessions.add(session); } for (const run of this._activeRlmChildRuns.values()) { @@ -10066,7 +10091,7 @@ export class AgentSession { // Inline (non-daemon) mode only; daemon clients attach to the child session directly. getRlmChildSession(childId: string): AgentSession | undefined { - const direct = this._activeRlmChildRuns.get(childId)?.session ?? this._rlmChildSessions.get(childId); + const direct = this._activeRlmChildRuns.get(childId)?.session ?? this._rlmChildSessions.get(childId)?.session; if (direct) { return direct; } @@ -10076,7 +10101,7 @@ export class AgentSession { return nested; } } - for (const retained of this._rlmChildSessions.values()) { + for (const { session: retained } of this._rlmChildSessions.values()) { const nested = retained.getRlmChildSession(childId); if (nested) { return nested; @@ -10106,7 +10131,7 @@ export class AgentSession { return true; } } - for (const retained of this._rlmChildSessions.values()) { + for (const { session: retained } of this._rlmChildSessions.values()) { if (retained.cancelRlmChildRun(childId, reason)) { return true; } @@ -10123,7 +10148,7 @@ export class AgentSession { [...this._activeRlmChildRuns.values()].some( (run) => run.session?.sessionName === name || (!run.session && run.sessionName === name), ) || - [...this._rlmChildSessions.values()].some((session) => session.sessionName === name) || + [...this._rlmChildSessions.values()].some(({ session }) => session.sessionName === name) || [...this._rlmChildCleanupFailures.values()].some((entry) => entry.session_name === name); if (localConflict) { throw new Error(formatAgentSessionNameUnavailable(name, depth)); @@ -10243,12 +10268,7 @@ export class AgentSession { if (!requestedSessionName) await this._assertRlmSubagentSessionNameAvailable(sessionName); const startedAt = Date.now(); const parentAssistantForUsage = this._findLastAssistantMessage(); - const label = rlmChildLabel(prompt); - let answerPreview: string | undefined; - let durationMs: number | undefined; - let toolUseCount = 0; let runningToolCount = 0; - let activity: RlmChildAgentActivity | undefined; let childSession: AgentSession | undefined; const run: RlmChildRun = { id: childNodeId, @@ -10257,6 +10277,7 @@ export class AgentSession { sessionDir: childSessionDir, model: modelSelection.model, status: "queued", + toolUseCount: 0, settled: false, abort: noopRlmChildAbort, publication: createAgentMessageDeferred(), @@ -10269,27 +10290,7 @@ export class AgentSession { this._activeRlmChildRuns.set(run.id, run); this._unsettledRlmChildRuns.add(run); const emitChildUpdate = () => { - const childModel = childSession?.model ?? modelSelection.model; - this._emit({ - type: "rlm_child_update", - child: { - id: childNodeId, - parentId: this._rlmParentNodeId, - sessionName: childSession?.sessionName ?? sessionName, - model: `${childModel.provider}/${childModel.id}`, - label, - status: run.status, - durationMs, - answerPreview, - toolUseCount: toolUseCount > 0 ? toolUseCount : undefined, - tokenCount: childSession?._contextTokensForCurrentMessages(), - recap: childSession?.getCurrentRecap(), - sessionDir: childSessionDir, - activity, - repliedSinceTask: childSession?._repliedToParentSinceTask, - error: run.error, - }, - }); + this._emit({ type: "rlm_child_update", child: this._rlmChildSnapshotForRun(run) }); }; run.emitUpdate = emitChildUpdate; emitChildUpdate(); @@ -10372,10 +10373,10 @@ export class AgentSession { return; } if (event.type === "agent_start") { - activity = { kind: "waiting" }; + run.activity = { kind: "waiting" }; emitChildUpdate(); } else if (event.type === "agent_end") { - activity = undefined; + run.activity = undefined; emitChildUpdate(); } else if (event.type === "message_end" && event.message.role === "assistant") { const assistant = event.message as AssistantMessage; @@ -10406,24 +10407,24 @@ export class AgentSession { } } const text = compactRlmText(readAssistantText(assistant)); - if (text) answerPreview = text; + if (text) run.answerPreview = text; void flushAgentTraceUpload(child.sessionManager).catch(() => undefined); emitChildUpdate(); } else if (event.type === "message_start" || event.type === "message_update") { if (event.message.role === "assistant") { const text = compactRlmText(readAssistantText(event.message as AssistantMessage)); - if (text) answerPreview = text; - activity = { kind: "writing" }; + if (text) run.answerPreview = text; + run.activity = { kind: "writing" }; emitChildUpdate(); } } else if (event.type === "tool_execution_start") { - toolUseCount += 1; + run.toolUseCount += 1; runningToolCount += 1; - activity = { kind: "executing", toolName: event.toolName }; + run.activity = { kind: "executing", toolName: event.toolName }; emitChildUpdate(); } else if (event.type === "tool_execution_end") { runningToolCount = Math.max(0, runningToolCount - 1); - if (runningToolCount === 0) activity = { kind: "waiting" }; + if (runningToolCount === 0) run.activity = { kind: "waiting" }; emitChildUpdate(); } else if (event.type === "session_info_changed" || event.type === "recap_update") { emitChildUpdate(); @@ -10458,8 +10459,8 @@ export class AgentSession { await child.waitForRlmQuiescence(); if (run.error) throw new Error(run.error); run.status = "done"; - durationMs = Date.now() - startedAt; - activity = undefined; + run.durationMs = Date.now() - startedAt; + run.activity = undefined; emitChildUpdate(); if ( !run.detachedDeletion && @@ -10492,8 +10493,8 @@ export class AgentSession { run.status = "error"; run.error = runError.message; } - durationMs = Date.now() - startedAt; - activity = undefined; + run.durationMs = Date.now() - startedAt; + run.activity = undefined; emitChildUpdate(); if (!run.detachedDeletion && !run.suppressTerminalNotice) { if (run.status === "error") { diff --git a/packages/coding-agent/src/modes/daemon/daemon-session-list.ts b/packages/coding-agent/src/modes/daemon/daemon-session-list.ts index 73dc116ba5..df72465145 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-session-list.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-session-list.ts @@ -2,8 +2,7 @@ import { statSync } from "node:fs"; import { resolve } from "node:path"; import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { Api, Model } from "@earendil-works/pi-ai"; -import { compactRlmText, rlmChildLabel } from "../../core/agent-session.js"; -import type { AgentSessionRuntimeMetadata } from "../../core/agent-session-runtime.js"; +import { compactRlmText } from "../../core/agent-session.js"; import type { AgentSessionRuntimeDiagnostic } from "../../core/agent-session-services.js"; import { type AgentCronJob, isHeartbeatCronJob } from "../../core/cron-jobs.js"; import type { SessionActionSnapshot } from "../../core/session-action-store.js"; @@ -329,87 +328,23 @@ export function summaryForInactiveSession( }; } -/** - * Build snapshots for all RLM child sessions hosted by the daemon under the - * given session, including grandchildren. Mirrors the shape of live - * rlm_child_update events so attach clients can seed their subagent state - * from daemon memory instead of replaying the event stream. - */ +/** Build the root AgentSession projection with daemon-only active session ids. */ export function buildRlmChildSnapshots( rootActiveSessionId: string, activeSessions: readonly ActiveSessionState[], ): AgentConnectionRlmChildAgentSnapshot[] { - const childrenByParent = new Map(); - for (const candidate of activeSessions) { - const metadata = candidate.runtime.metadata; - if (metadata.kind !== "subagent" || !metadata.parentActiveSessionId) { - continue; - } - const siblings = childrenByParent.get(metadata.parentActiveSessionId) ?? []; - siblings.push(candidate); - childrenByParent.set(metadata.parentActiveSessionId, siblings); - } - - const snapshots: AgentConnectionRlmChildAgentSnapshot[] = []; - const visit = (parent: ActiveSessionState | undefined, parentActiveSessionId: string): void => { - const parentNodeId = parent?.runtime.metadata.rlmChildId; - for (const child of childrenByParent.get(parentActiveSessionId) ?? []) { - snapshots.push(rlmChildSnapshotForActiveSession(child, child.runtime.metadata, parentNodeId, parent)); - // A child passes its own node id to its children as their parent id. - visit(child, child.activeSessionId); - } - }; const root = activeSessions.find((candidate) => candidate.activeSessionId === rootActiveSessionId); - visit(root, rootActiveSessionId); - return snapshots; -} - -function rlmChildSnapshotForActiveSession( - activeSession: ActiveSessionState, - metadata: AgentSessionRuntimeMetadata, - parentNodeId: string | undefined, - parent: ActiveSessionState | undefined, -): AgentConnectionRlmChildAgentSnapshot { - const session = activeSession.runtime.session; - let answerPreview: string | undefined; - let toolUseCount = 0; - const messages = - session.state.streamingMessage?.role === "assistant" - ? [...session.messages, session.state.streamingMessage] - : session.messages; - for (const message of messages) { - if (message.role === "assistant") { - const text = compactRlmText(readMessageText(message.content)); - if (text) { - answerPreview = text; - } - toolUseCount += message.content.filter((block) => block.type === "toolCall").length; - } - } - // The parent session's run tracker is the source of truth for child status; - // a daemon-hosted child whose agent is momentarily idle is still part of an - // active run. The streaming heuristic only covers parents the daemon does - // not host (e.g. children attributed to a session created by an older build). - const runStatus = metadata.rlmChildId - ? parent?.runtime.session.getRlmChildRunStatus(metadata.rlmChildId) - : undefined; - const status = runStatus ?? (session.isSessionActive ? "running" : "done"); - const isActive = status === "running" || session.isSessionActive; - return { - id: metadata.rlmChildId ?? activeSession.activeSessionId, - parentId: parentNodeId, - activeSessionId: activeSession.activeSessionId, - sessionName: session.sessionName, - model: session.model ? `${session.model.provider}/${session.model.id}` : undefined, - label: rlmChildLabel(metadata.prompt ?? ""), - status, - answerPreview, - toolUseCount: toolUseCount > 0 ? toolUseCount : undefined, - tokenCount: session._contextTokensForCurrentMessages(), - recap: session.getCurrentRecap(), - sessionDir: metadata.sessionDir ?? session.sessionManager.getSessionDir(), - activity: isActive ? { kind: session.isStreaming ? "writing" : "waiting" } : undefined, - }; + if (!root) return []; + const activeSessionIds = new Map( + activeSessions.flatMap((candidate) => { + const childId = candidate.runtime.metadata.rlmChildId; + return childId ? [[childId, candidate.activeSessionId] as const] : []; + }), + ); + return root.runtime.session.getRlmChildSnapshots().map((snapshot) => ({ + ...snapshot, + activeSessionId: activeSessionIds.get(snapshot.id), + })); } function firstUserMessageText(session: ActiveSessionState["runtime"]["session"]): string | undefined { diff --git a/packages/coding-agent/test/agent-session-recursion.test.ts b/packages/coding-agent/test/agent-session-recursion.test.ts index 4b5b7b9e29..613e40d212 100644 --- a/packages/coding-agent/test/agent-session-recursion.test.ts +++ b/packages/coding-agent/test/agent-session-recursion.test.ts @@ -138,7 +138,7 @@ interface InspectableRlmSession { } >; _rlmChildCleanupFailures: Map>["subagents"][number]>; - _rlmChildSessions: Map; + _rlmChildSessions: Map; _rlmChildUnsubscribes: Map void>; _deletedRlmChildIds: Set; _rlmQuiescenceWaitAborts: Set; @@ -744,9 +744,9 @@ describe("AgentSession rlm recursion", () => { }); // The same child can be visible in both lifecycle registries while // deletion settles; it must be traversed exactly once and remain hidden. - rootInternals._rlmChildSessions.set(id, parent); + rootInternals._rlmChildSessions.set(id, { session: parent }); } else { - rootInternals._rlmChildSessions.set(id, parent); + rootInternals._rlmChildSessions.set(id, { session: parent }); if (hiding === "deleted") { rootInternals._deletedRlmChildIds.add(id); } else { @@ -838,6 +838,14 @@ describe("AgentSession rlm recursion", () => { await waitFor(() => childUpdates.some((update) => update.status === "done")); const doneUpdate = [...childUpdates].reverse().find((update) => update.status === "done"); expect(doneUpdate?.answerPreview).toBe("child answer: summarize shard 1"); + expect(root.getRlmChildSnapshots()).toEqual([ + expect.objectContaining({ + id: result.rlm_child_id, + status: "done", + answerPreview: doneUpdate?.answerPreview, + durationMs: expect.any(Number), + }), + ]); const child = root.getRlmChildSession(result.rlm_child_id); expect(child?.messages[0]).toMatchObject({ role: "custom", @@ -935,6 +943,9 @@ describe("AgentSession rlm recursion", () => { }, }); const spawned = await root.runRlmChild("pending task", { name: "pending-child" }); + expect(root.getRlmChildSnapshots()).toEqual([ + expect.objectContaining({ id: spawned.rlm_child_id, status: "queued" }), + ]); const handlers = (root as unknown as InspectableRlmSession)._createKernelHostHandlers(); const send = handlers["agent_message.send"]; if (!send) throw new Error("Missing agent_message.send host handler"); diff --git a/packages/coding-agent/test/daemon-session-list.test.ts b/packages/coding-agent/test/daemon-session-list.test.ts index a332372bfb..b8286546ba 100644 --- a/packages/coding-agent/test/daemon-session-list.test.ts +++ b/packages/coding-agent/test/daemon-session-list.test.ts @@ -1,6 +1,7 @@ import { resolve } from "node:path"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { describe, expect, it } from "vitest"; +import type { RlmChildAgentSnapshot } from "../src/core/agent-session.js"; import type { AgentCronJob } from "../src/core/cron-jobs.js"; import type { SessionInfo } from "../src/core/session-manager.js"; import type { ActiveSessionState, DaemonSocketClient } from "../src/modes/daemon/active-session-state.js"; @@ -496,150 +497,42 @@ describe("summaryForActiveSession recap currency", () => { }); describe("buildRlmChildSnapshots", () => { - it("collects children and grandchildren with event-compatible parent ids", () => { - const parent = makeState({ activeSessionId: "parent", sessionFile: "/tmp/parent.jsonl" }); - const child = makeState({ - activeSessionId: "child", - model: { provider: "anthropic", id: "claude-opus-4-7" }, - isStreaming: true, - metadata: { - kind: "subagent", - createdAt: 1, - parentActiveSessionId: "parent", - rlmChildId: "sub-aaa", - rlmParentNodeId: "sub-aaa", - prompt: "Summarize the repo\nlayout", - sessionDir: "/tmp/artifacts/sub-aaa", - }, - messages: [ - { role: "user", content: "Summarize the repo layout" }, - { - role: "assistant", - content: [ - { type: "text", text: "The repo is an npm workspace." }, - { type: "toolCall", id: "tool-1", name: "ipython", arguments: {} }, - ], - }, - ] as AgentMessage[], - contextTokens: 41_000, - }); - const grandchild = makeState({ - activeSessionId: "grandchild", - metadata: { - kind: "subagent", - createdAt: 2, - parentActiveSessionId: "child", - rlmChildId: "sub-bbb", - rlmParentNodeId: "sub-bbb", - prompt: "Read the docs", - sessionDir: "/tmp/artifacts/sub-aaa/sub-bbb", - }, - }); - const unrelated = makeState({ - activeSessionId: "unrelated-child", - metadata: { - kind: "subagent", - createdAt: 3, - parentActiveSessionId: "someone-else", - rlmChildId: "sub-ccc", - }, - }); - - const snapshots = buildRlmChildSnapshots("parent", [parent, child, grandchild, unrelated]); - - expect(snapshots.map((snapshot) => [snapshot.id, snapshot.parentId, snapshot.status])).toEqual([ - ["sub-aaa", undefined, "running"], - ["sub-bbb", "sub-aaa", "done"], - ]); - expect(snapshots[0]).toMatchObject({ - model: "anthropic/claude-opus-4-7", - label: "Summarize the repo layout", - answerPreview: "The repo is an npm workspace.", - toolUseCount: 1, - tokenCount: 41_000, - sessionDir: "/tmp/artifacts/sub-aaa", - activeSessionId: "child", - }); - }); - - it("prefers the parent's run status over the streaming heuristic", () => { - // An idle child session is still part of an active run; only the parent's - // run tracker knows that. - const parent = makeState({ - activeSessionId: "parent", - sessionFile: "/tmp/parent.jsonl", - childRunStatuses: { "sub-aaa": "running" }, - }); - const idleChild = makeState({ - activeSessionId: "child", - isStreaming: false, - metadata: { - kind: "subagent", - createdAt: 1, - parentActiveSessionId: "parent", - rlmChildId: "sub-aaa", - rlmParentNodeId: "sub-aaa", - prompt: "Slow task", - sessionDir: "/tmp/artifacts/sub-aaa", - }, - }); - - const snapshots = buildRlmChildSnapshots("parent", [parent, idleChild]); - - expect(snapshots.map((snapshot) => [snapshot.id, snapshot.status])).toEqual([["sub-aaa", "running"]]); - }); - - it("keeps terminal run status while projecting a retained child's active follow-up", () => { + it("uses the AgentSession projection and adds resident active session ids", () => { + const queued = { + id: "sub-queued", + label: "Queued task", + status: "queued" as const, + sessionDir: "/tmp/artifacts/sub-queued", + }; + const executing = { + id: "sub-running", + label: "Running task", + status: "running" as const, + sessionDir: "/tmp/artifacts/sub-running", + activity: { kind: "executing" as const, toolName: "ipython" }, + }; const parent = makeState({ activeSessionId: "parent", - childRunStatuses: { "sub-aaa": "done" }, + childSnapshots: [queued, executing], }); - const activeRetainedChild = makeState({ - activeSessionId: "child", - isStreaming: true, + const residentChild = makeState({ + activeSessionId: "running-child", metadata: { kind: "subagent", createdAt: 1, parentActiveSessionId: "parent", - rlmChildId: "sub-aaa", + rlmChildId: "sub-running", }, }); - expect(buildRlmChildSnapshots("parent", [parent, activeRetainedChild])[0]).toMatchObject({ - status: "done", - activity: { kind: "writing" }, - }); - }); - - it("includes in-flight assistant output in child snapshots", () => { - const parent = makeState({ activeSessionId: "parent" }); - const child = makeState({ - activeSessionId: "child", - isStreaming: true, - metadata: { - kind: "subagent", - createdAt: 1, - parentActiveSessionId: "parent", - rlmChildId: "sub-aaa", - }, - streamingMessage: { - role: "assistant", - content: [ - { type: "text", text: "Still investigating" }, - { type: "toolCall", id: "tool-1", name: "search", arguments: {} }, - ], - } as AgentMessage, - }); - - expect(buildRlmChildSnapshots("parent", [parent, child])[0]).toMatchObject({ - answerPreview: "Still investigating", - toolUseCount: 1, - }); + expect(buildRlmChildSnapshots("parent", [parent, residentChild])).toEqual([ + { ...queued, activeSessionId: undefined }, + { ...executing, activeSessionId: "running-child" }, + ]); }); - it("returns no snapshots for sessions without children", () => { - const solo = makeState({ activeSessionId: "solo" }); - expect(buildRlmChildSnapshots("solo", [solo])).toEqual([]); + it("returns no snapshots when the root is not resident", () => { + expect(buildRlmChildSnapshots("missing", [])).toEqual([]); }); }); @@ -697,6 +590,7 @@ interface StateOptions { unfinishedActionCount?: number; contextTokens?: number; streamingMessage?: AgentMessage; + childSnapshots?: RlmChildAgentSnapshot[]; rlmDepth?: number; metadata?: { kind: "top-level" | "subagent"; @@ -742,6 +636,7 @@ function makeState(options: StateOptions): ActiveSessionState { }, messages: options.messages ?? ([] as AgentMessage[]), getRlmChildRunStatus: (childId: string) => options.childRunStatuses?.[childId], + getRlmChildSnapshots: () => options.childSnapshots ?? [], hasRunningRlmChildren: () => options.hasRunningRlmChildren ?? false, hasAcceptedPromptInFlight: options.hasAcceptedPromptInFlight ?? false, unfinishedActionCount: options.unfinishedActionCount ?? (options.hasAcceptedPromptInFlight ? 1 : 0), From 582eb613ac4fd7ea7f0043e3bcfb51bc89d8f8dd Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 18:58:28 +0200 Subject: [PATCH 22/39] test(coding-agent): complete daemon session stub --- packages/coding-agent/test/daemon-mode.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index d4019dd18b..3b2b8822e7 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -10377,6 +10377,7 @@ function makeRuntimeSession( }, setSubagentRuntimeHost: vi.fn(), getRlmChildRunStatus: vi.fn(() => "running"), + getRlmChildSnapshots: vi.fn(() => []), registerRlmChildSession: vi.fn(() => true), releaseRlmChildSession: vi.fn(() => vi.fn()), subscribe: vi.fn(() => vi.fn()), From 316214310c140c713945d9a6b90b6a24d067ae5d Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 19:04:17 +0200 Subject: [PATCH 23/39] fix(coding-agent): preserve paused compaction continuations --- .../coding-agent/src/core/agent-session.ts | 46 ++++++++++++++----- .../suite/agent-session-compaction.test.ts | 15 ++++-- .../test/suite/agent-session-queue.test.ts | 18 +++++++- 3 files changed, 62 insertions(+), 17 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 8e1738b0eb..522843f0ec 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -830,11 +830,12 @@ function createAgentMessageDeferred(): AgentMessageDeferred { /** One-shot settlement for a scheduled post-compaction continuation; a settled failure is never re-exposed to later waiters. */ interface PostCompactionContinuationSettlement extends AgentMessageDeferred { + continueAfterSessionInput: boolean; settled: boolean; } function createPostCompactionContinuationSettlement(): PostCompactionContinuationSettlement { - return { ...createAgentMessageDeferred(), settled: false }; + return { ...createAgentMessageDeferred(), continueAfterSessionInput: false, settled: false }; } export interface ModelCycleResult { @@ -7310,6 +7311,7 @@ export class AgentSession { throw new Error("Cannot compact without aborting while the agent is running."); } const hadPostCompactionContinue = this._postCompactionContinuationScheduled; + const continueAfterSessionInput = this._postCompactionContinuationSettlement?.continueAfterSessionInput ?? false; this._disconnectFromAgent(); if (!options.skipAbort) await this.abort(); let didCompact = false; @@ -7378,7 +7380,7 @@ export class AgentSession { if (didCompact) { this._discardPendingAutoRefine({ cancelPostCompactionContinue: true }); if (hadPostCompactionContinue) { - this._schedulePostCompactionContinue(); + this._schedulePostCompactionContinue(continueAfterSessionInput); } // Queued agent or session-owned inputs resume the loop; defer refine // behind them instead of interleaving it before their turns. @@ -7602,14 +7604,15 @@ export class AgentSession { this._scheduleAutoRefine("compact"); } - private _schedulePostCompactionContinue(): void { - if (this._postCompactionContinuationScheduled) { - return; - } + private _schedulePostCompactionContinue(continueAfterSessionInput = false): void { if (!this._postCompactionContinuationSettlement || this._postCompactionContinuationSettlement.settled) { this._postCompactionContinuationSettlement = createPostCompactionContinuationSettlement(); } const settlement = this._postCompactionContinuationSettlement; + settlement.continueAfterSessionInput ||= continueAfterSessionInput; + if (this._postCompactionContinuationScheduled) { + return; + } this._postCompactionContinuationScheduled = true; this._scheduledPostCompactionContinuationMessages = [...this._postCompactionContinuationMessages]; void this._runScheduledPostCompactionContinue(settlement) @@ -7625,11 +7628,27 @@ export class AgentSession { return continuationMessages.some((message) => this._postCompactionContinuationMessages.includes(message)); } + private async _waitForQueuedWorkResume(settlement: PostCompactionContinuationSettlement): Promise { + while (this._queuedWorkPauses.size > 0 && this._postCompactionContinuationSettlement === settlement) { + let resume = () => {}; + const resumed = new Promise((resolve) => { + resume = resolve; + this._sessionInputCheckpointWaiters.add(resolve); + }); + try { + await Promise.race([resumed, settlement.promise]); + } finally { + this._sessionInputCheckpointWaiters.delete(resume); + } + } + } + private async _runScheduledPostCompactionContinue(settlement: PostCompactionContinuationSettlement): Promise { while (this._postCompactionContinuationScheduled && this._postCompactionContinuationSettlement === settlement) { await this.agent.waitForIdle(); await this.waitForRetry(); await this._waitForRefineIdle(); + await this._waitForQueuedWorkResume(settlement); const commitFence = await this._acquireSessionActionCommitFence(); let continuation: Promise | undefined; @@ -7644,6 +7663,10 @@ export class AgentSession { return; } + if (this._queuedWorkPauses.size > 0) { + continue; + } + continuationMessages = [...this._scheduledPostCompactionContinuationMessages]; if (continuationMessages.length > 0 && !this._sessionOwnsScheduledContinuations(continuationMessages)) { this._cancelPostCompactionContinue(); @@ -7665,9 +7688,8 @@ export class AgentSession { await this.waitForIdle(); if (this._postCompactionContinuationSettlement !== settlement) return; const shouldContinue = - continuationMessages.length === 0 - ? this.unfinishedActionCount > 0 - : this._sessionOwnsScheduledContinuations(continuationMessages); + (settlement.continueAfterSessionInput && continuationMessages.length === 0) || + this._sessionOwnsScheduledContinuations(continuationMessages); if (shouldContinue) { this._scheduledPostCompactionContinuationMessages = [...this._postCompactionContinuationMessages]; continue; @@ -8508,7 +8530,7 @@ export class AgentSession { (reason === "requested" || reason === "threshold") && (shouldContinueAfterCompaction || this.agent.hasQueuedMessages() || this.hasPendingSessionWork) ) { - this._schedulePostCompactionContinue(); + this._schedulePostCompactionContinue(shouldContinueAfterCompaction); } }; @@ -8560,13 +8582,13 @@ export class AgentSession { this.agent.state.messages = messages.slice(0, -1); } - this._schedulePostCompactionContinue(); + this._schedulePostCompactionContinue(true); this._scheduleAutoRefineAfterCompaction(willContinueAfterCompaction); return true; } else if (shouldContinueAfterCompaction || hasQueuedMessages) { // Compaction can intentionally stop a tool loop between turns. // Queued follow-up/steering/custom messages can also be waiting. - this._schedulePostCompactionContinue(); + this._schedulePostCompactionContinue(shouldContinueAfterCompaction); this._scheduleAutoRefineAfterCompaction(willContinueAfterCompaction); } else { this._scheduleAutoRefineAfterCompaction(willContinueAfterCompaction); diff --git a/packages/coding-agent/test/suite/agent-session-compaction.test.ts b/packages/coding-agent/test/suite/agent-session-compaction.test.ts index ddef4a8265..9a7733ac43 100644 --- a/packages/coding-agent/test/suite/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/suite/agent-session-compaction.test.ts @@ -876,12 +876,19 @@ describe("AgentSession compaction characterization", () => { response: "continuation handled", tracked: true, }, - ])("does not continue again after the session pump handles $name", async ({ text, response, tracked }) => { + { + name: "an empty resume request", + text: "concurrent input", + response: "concurrent input handled", + tracked: false, + continueAfterSessionInput: true, + }, + ])("settles $name after the session pump runs", async ({ text, response, tracked, continueAfterSessionInput }) => { vi.useFakeTimers(); const harness = await createHarness(); harnesses.push(harness); const sessionInternals = harness.session as unknown as { - _schedulePostCompactionContinue(): void; + _schedulePostCompactionContinue(continueAfterSessionInput?: boolean): void; _postCompactionContinuationMessages: AgentMessage[]; _postCompactionContinuationScheduled: boolean; _createPreparedTurnAction( @@ -907,10 +914,10 @@ describe("AgentSession compaction characterization", () => { ); const continueSpy = vi.spyOn(harness.session.agent, "continue"); - sessionInternals._schedulePostCompactionContinue(); + sessionInternals._schedulePostCompactionContinue(continueAfterSessionInput); await vi.advanceTimersByTimeAsync(200); - expect(continueSpy).not.toHaveBeenCalled(); + expect(continueSpy).toHaveBeenCalledTimes(continueAfterSessionInput ? 1 : 0); expect(sessionInternals._postCompactionContinuationScheduled).toBe(false); expect(sessionInternals._postCompactionContinuationMessages).toEqual([]); expect(harness.session.messages.at(-1)).toMatchObject({ diff --git a/packages/coding-agent/test/suite/agent-session-queue.test.ts b/packages/coding-agent/test/suite/agent-session-queue.test.ts index d1c708253f..a51fe75211 100644 --- a/packages/coding-agent/test/suite/agent-session-queue.test.ts +++ b/packages/coding-agent/test/suite/agent-session-queue.test.ts @@ -39,7 +39,7 @@ type AutoRefineInternals = { _scheduleAutoRefine(reason: AutoRefineReason): void; _scheduleAutoRefineAfterCompaction(willContinueAfterCompaction: boolean): void; _scheduleAutoRefineAfterAgentEnd(): void; - _schedulePostCompactionContinue(): void; + _schedulePostCompactionContinue(continueAfterSessionInput?: boolean): void; _invalidatePendingAutoRefineForBranchChange(): Promise; _cancelPostCompactionContinue(): void; _assistantTurnsSinceAutoRefine: number; @@ -353,6 +353,22 @@ describe("AgentSession queue characterization", () => { expect(internals._postCompactionContinuationScheduled).toBe(false); }); + it("waits for a queued-work pause to release before post-compaction continuation", async () => { + const harness = await createAutoRefineHarness(); + harnesses.push(harness); + const internals = harness.session as unknown as AutoRefineInternals; + const pause = harness.session.acquireQueuedWorkPause(); + const continueAgent = vi.spyOn(harness.session.agent, "continue").mockResolvedValue(); + + internals._schedulePostCompactionContinue(); + await new Promise(setImmediate); + expect(continueAgent).not.toHaveBeenCalled(); + + pause.release(); + await harness.session.waitForHeadlessIdle(); + expect(continueAgent).toHaveBeenCalledTimes(1); + }); + it("cancels scheduled post-compaction continuation on branch changes", async () => { vi.useFakeTimers(); const harness = await createAutoRefineHarness({ From a233ee7bba1778f4481526d2cf551faa198db76c Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 20:16:02 +0200 Subject: [PATCH 24/39] fix(coding-agent): preserve retained child event state --- packages/coding-agent/src/core/agent-session.ts | 5 ++++- .../test/agent-session-recursion.test.ts | 14 +++++++++++--- .../coding-agent/test/daemon-session-list.test.ts | 2 -- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index eb68a52360..5b20c798aa 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -9976,7 +9976,10 @@ export class AgentSession { return unsubscribe; } - private _rlmChildSnapshotForRun(run: RlmChildRun, child = run.session): RlmChildAgentSnapshot { + private _rlmChildSnapshotForRun( + run: RlmChildRun, + child = run.session ?? this._rlmChildSessions.get(run.id)?.session, + ): RlmChildAgentSnapshot { const model = child?.model ?? run.model; return { id: run.id, diff --git a/packages/coding-agent/test/agent-session-recursion.test.ts b/packages/coding-agent/test/agent-session-recursion.test.ts index 613e40d212..5117e1b0a6 100644 --- a/packages/coding-agent/test/agent-session-recursion.test.ts +++ b/packages/coding-agent/test/agent-session-recursion.test.ts @@ -19,7 +19,7 @@ import { createAgentSessionMessage, isAgentSessionMessage, } from "../src/core/agent-messages.js"; -import { AgentSession } from "../src/core/agent-session.js"; +import { AgentSession, type RlmChildAgentSnapshot } from "../src/core/agent-session.js"; import { AuthStorage } from "../src/core/auth-storage.js"; import type { LoadExtensionsResult } from "../src/core/extensions/index.js"; import { type HostRequestHandlers, KernelManager } from "../src/core/kernel/index.js"; @@ -2300,14 +2300,22 @@ describe("AgentSession rlm recursion", () => { if (!child) { throw new Error("Missing retained child session"); } + const rootInternals = root as unknown as InspectableRlmSession; + await waitFor(() => !rootInternals._activeRlmChildRuns.has(childId)); + child.setCurrentRecap("retained recap"); child.setSessionName("renamed-worker"); const childUpdates = events.filter( - (event): event is { type: "rlm_child_update"; child: { sessionName?: string } } => + (event): event is { type: "rlm_child_update"; child: RlmChildAgentSnapshot } => typeof event === "object" && event !== null && (event as { type?: string }).type === "rlm_child_update", ); - expect(childUpdates.at(-1)?.child.sessionName).toBe("renamed-worker"); + expect(childUpdates.at(-1)?.child).toMatchObject({ + sessionName: "renamed-worker", + tokenCount: 10, + recap: "retained recap", + repliedSinceTask: false, + }); }); it("surfaces a child's recap on its snapshot once the summarizer sets it", async () => { diff --git a/packages/coding-agent/test/daemon-session-list.test.ts b/packages/coding-agent/test/daemon-session-list.test.ts index b8286546ba..6cb772e27f 100644 --- a/packages/coding-agent/test/daemon-session-list.test.ts +++ b/packages/coding-agent/test/daemon-session-list.test.ts @@ -584,7 +584,6 @@ interface StateOptions { messages?: AgentMessage[]; hasUserContent?: boolean; summaryState?: ActiveSessionState["summaryState"]; - childRunStatuses?: Record; hasRunningRlmChildren?: boolean; hasAcceptedPromptInFlight?: boolean; unfinishedActionCount?: number; @@ -635,7 +634,6 @@ function makeState(options: StateOptions): ActiveSessionState { hasUserContent: () => options.hasUserContent ?? false, }, messages: options.messages ?? ([] as AgentMessage[]), - getRlmChildRunStatus: (childId: string) => options.childRunStatuses?.[childId], getRlmChildSnapshots: () => options.childSnapshots ?? [], hasRunningRlmChildren: () => options.hasRunningRlmChildren ?? false, hasAcceptedPromptInFlight: options.hasAcceptedPromptInFlight ?? false, From 64eda449088100f5dc57048d25c7e25bd82d9292 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 20:25:57 +0200 Subject: [PATCH 25/39] refactor(coding-agent): centralize session path detection --- packages/coding-agent/.changes/session-path-predicate.md | 1 + packages/coding-agent/src/cli/daemon-command.ts | 5 +---- .../coding-agent/src/modes/daemon/daemon-supervisor.ts | 7 ++----- 3 files changed, 4 insertions(+), 9 deletions(-) create mode 100644 packages/coding-agent/.changes/session-path-predicate.md diff --git a/packages/coding-agent/.changes/session-path-predicate.md b/packages/coding-agent/.changes/session-path-predicate.md new file mode 100644 index 0000000000..d6393936b2 --- /dev/null +++ b/packages/coding-agent/.changes/session-path-predicate.md @@ -0,0 +1 @@ +- Made session path detection consistent across direct and daemon commands. diff --git a/packages/coding-agent/src/cli/daemon-command.ts b/packages/coding-agent/src/cli/daemon-command.ts index 2deb4080c6..4fd655ef95 100644 --- a/packages/coding-agent/src/cli/daemon-command.ts +++ b/packages/coding-agent/src/cli/daemon-command.ts @@ -8,6 +8,7 @@ import { expandTildePath } from "../config.js"; import type { AgentSessionEvent } from "../core/agent-session.js"; import type { AgentSessionRuntimeConfig } from "../core/agent-session-config.js"; import { type AgentCronJob, formatAgentCronJob } from "../core/cron-jobs.js"; +import { looksLikeSessionPath } from "../core/session-resolver.js"; import { DaemonClient, type DaemonClientMessageListener } from "../modes/daemon/daemon-client.js"; import type { DaemonOutbound, DaemonResponse } from "../modes/daemon/daemon-protocol.js"; import { matchesSessionIdSuffix } from "../modes/daemon/daemon-session-id.js"; @@ -621,10 +622,6 @@ function parseExtensionFlagOption( return { consumed: 0, daemonArg: arg }; } -function looksLikeSessionPath(value: string): boolean { - return value.includes("/") || value.includes("\\") || value.endsWith(".jsonl"); -} - function requireOptionValue(args: string[], index: number, option: string): string { const value = args[index + 1]; if (!value) { diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 729ec6b3ac..c6b9254e47 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -2,7 +2,7 @@ import { type ChildProcess, spawn } from "node:child_process"; import { createHash, randomBytes, randomUUID } from "node:crypto"; import { chmodSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { createServer, type Server, type Socket } from "node:net"; -import { dirname, isAbsolute, join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { Writable } from "node:stream"; import { getLogger } from "@earendil-works/pi-ai"; import { createCliSubprocessEnv, createCliSubprocessLaunchSpec } from "../../cli/subprocess-launch.js"; @@ -48,6 +48,7 @@ import { } from "../../core/session-action-store.js"; import { canonicalSessionPath, getProcessStartId, SessionAlreadyActiveError } from "../../core/session-lease.js"; import { getSessionArtifactPathForFile, readSessionInfo, type SessionInfo } from "../../core/session-manager.js"; +import { looksLikeSessionPath } from "../../core/session-resolver.js"; import { SettingsManager } from "../../core/settings-manager.js"; import { isProcessAlive, processIdExists, signalProcessGroupOrProcess } from "../../utils/child-process.js"; import type { AgentConnectionHeartbeat } from "../agent-connection/types.js"; @@ -545,10 +546,6 @@ function workerSocketPath(supervisorSocketPath: string, workerId: string): strin return join(defaultDaemonSocketDir(), `worker-${key}-${workerId.slice(0, 12)}.sock`); } -function looksLikeSessionPath(selector: string): boolean { - return isAbsolute(selector) || selector.endsWith(".jsonl") || selector.includes("/") || selector.includes("\\"); -} - function isFinalizedTranscriptEvent(eventType: string | undefined): boolean { return ( eventType === "message_end" || From 480a00332631993c9e9b85b95093fdaa67318a8d Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 20:35:23 +0200 Subject: [PATCH 26/39] fix(coding-agent): preserve restored child snapshots --- .../coding-agent/src/core/agent-session.ts | 46 ++++++++++++++----- .../test/agent-session-recursion.test.ts | 25 ++++++++++ 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 5b20c798aa..a23f4f548f 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1163,6 +1163,7 @@ export class AgentSession { private _rlmSessionDir?: string; private _rlmParentNodeId?: string; private _rlmParentAgent?: string; + private _rlmParentRun?: RlmChildRun; private _repliedToParentSinceTask: boolean | undefined; private _parentReplyCount = 0; private _subagentRuntimeHost?: SubagentRuntimeHost; @@ -9954,7 +9955,9 @@ export class AgentSession { void session.disposeAsync().catch(() => undefined); return false; } - this._rlmChildSessions.set(childId, { session, run: this._activeRlmChildRuns.get(childId) }); + const run = this._activeRlmChildRuns.get(childId) ?? session._rlmParentRun; + if (run) session._rlmParentRun = run; + this._rlmChildSessions.set(childId, { session, run }); if (unsubscribe) { this._rlmChildUnsubscribes.set(childId, unsubscribe); } @@ -10000,6 +10003,35 @@ export class AgentSession { }; } + private _rlmChildSnapshotForSession(childId: string, child: AgentSession): RlmChildAgentSnapshot { + let answerPreview: string | undefined; + let toolUseCount = 0; + const messages = + child.state.streamingMessage?.role === "assistant" + ? [...child.messages, child.state.streamingMessage] + : child.messages; + for (const message of messages) { + if (message.role !== "assistant") continue; + const text = compactRlmText(readAssistantText(message)); + if (text) answerPreview = text; + toolUseCount += message.content.filter((block) => block.type === "toolCall").length; + } + return { + id: childId, + parentId: this._rlmParentNodeId, + sessionName: child.sessionName, + model: child.model ? `${child.model.provider}/${child.model.id}` : undefined, + label: child.sessionName ?? "child agent", + status: "done", + answerPreview, + toolUseCount: toolUseCount > 0 ? toolUseCount : undefined, + tokenCount: child._contextTokensForCurrentMessages(), + recap: child.getCurrentRecap(), + sessionDir: child._rlmSessionDir ?? child.sessionManager.getSessionDir(), + repliedSinceTask: child._repliedToParentSinceTask, + }; + } + /** Live recursive child roster from lifecycle state, including nested work under retained parents. */ getRlmChildSnapshots(): RlmChildAgentSnapshot[] { const snapshots: RlmChildAgentSnapshot[] = []; @@ -10022,17 +10054,9 @@ export class AgentSession { if (recorded.has(childId) || traversed.has(childId)) continue; const hidden = this._deletingRlmChildren.has(childId) || this._deletedRlmChildIds.has(childId); if (!hidden) { - const snapshot: RlmChildAgentSnapshot = run + const snapshot = run ? this._rlmChildSnapshotForRun(run, child) - : { - id: childId, - parentId: this._rlmParentNodeId, - sessionName: child.sessionName, - model: child.model ? `${child.model.provider}/${child.model.id}` : undefined, - label: child.sessionName ?? "child agent", - status: "done", - sessionDir: child._rlmSessionDir ?? child.sessionManager.getSessionDir(), - }; + : this._rlmChildSnapshotForSession(childId, child); snapshots.push({ ...snapshot, status: this._rlmChildCleanupFailures.has(childId) ? "cancelled" : snapshot.status, diff --git a/packages/coding-agent/test/agent-session-recursion.test.ts b/packages/coding-agent/test/agent-session-recursion.test.ts index 5117e1b0a6..0d147e4cee 100644 --- a/packages/coding-agent/test/agent-session-recursion.test.ts +++ b/packages/coding-agent/test/agent-session-recursion.test.ts @@ -616,6 +616,10 @@ describe("AgentSession rlm recursion", () => { mkdirSync(childDir, { recursive: true }); const child = createSession({ rlmSessionDir: childDir }); child.setSessionName("restored-worker"); + const restoredAnswer = assistantMessage("restored answer", usage(7, 3)); + restoredAnswer.content.push({ type: "toolCall", id: "tool-1", name: "ipython", arguments: {} }); + child.agent.state.messages.push(restoredAnswer); + child.setCurrentRecap("restored recap"); const disposeChild = vi.spyOn(child, "disposeAsync"); const root = createSession(); const childStatuses: string[] = []; @@ -626,6 +630,15 @@ describe("AgentSession rlm recursion", () => { }); expect(root.registerRlmChildSession(childId, child)).toBe(true); + expect(root.getRlmChildSnapshots()).toEqual([ + expect.objectContaining({ + id: childId, + answerPreview: "restored answer", + toolUseCount: 1, + tokenCount: 10, + recap: "restored recap", + }), + ]); expect((await root.listRlmSubagents()).subagents).toEqual([ expect.objectContaining({ rlm_child_id: childId, @@ -2302,6 +2315,9 @@ describe("AgentSession rlm recursion", () => { } const rootInternals = root as unknown as InspectableRlmSession; await waitFor(() => !rootInternals._activeRlmChildRuns.has(childId)); + const unsubscribe = root.releaseRlmChildSession(childId, child); + if (!unsubscribe) throw new Error("Failed to release retained child"); + expect(root.registerRlmChildSession(childId, child, unsubscribe)).toBe(true); child.setCurrentRecap("retained recap"); child.setSessionName("renamed-worker"); @@ -2312,10 +2328,19 @@ describe("AgentSession rlm recursion", () => { ); expect(childUpdates.at(-1)?.child).toMatchObject({ sessionName: "renamed-worker", + durationMs: expect.any(Number), tokenCount: 10, recap: "retained recap", repliedSinceTask: false, }); + expect(root.getRlmChildSnapshots()).toEqual([ + expect.objectContaining({ + sessionName: "renamed-worker", + durationMs: expect.any(Number), + tokenCount: 10, + recap: "retained recap", + }), + ]); }); it("surfaces a child's recap on its snapshot once the summarizer sets it", async () => { From d6f1f6d4cc27a1bd008b9fca9fb6bb7e20035b5d Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 20:52:04 +0200 Subject: [PATCH 27/39] fix(coding-agent): unify legacy RLM registry parsing --- .../.changes/legacy-rlm-parser.md | 1 + .../src/modes/daemon/daemon-mode.ts | 61 ++------- .../src/modes/daemon/rlm-ledger.ts | 118 ++++++++++-------- .../coding-agent/test/daemon-mode.test.ts | 7 +- 4 files changed, 76 insertions(+), 111 deletions(-) create mode 100644 packages/coding-agent/.changes/legacy-rlm-parser.md diff --git a/packages/coding-agent/.changes/legacy-rlm-parser.md b/packages/coding-agent/.changes/legacy-rlm-parser.md new file mode 100644 index 0000000000..15cf1b793a --- /dev/null +++ b/packages/coding-agent/.changes/legacy-rlm-parser.md @@ -0,0 +1 @@ +- Fixed passive RLM child metadata recovery from legacy registries without a session directory. diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 0bda7e5ea5..853a5ba9aa 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -9,7 +9,7 @@ import { spawn } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; -import { readFile, stat } from "node:fs/promises"; +import { stat } from "node:fs/promises"; import { createConnection, createServer, type Server, type Socket } from "node:net"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { type Api, getLogger, type Model } from "@earendil-works/pi-ai"; @@ -195,9 +195,11 @@ import { import { MutationDrainLatch } from "./mutation-drain-latch.js"; import { createRlmLedgerRegistrySeedSource, + type LegacyRlmSubagentRegistryEntry, type RlmLedgerDeleteReason, type RlmLedgerEdge, RlmSpawnLedger, + readLegacyRlmSubagentRegistry as readLegacyRlmSubagentRegistryFile, } from "./rlm-ledger.js"; import { readRlmSubagentDisplayEntry, @@ -405,17 +407,6 @@ interface PassiveRlmSubagentEntry { createdAt: number; } -/** - * Legacy per-parent `rlm-subagents.jsonl` entry shape, exactly as the daemon - * wrote it before the spawn ledger became topology authority. Read-only: - * registries are consumed only as the ledger seed source and as fallback - * hydration metadata for pre-ledger children without a display file. - */ -interface LegacyRlmSubagentRegistryEntry extends PassiveRlmSubagentEntry { - type: "rlm_subagent"; - updatedAt: string; -} - /** Spread-ready optional metadata fields shared by display files and legacy registry entries. */ function rlmSubagentMetadataFields(source: { rlmMaxDepth?: number; @@ -966,50 +957,14 @@ export class AgentDaemon { return join(getSessionArtifactPathForFile(parentSessionFile, parentSessionId), RLM_SUBAGENT_REGISTRY_FILE); } - private async readLegacyRlmSubagentRegistry( + private readLegacyRlmSubagentRegistry( path: string, throwOnReadError = false, ): Promise { - let lines: string[]; - try { - lines = (await readFile(path, "utf8")).split(/\r?\n/); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - this.log(`failed to read RLM subagent registry: ${error instanceof Error ? error.message : String(error)}`); - if (throwOnReadError) { - throw error; - } - } - return []; - } - const latest = new Map(); - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } - try { - const entry = JSON.parse(trimmed) as Partial; - if ( - entry.type !== "rlm_subagent" || - typeof entry.childId !== "string" || - typeof entry.sessionName !== "string" || - typeof entry.sessionDir !== "string" || - typeof entry.sessionFile !== "string" || - (entry.status !== "running" && entry.status !== "completed" && entry.status !== "deleted") || - (entry.rlmDepth !== undefined && (!Number.isSafeInteger(entry.rlmDepth) || entry.rlmDepth < 0)) || - (entry.rlmMaxDepth !== undefined && (!Number.isSafeInteger(entry.rlmMaxDepth) || entry.rlmMaxDepth < 0)) - ) { - continue; - } - latest.set(entry.childId, entry as LegacyRlmSubagentRegistryEntry); - } catch (error) { - this.log( - `ignored malformed RLM subagent registry entry: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - return [...latest.values()]; + return readLegacyRlmSubagentRegistryFile(path, { + throwOnReadError, + log: (message) => this.log(message), + }); } /** diff --git a/packages/coding-agent/src/modes/daemon/rlm-ledger.ts b/packages/coding-agent/src/modes/daemon/rlm-ledger.ts index 0ab9ee9e28..2738c27ec6 100644 --- a/packages/coding-agent/src/modes/daemon/rlm-ledger.ts +++ b/packages/coding-agent/src/modes/daemon/rlm-ledger.ts @@ -103,22 +103,70 @@ export interface RlmLedgerSeedRegistryEntry { status: "running" | "completed" | "deleted"; } +export interface LegacyRlmSubagentRegistryEntry extends RlmLedgerSeedRegistryEntry { + type: "rlm_subagent"; + sessionDir: string; + parentSessionId: string; + parentSessionFile?: string; + rlmMaxDepth?: number; + rlmParentNodeId?: string; + prompt?: string; + spawnCode?: string; + model?: { provider: string; modelId: string }; + createdAt: number; + updatedAt: string; +} + export interface RlmLedgerSeedSource { - /** - * Tolerant last-writer-wins registry read for a parent session file, using - * the daemon's existing registry conventions. Must never throw for a - * missing registry; other failures may throw (seeding degrades to empty). - */ readRegistryForSessionFile(sessionFile: string): Promise; } -/** - * Default seed source: derive the per-parent registry path from the session - * file's header id (a bounded first-line read, no full transcript parse) and - * read it with the same tolerant last-writer-wins semantics the daemon's - * passive-hydration reader uses (malformed lines ignored, unknown fields - * accepted, absent depths allowed). - */ +export async function readLegacyRlmSubagentRegistry( + path: string, + options: { throwOnReadError?: boolean; log?: (message: string) => void } = {}, +): Promise { + let contents: string; + try { + contents = await readFile(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + options.log?.( + `failed to read RLM subagent registry: ${error instanceof Error ? error.message : String(error)}`, + ); + if (options.throwOnReadError) throw error; + } + return []; + } + const latest = new Map(); + for (const line of contents.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const entry = JSON.parse(trimmed) as Partial; + if ( + entry.type !== "rlm_subagent" || + typeof entry.childId !== "string" || + typeof entry.sessionName !== "string" || + typeof entry.sessionFile !== "string" || + (entry.status !== "running" && entry.status !== "completed" && entry.status !== "deleted") || + (entry.rlmDepth !== undefined && (!Number.isSafeInteger(entry.rlmDepth) || entry.rlmDepth < 0)) || + (entry.rlmMaxDepth !== undefined && (!Number.isSafeInteger(entry.rlmMaxDepth) || entry.rlmMaxDepth < 0)) + ) { + continue; + } + latest.set(entry.childId, { + ...entry, + sessionDir: typeof entry.sessionDir === "string" ? entry.sessionDir : dirname(entry.sessionFile), + } as LegacyRlmSubagentRegistryEntry); + } catch (error) { + options.log?.( + `ignored malformed RLM subagent registry entry: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + return [...latest.values()]; +} + export function createRlmLedgerRegistrySeedSource(): RlmLedgerSeedSource { return { readRegistryForSessionFile: async (sessionFile) => { @@ -133,49 +181,9 @@ export function createRlmLedgerRegistrySeedSource(): RlmLedgerSeedSource { return []; } if (!headerId) return []; - const registryPath = join(getSessionArtifactPathForFile(sessionFile, headerId), "rlm-subagents.jsonl"); - let contents: string; - try { - contents = await readFile(registryPath, "utf8"); - } catch { - return []; - } - const latest = new Map(); - for (const line of contents.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - const entry = JSON.parse(trimmed) as { - type?: unknown; - childId?: unknown; - sessionName?: unknown; - sessionFile?: unknown; - rlmDepth?: unknown; - status?: unknown; - }; - if ( - entry.type !== "rlm_subagent" || - typeof entry.childId !== "string" || - typeof entry.sessionName !== "string" || - typeof entry.sessionFile !== "string" || - (entry.status !== "running" && entry.status !== "completed" && entry.status !== "deleted") || - (entry.rlmDepth !== undefined && - (!Number.isSafeInteger(entry.rlmDepth) || (entry.rlmDepth as number) < 0)) - ) { - continue; - } - latest.set(entry.childId, { - childId: entry.childId, - sessionName: entry.sessionName, - sessionFile: entry.sessionFile, - ...(typeof entry.rlmDepth === "number" ? { rlmDepth: entry.rlmDepth } : {}), - status: entry.status, - }); - } catch { - // Malformed registry history is ignored, matching the daemon reader. - } - } - return [...latest.values()]; + return readLegacyRlmSubagentRegistry( + join(getSessionArtifactPathForFile(sessionFile, headerId), "rlm-subagents.jsonl"), + ); }, }; } diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index bc5d92e722..6ca4a618c1 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -947,18 +947,19 @@ describe("daemon mode helpers", () => { const registryPath = join(fixture.parentArtifactDir, "rlm-subagents.jsonl"); const entry = JSON.parse(readFileSync(registryPath, "utf8")) as Record; entry.status = "running"; + delete entry.sessionDir; writeFileSync(registryPath, `${JSON.stringify(entry)}\n`); const internals = fixture.daemon as unknown as { createRuntime(command: Extract): Promise; - listPassiveRlmSubagents(): Promise>; + listPassiveRlmSubagents(): Promise>; createAgentMessageController( getCurrentState: () => ActiveSessionState | undefined, ): AgentSessionMessageController; }; const parentState = await internals.createRuntime({ type: "create", sessionPath: fixture.parentSessionFile }); - expect((await internals.listPassiveRlmSubagents()).map(({ entry }) => entry.childId)).toContain( - fixture.childId, + expect((await internals.listPassiveRlmSubagents()).map(({ entry }) => entry)).toContainEqual( + expect.objectContaining({ childId: fixture.childId, status: "running" }), ); await expect(internals.createAgentMessageController(() => parentState).roster?.()).resolves.toMatchObject({ entries: [expect.objectContaining({ relationship: "child", name: "renamed-worker" })], From b48801f627d291f09ac0412c4a4dc00c584fb726 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 21:16:56 +0200 Subject: [PATCH 28/39] fix(coding-agent): remove test-only telemetry behavior --- .../.changes/remove-test-telemetry-branch.md | 1 + packages/coding-agent/src/core/telemetry.ts | 3 --- packages/coding-agent/test/telemetry.test.ts | 16 ++++++---------- packages/coding-agent/vitest.config.ts | 1 + 4 files changed, 8 insertions(+), 13 deletions(-) create mode 100644 packages/coding-agent/.changes/remove-test-telemetry-branch.md diff --git a/packages/coding-agent/.changes/remove-test-telemetry-branch.md b/packages/coding-agent/.changes/remove-test-telemetry-branch.md new file mode 100644 index 0000000000..3504399521 --- /dev/null +++ b/packages/coding-agent/.changes/remove-test-telemetry-branch.md @@ -0,0 +1 @@ +- Stopped treating `NODE_ENV=test` as an implicit telemetry opt-out. diff --git a/packages/coding-agent/src/core/telemetry.ts b/packages/coding-agent/src/core/telemetry.ts index de8eb860fe..bb32bce5fd 100644 --- a/packages/coding-agent/src/core/telemetry.ts +++ b/packages/coding-agent/src/core/telemetry.ts @@ -212,9 +212,6 @@ export function isTelemetryEnabled(settingsManager: SettingsManager): boolean { if (override !== undefined) { return override; } - if (process.env.NODE_ENV === "test") { - return false; - } return settingsManager.getTelemetryEnabled(); } diff --git a/packages/coding-agent/test/telemetry.test.ts b/packages/coding-agent/test/telemetry.test.ts index 12b4e17c50..49c2ba4263 100644 --- a/packages/coding-agent/test/telemetry.test.ts +++ b/packages/coding-agent/test/telemetry.test.ts @@ -2,7 +2,7 @@ import { lstatSync, mkdtempSync, readFileSync, statSync, symlinkSync, writeFileS import { tmpdir } from "node:os"; import { join } from "node:path"; import type { AssistantMessage } from "@earendil-works/pi-ai"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AgentSession, AgentSessionEvent } from "../src/core/agent-session.js"; import { SettingsManager } from "../src/core/settings-manager.js"; import { @@ -236,7 +236,7 @@ describe("telemetry controls", () => { it("honors settings and environment opt-outs", () => { const settings = SettingsManager.inMemory({ telemetry: { enabled: true } }); - vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("DO_NOT_TRACK", "0"); expect(isTelemetryEnabled(settings)).toBe(true); vi.stubEnv("DO_NOT_TRACK", "1"); @@ -251,14 +251,6 @@ describe("telemetry controls", () => { expect(isTelemetryEnabled(settings)).toBe(false); }); - it("is disabled by default in tests unless explicitly enabled", () => { - const settings = SettingsManager.inMemory({ telemetry: { enabled: true } }); - vi.stubEnv("NODE_ENV", "test"); - expect(isTelemetryEnabled(settings)).toBe(false); - vi.stubEnv("PRIME_AGENT_TELEMETRY", "1"); - expect(isTelemetryEnabled(settings)).toBe(true); - }); - it("normalizes malformed telemetry settings before updating them", async () => { const settings = SettingsManager.inMemory({ telemetry: true as never }); const disabledSettings = SettingsManager.inMemory({ telemetry: false as never }); @@ -276,6 +268,10 @@ describe("telemetry controls", () => { }); describe("agent telemetry aggregation", () => { + beforeEach(() => { + vi.stubEnv("DO_NOT_TRACK", "0"); + }); + it("captures only allowlisted built-in command names", async () => { vi.stubEnv("PRIME_AGENT_TELEMETRY", "1"); const sink = new FakeTelemetrySink(); diff --git a/packages/coding-agent/vitest.config.ts b/packages/coding-agent/vitest.config.ts index 73a0dae79b..35c1def04b 100644 --- a/packages/coding-agent/vitest.config.ts +++ b/packages/coding-agent/vitest.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ globals: true, environment: "node", testTimeout: 30000, + env: { DO_NOT_TRACK: "1" }, tags: [ { name: "process-stress", From c36534342d98a0001e1dba63a6d315b376ca7f32 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 21:38:44 +0200 Subject: [PATCH 29/39] test(coding-agent): enable telemetry explicitly --- packages/coding-agent/test/agent-session-services.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/coding-agent/test/agent-session-services.test.ts b/packages/coding-agent/test/agent-session-services.test.ts index c6e38c878f..211be15583 100644 --- a/packages/coding-agent/test/agent-session-services.test.ts +++ b/packages/coding-agent/test/agent-session-services.test.ts @@ -29,6 +29,7 @@ describe("createAgentSessionFromServices", () => { }); it("shows the telemetry disclosure independently of the Herdr reporter", async () => { + vi.stubEnv("DO_NOT_TRACK", "0"); vi.stubEnv("PRIME_AGENT_TELEMETRY", "1"); const tempDir = join(tmpdir(), `pi-session-telemetry-notice-${Date.now()}`); mkdirSync(tempDir, { recursive: true }); @@ -50,6 +51,7 @@ describe("createAgentSessionFromServices", () => { }); it("honors an explicit daemon-carried telemetry opt-out", async () => { + vi.stubEnv("DO_NOT_TRACK", "0"); vi.stubEnv("PRIME_AGENT_TELEMETRY", "1"); const tempDir = join(tmpdir(), `pi-session-daemon-telemetry-opt-out-${Date.now()}`); mkdirSync(tempDir, { recursive: true }); @@ -81,6 +83,7 @@ describe("createAgentSessionFromServices", () => { }); it("does not install top-level telemetry for a resumed child session", async () => { + vi.stubEnv("DO_NOT_TRACK", "0"); vi.stubEnv("PRIME_AGENT_TELEMETRY", "1"); const tempDir = join(tmpdir(), `pi-session-child-telemetry-${Date.now()}`); mkdirSync(tempDir, { recursive: true }); From 6cc7263170afc08afdf9559fd4c66c8718fba66b Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 22:05:17 +0200 Subject: [PATCH 30/39] refactor(coding-agent): remove config cache test resets --- .../.changes/remove-config-cache-reset.md | 1 + .../coding-agent/src/core/model-registry.ts | 4 ---- .../src/core/resolve-config-value.ts | 5 ----- .../coding-agent/test/auth-storage.test.ts | 22 ------------------- .../coding-agent/test/model-registry.test.ts | 3 +-- 5 files changed, 2 insertions(+), 33 deletions(-) create mode 100644 packages/coding-agent/.changes/remove-config-cache-reset.md diff --git a/packages/coding-agent/.changes/remove-config-cache-reset.md b/packages/coding-agent/.changes/remove-config-cache-reset.md new file mode 100644 index 0000000000..8150a69571 --- /dev/null +++ b/packages/coding-agent/.changes/remove-config-cache-reset.md @@ -0,0 +1 @@ +- Removed internal test-only configuration cache reset hooks. diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index 0bd646d229..4ebf6c3b8d 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -37,7 +37,6 @@ import { } from "./prime-inference-models.js"; import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "./provider-display-names.js"; import { - clearConfigValueCache, resolveConfigValueOrThrow, resolveConfigValueUncached, resolveHeadersOrThrow, @@ -349,9 +348,6 @@ function applyModelOverride(model: Model, override: ModelOverride): Model 0 ? resolved : undefined; } - -/** Clear the config value command cache. Exported for testing. */ -export function clearConfigValueCache(): void { - commandResultCache.clear(); -} diff --git a/packages/coding-agent/test/auth-storage.test.ts b/packages/coding-agent/test/auth-storage.test.ts index 27b7ff7a0f..049d254206 100644 --- a/packages/coding-agent/test/auth-storage.test.ts +++ b/packages/coding-agent/test/auth-storage.test.ts @@ -5,7 +5,6 @@ import { registerOAuthProvider } from "@earendil-works/pi-ai/oauth"; import lockfile from "proper-lockfile"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { AuthStorage } from "../src/core/auth-storage.js"; -import { clearConfigValueCache } from "../src/core/resolve-config-value.js"; describe("AuthStorage", () => { let tempDir: string; @@ -22,7 +21,6 @@ describe("AuthStorage", () => { if (tempDir && existsSync(tempDir)) { rmSync(tempDir, { recursive: true }); } - clearConfigValueCache(); vi.restoreAllMocks(); }); @@ -835,26 +833,6 @@ describe("AuthStorage", () => { expect(count).toBe(1); }); - test("clearConfigValueCache allows command to run again", async () => { - const counterFile = join(tempDir, "counter"); - writeFileSync(counterFile, "0"); - - const counterPath = toShPath(counterFile); - const command = `!sh -c 'count=$(cat "${counterPath}"); echo $((count + 1)) > "${counterPath}"; echo "key-value"'`; - writeAuthJson({ - anthropic: { type: "api_key", key: command }, - }); - - authStorage = AuthStorage.create(authJsonPath); - await authStorage.getApiKey("anthropic"); - - clearConfigValueCache(); - await authStorage.getApiKey("anthropic"); - - const count = parseInt(readFileSync(counterFile, "utf-8").trim(), 10); - expect(count).toBe(2); - }); - test("different commands are cached separately", async () => { writeAuthJson({ anthropic: { type: "api_key", key: "!echo key-anthropic" }, diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index 4add0e701b..0afca0a61f 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -6,7 +6,7 @@ import { getApiProvider } from "@earendil-works/pi-ai"; import { getOAuthProvider, registerOAuthProvider } from "@earendil-works/pi-ai/oauth"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { AuthStorage } from "../src/core/auth-storage.js"; -import { clearApiKeyCache, ModelRegistry, type ProviderConfigInput } from "../src/core/model-registry.js"; +import { ModelRegistry, type ProviderConfigInput } from "../src/core/model-registry.js"; describe("ModelRegistry", () => { let tempDir: string; @@ -24,7 +24,6 @@ describe("ModelRegistry", () => { if (tempDir && existsSync(tempDir)) { rmSync(tempDir, { recursive: true }); } - clearApiKeyCache(); }); function providerConfig( From 04989f66b8ba76a301188fdc78b27f7a44143604 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 22:46:54 +0200 Subject: [PATCH 31/39] refactor(ai): remove unused overflow pattern export (cherry picked from commit 63686a630dba1ec582690eb581f94dbaa519ce46) --- packages/ai/src/utils/overflow.ts | 7 ------- .../.changes/remove-overflow-patterns-export.md | 1 + 2 files changed, 1 insertion(+), 7 deletions(-) create mode 100644 packages/coding-agent/.changes/remove-overflow-patterns-export.md diff --git a/packages/ai/src/utils/overflow.ts b/packages/ai/src/utils/overflow.ts index 648f2343ed..bf59405d82 100644 --- a/packages/ai/src/utils/overflow.ts +++ b/packages/ai/src/utils/overflow.ts @@ -142,10 +142,3 @@ export function isContextOverflow(message: AssistantMessage, contextWindow?: num return false; } - -/** - * Get the overflow patterns for testing purposes. - */ -export function getOverflowPatterns(): RegExp[] { - return [...OVERFLOW_PATTERNS]; -} diff --git a/packages/coding-agent/.changes/remove-overflow-patterns-export.md b/packages/coding-agent/.changes/remove-overflow-patterns-export.md new file mode 100644 index 0000000000..95a2b5226e --- /dev/null +++ b/packages/coding-agent/.changes/remove-overflow-patterns-export.md @@ -0,0 +1 @@ +- Removed the unused `getOverflowPatterns` public API. From 48ec8a312da20509297652e6ab9857ff2f6737d3 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 23:45:33 +0200 Subject: [PATCH 32/39] refactor(coding-agent): remove daemon lookup test override --- .../.changes/remove-daemon-lookup-fake.md | 1 + packages/coding-agent/src/main.ts | 5 +-- .../test/main-interactive-routing.test.ts | 43 ------------------- 3 files changed, 2 insertions(+), 47 deletions(-) create mode 100644 packages/coding-agent/.changes/remove-daemon-lookup-fake.md diff --git a/packages/coding-agent/.changes/remove-daemon-lookup-fake.md b/packages/coding-agent/.changes/remove-daemon-lookup-fake.md new file mode 100644 index 0000000000..789c472947 --- /dev/null +++ b/packages/coding-agent/.changes/remove-daemon-lookup-fake.md @@ -0,0 +1 @@ +- Removed the test-only daemon active-session lookup override. diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 385fde6831..28ccdafb50 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -302,11 +302,8 @@ export function shouldEnsureDaemonBeforeActiveSessionLookup(options: DaemonActiv ); } -type ActiveDaemonSessionSummaryLookup = (socketPath: string, selector: string) => Promise; - interface ActiveDaemonSessionSummaryLookupOptions { fallbackOnError?: boolean; - lookup?: ActiveDaemonSessionSummaryLookup; } export async function findActiveDaemonSessionSummaryForInteractiveStartup( @@ -315,7 +312,7 @@ export async function findActiveDaemonSessionSummaryForInteractiveStartup( options: ActiveDaemonSessionSummaryLookupOptions = {}, ): Promise { try { - return await (options.lookup ?? findActiveDaemonSessionSummary)(socketPath, selector); + return await findActiveDaemonSessionSummary(socketPath, selector); } catch (error) { if (options.fallbackOnError === false) { throw error; diff --git a/packages/coding-agent/test/main-interactive-routing.test.ts b/packages/coding-agent/test/main-interactive-routing.test.ts index 727662662f..6c1e4bf3e4 100644 --- a/packages/coding-agent/test/main-interactive-routing.test.ts +++ b/packages/coding-agent/test/main-interactive-routing.test.ts @@ -8,7 +8,6 @@ import { type AppMode, type DaemonInteractiveSessionManagerDecision, daemonServerDefaultSessionConfig, - findActiveDaemonSessionSummaryForInteractiveStartup, findActiveDaemonSessionSummaryForSessionFile, type InteractiveDaemonStartupDecision, isClientOwnedDaemonSession, @@ -232,48 +231,6 @@ describe("daemon-backed interactive session manager routing", () => { ).toBe(false); }); - test("falls back to local session lookup when daemon active-session probing fails", async () => { - await expect( - findActiveDaemonSessionSummaryForInteractiveStartup("/tmp/prime.sock", "saved-session-id", { - lookup: async () => { - throw new Error("Daemon returned an invalid active session summary"); - }, - }), - ).resolves.toBeUndefined(); - }); - - test("propagates active-session lookup failures for explicit attach", async () => { - await expect( - findActiveDaemonSessionSummaryForInteractiveStartup("/tmp/prime.sock", "active-1", { - fallbackOnError: false, - lookup: async () => { - throw new Error("protocol mismatch"); - }, - }), - ).rejects.toThrow("protocol mismatch"); - }); - - test("uses daemon active-session summary when probing succeeds", async () => { - await expect( - findActiveDaemonSessionSummaryForInteractiveStartup("/tmp/prime.sock", "active-1", { - lookup: async () => ({ - id: "active-1", - activeSessionId: "active-1", - lifecycle: "draft", - activity: "idle", - isSessionActive: false, - sessionId: "session-1", - cwd: "/tmp/project", - isStreaming: false, - isCompacting: false, - attachedClients: 0, - messageCount: 0, - sessionActions: { queuedCount: 0, steering: [], followUps: [] }, - }), - }), - ).resolves.toMatchObject({ activeSessionId: "active-1" }); - }); - test("uses an ephemeral local session manager for fresh daemon-owned sessions", () => { expect(shouldUseEphemeralSessionManagerForDaemonInteractive({})).toBe(true); }); From 1af76802ff341255803e8c17011512c536850a5c Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 23:48:58 +0200 Subject: [PATCH 33/39] Drop the now-unimported export keyword --- packages/coding-agent/src/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 28ccdafb50..8740776ec5 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -306,7 +306,7 @@ interface ActiveDaemonSessionSummaryLookupOptions { fallbackOnError?: boolean; } -export async function findActiveDaemonSessionSummaryForInteractiveStartup( +async function findActiveDaemonSessionSummaryForInteractiveStartup( socketPath: string, selector: string, options: ActiveDaemonSessionSummaryLookupOptions = {}, From 938b0a79898fab19d95a1c3b1cc91b4bbf05ccfd Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 26 Aug 2026 11:12:03 +0200 Subject: [PATCH 34/39] fix(coding-agent): retain queued edit after lost move --- .../src/modes/interactive/interactive-mode.ts | 41 ++++++++++--------- .../test/interactive-queue-edit.test.ts | 17 ++++++++ 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 5f912e3446..0cb50c3ea4 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -7079,27 +7079,30 @@ export class InteractiveMode { return this.enqueueQueueMutation(async () => { if (discardStaleSelection()) return true; const selected = this.queueSelection.selected; - if (!selected) return true; 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 = diff --git a/packages/coding-agent/test/interactive-queue-edit.test.ts b/packages/coding-agent/test/interactive-queue-edit.test.ts index 761bb043e8..ca79d849a4 100644 --- a/packages/coding-agent/test/interactive-queue-edit.test.ts +++ b/packages/coding-agent/test/interactive-queue-edit.test.ts @@ -400,6 +400,23 @@ describe("interactive queued-message editing", () => { expect(harness.editor.getText()).toBe("draft"); }); + 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 () => { + emitQueueUpdate(harness, { steering: ["s1"], followUp: [] }); + return "applied"; + }); + harness.editor.setText("draft"); + harness.browseQueueSelection(-1); + harness.moveQueueSelection(-1); + harness.editor.setText(""); + await harness.applyQueueSelection("s2 edited", "steering"); + + expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenCalledOnce(); + expect(harness.editor.getText()).toBe("s2 edited"); + expect(harness.showStatus).toHaveBeenCalledWith("Queue changed; edit kept in the editor"); + }); + it("uses canonical post-move positions for consecutive moves and an edit", async () => { const queue = ["s1", "s2", "s3"]; const harness = createHarness({ steering: queue, followUp: [] }); From e7b00bc0ae3080eed5b2403c2260b331d90b8804 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 27 Aug 2026 10:03:33 +0200 Subject: [PATCH 35/39] fix(coding-agent): bound REPL snapshot disposal --- .../kernel-snapshot-dispose-timeout.md | 2 +- .../src/core/kernel/repl-manager.ts | 23 ++++---- .../coding-agent/src/core/kernel/shared.ts | 3 - .../test/repl-kernel-abort.test.ts | 58 +++++++++++++++---- 4 files changed, 61 insertions(+), 25 deletions(-) diff --git a/packages/coding-agent/.changes/kernel-snapshot-dispose-timeout.md b/packages/coding-agent/.changes/kernel-snapshot-dispose-timeout.md index 995a26377c..e063919711 100644 --- a/packages/coding-agent/.changes/kernel-snapshot-dispose-timeout.md +++ b/packages/coding-agent/.changes/kernel-snapshot-dispose-timeout.md @@ -1 +1 @@ -- Fixed graceful IPython kernel disposal so timed-out final snapshots are cancelled before socket teardown. +- Fixed graceful Python kernel disposal so timed-out final snapshots are cancelled before teardown. diff --git a/packages/coding-agent/src/core/kernel/repl-manager.ts b/packages/coding-agent/src/core/kernel/repl-manager.ts index 28c39c3f0d..631d8226a3 100644 --- a/packages/coding-agent/src/core/kernel/repl-manager.ts +++ b/packages/coding-agent/src/core/kernel/repl-manager.ts @@ -37,7 +37,6 @@ import { parseDiffDisplay, parseSentAgentMessage, raceStartupWithAbort, - SNAPSHOT_DISPOSE_TIMEOUT_MS, SNAPSHOT_EXECUTION_TIMEOUT_MS, } from "./shared.js"; import { @@ -1100,19 +1099,21 @@ export class ReplKernelManager { } } - /** Best-effort final snapshot before a graceful dispose, bounded by a timeout. */ private async flushSnapshotForDispose(): Promise { if (!this.options.snapshot || !this.isRunning) return; + const pendingExecutions = this.executionQueue; + if (this.activeExecution) void this.interrupt().catch(() => undefined); let timeout: ReturnType | undefined; - const guard = new Promise((resolve) => { - timeout = globalThis.setTimeout(resolve, SNAPSHOT_DISPOSE_TIMEOUT_MS); - if (timeout && typeof timeout === "object" && "unref" in timeout) timeout.unref(); - }); - try { - await Promise.race([this.snapshotState().then(() => undefined), guard]); - } finally { - if (timeout) clearTimeout(timeout); - } + const queueSettled = await Promise.race([ + pendingExecutions.then(() => true), + new Promise((resolve) => { + timeout = globalThis.setTimeout(() => resolve(false), SNAPSHOT_EXECUTION_TIMEOUT_MS); + timeout.unref?.(); + }), + ]); + if (timeout) globalThis.clearTimeout(timeout); + if (!queueSettled) return; + await this.captureSnapshot({ executionTimeoutMs: SNAPSHOT_EXECUTION_TIMEOUT_MS }); } /** Graceful cleanup. Waits briefly for in-flight host request handlers before killing the child. */ diff --git a/packages/coding-agent/src/core/kernel/shared.ts b/packages/coding-agent/src/core/kernel/shared.ts index bdaec97126..e066d680c7 100644 --- a/packages/coding-agent/src/core/kernel/shared.ts +++ b/packages/coding-agent/src/core/kernel/shared.ts @@ -6,9 +6,6 @@ export const DEFAULT_MAX_OUTPUT_CHARS = 65536; export const HOST_REQUEST_DISPOSE_TIMEOUT_MS = 5000; export const KERNEL_SHUTDOWN_TIMEOUT_MS = 5000; export const DEFAULT_SNAPSHOT_DEBOUNCE_MS = 1500; -// Cap how long a graceful dispose waits on the final snapshot; the debounced -// on-disk copy is the fallback if this is exceeded. -export const SNAPSHOT_DISPOSE_TIMEOUT_MS = 5000; export const SNAPSHOT_EXECUTION_TIMEOUT_MS = 5000; export const KERNEL_ABORT_GRACE_MS = 1000; export const KERNEL_BUSY_REUSE_WAIT_MS = 5000; diff --git a/packages/coding-agent/test/repl-kernel-abort.test.ts b/packages/coding-agent/test/repl-kernel-abort.test.ts index 6187e4fa95..4881ef8942 100644 --- a/packages/coding-agent/test/repl-kernel-abort.test.ts +++ b/packages/coding-agent/test/repl-kernel-abort.test.ts @@ -216,7 +216,7 @@ describe("ReplKernelManager abort handling", () => { manager.disposeSync(); }); - it("starts the snapshot timeout after earlier kernel work finishes", async () => { + it("cancels a hung final snapshot execution before teardown", async () => { vi.useFakeTimers(); const manager = new ReplKernelManager({ cwd: process.cwd(), @@ -240,33 +240,71 @@ describe("ReplKernelManager abort handling", () => { ); }), ); + const cleanupResources = vi.fn(); Object.assign( manager as unknown as { state: "running"; executionQueue: Promise; executeInner: typeof executeInner; start: () => Promise; + cleanupResources: () => void; }, - { state: "running", executionQueue: previousExecution, executeInner, start: async () => {} }, + { state: "running", executionQueue: previousExecution, executeInner, start: async () => {}, cleanupResources }, ); - const snapshot = ( - manager as unknown as { - captureSnapshot: (options?: { executionTimeoutMs?: number }) => Promise; - } - ).captureSnapshot({ executionTimeoutMs: 5000 }); - await vi.advanceTimersByTimeAsync(5000); + const disposal = manager.dispose(); expect(executeInner).not.toHaveBeenCalled(); - releaseQueue(); await waitForCalls(executeInner, 1); const signal = executeInner.mock.calls[0]?.[2].signal; expect(signal?.aborted).toBe(false); await vi.advanceTimersByTimeAsync(4999); expect(signal?.aborted).toBe(false); + expect(cleanupResources).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); + expect(signal?.aborted).toBe(true); - await expect(snapshot).resolves.toBeNull(); + await expect(disposal).resolves.toBeUndefined(); + expect(cleanupResources).toHaveBeenCalledOnce(); + }); + + it("tears down when the final snapshot is blocked behind a hung execution", async () => { + vi.useFakeTimers(); + const manager = new ReplKernelManager({ + cwd: process.cwd(), + snapshot: { path: "/tmp/test-state.dill", manifestPath: "/tmp/test-state.json" }, + }); + const executeInner = vi.fn(); + const interrupt = vi.fn(async () => {}); + const cleanupResources = vi.fn(); + Object.assign( + manager as unknown as { + state: "running"; + executionQueue: Promise; + activeExecution: object; + executeInner: typeof executeInner; + interrupt: typeof interrupt; + cleanupResources: () => void; + }, + { + state: "running", + executionQueue: new Promise(() => {}), + activeExecution: {}, + executeInner, + interrupt, + cleanupResources, + }, + ); + + const disposal = manager.dispose(); + expect(interrupt).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(4999); + expect(cleanupResources).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + + await expect(disposal).resolves.toBeUndefined(); + expect(executeInner).not.toHaveBeenCalled(); + expect(cleanupResources).toHaveBeenCalledOnce(); }); it("routes null-id and stale-id stream events into backgroundOutput, not stdout", async () => { From 5ad43a144f1cd5ebe1439c249fb448d9e7318f31 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 27 Aug 2026 11:01:15 +0200 Subject: [PATCH 36/39] fix(coding-agent): await active compaction continuation barrier --- .../coding-agent/src/core/agent-session.ts | 9 +++- .../suite/agent-session-compaction.test.ts | 45 +++++++++++++++++++ .../test/suite/agent-session-queue.test.ts | 25 +++++++++++ 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index ccab458af5..ef5c4341e5 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -7649,6 +7649,11 @@ export class AgentSession { await this.waitForRetry(); await this._waitForRefineIdle(); await this._waitForQueuedWorkResume(settlement); + const compactionOperation = this._compactionOperation; + if (compactionOperation) { + await Promise.race([compactionOperation, settlement.promise]); + continue; + } const commitFence = await this._acquireSessionActionCommitFence(); let continuation: Promise | undefined; @@ -7663,7 +7668,7 @@ export class AgentSession { return; } - if (this._queuedWorkPauses.size > 0) { + if (this._queuedWorkPauses.size > 0 || this._compactionOperation) { continue; } @@ -7713,7 +7718,7 @@ export class AgentSession { } continue; } - if (code !== "nothing-to-continue") { + if (code !== "nothing-to-continue" && this._postCompactionContinuationSettlement === settlement) { this._settlePostCompactionContinue(this._asError(error)); } return; diff --git a/packages/coding-agent/test/suite/agent-session-compaction.test.ts b/packages/coding-agent/test/suite/agent-session-compaction.test.ts index 9a7733ac43..ac5f775e5f 100644 --- a/packages/coding-agent/test/suite/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/suite/agent-session-compaction.test.ts @@ -268,6 +268,51 @@ describe("AgentSession compaction characterization", () => { } }); + it("waits for active manual compaction before continuing", async () => { + const compactionStarted = createDeferred(); + const compactionRelease = createDeferred(); + const harness = await createHarness({ + settings: { compaction: { keepRecentTokens: 1 } }, + extensionFactories: [ + (pi) => { + pi.on("session_before_compact", async (event) => { + compactionStarted.resolve(); + await compactionRelease.promise; + return { + compaction: { + summary: "summary from extension", + firstKeptEntryId: event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + details: { source: "extension" }, + }, + }; + }); + }, + ], + }); + harnesses.push(harness); + const internals = harness.session as unknown as { + _schedulePostCompactionContinue(): void; + }; + harness.setResponses([fauxAssistantMessage("one"), fauxAssistantMessage("two")]); + await harness.session.prompt("first"); + await harness.session.prompt("second"); + const pause = harness.session.acquireQueuedWorkPause(); + const continueAgent = vi.spyOn(harness.session.agent, "continue").mockResolvedValue(); + internals._schedulePostCompactionContinue(); + + const compaction = harness.session.compact(undefined, { skipAbort: true }); + await compactionStarted.promise; + pause.release(); + await new Promise(setImmediate); + expect(continueAgent).not.toHaveBeenCalled(); + + compactionRelease.resolve(); + await compaction; + await harness.session.waitForHeadlessIdle(); + expect(continueAgent).toHaveBeenCalledTimes(1); + }); + it("treats session-owned queued inputs as queued work after compaction", async () => { const harness = await createHarness({ settings: { compaction: { keepRecentTokens: 1 } }, diff --git a/packages/coding-agent/test/suite/agent-session-queue.test.ts b/packages/coding-agent/test/suite/agent-session-queue.test.ts index a51fe75211..14e31404a1 100644 --- a/packages/coding-agent/test/suite/agent-session-queue.test.ts +++ b/packages/coding-agent/test/suite/agent-session-queue.test.ts @@ -353,6 +353,31 @@ describe("AgentSession queue characterization", () => { expect(internals._postCompactionContinuationScheduled).toBe(false); }); + it("does not let a failed cancelled continuation reject its replacement", async () => { + const harness = await createAutoRefineHarness(); + harnesses.push(harness); + const internals = harness.session as unknown as AutoRefineInternals; + const cancelledRun = createDeferred(); + const replacementRun = createDeferred(); + const continueAgent = vi + .spyOn(harness.session.agent, "continue") + .mockReturnValueOnce(cancelledRun.promise) + .mockReturnValueOnce(replacementRun.promise); + + internals._schedulePostCompactionContinue(); + await vi.waitFor(() => expect(continueAgent).toHaveBeenCalledTimes(1)); + internals._cancelPostCompactionContinue(); + internals._schedulePostCompactionContinue(); + await vi.waitFor(() => expect(continueAgent).toHaveBeenCalledTimes(2)); + const idle = harness.session.waitForHeadlessIdle(); + + cancelledRun.reject(new Error("cancelled continuation failed")); + await new Promise(setImmediate); + replacementRun.resolve(); + + await expect(idle).resolves.toBeUndefined(); + }); + it("waits for a queued-work pause to release before post-compaction continuation", async () => { const harness = await createAutoRefineHarness(); harnesses.push(harness); From 019a7dfa04d62f38f3bd017766a90fe50e7a72ef Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 27 Aug 2026 11:02:38 +0200 Subject: [PATCH 37/39] fix(coding-agent): retain child snapshots through passivation --- .../coding-agent/src/core/agent-session.ts | 21 +++++++++-------- .../src/modes/daemon/daemon-mode.ts | 5 +--- .../test/agent-session-recursion.test.ts | 5 ++-- .../coding-agent/test/daemon-mode.test.ts | 23 +++++-------------- 4 files changed, 20 insertions(+), 34 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index c7ae324552..5ac254e367 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1163,7 +1163,6 @@ export class AgentSession { private _rlmSessionDir?: string; private _rlmParentNodeId?: string; private _rlmParentAgent?: string; - private _rlmParentRun?: RlmChildRun; private _repliedToParentSinceTask: boolean | undefined; private _parentReplyCount = 0; private _subagentRuntimeHost?: SubagentRuntimeHost; @@ -9957,9 +9956,7 @@ export class AgentSession { void session.disposeAsync().catch(() => undefined); return false; } - const run = this._activeRlmChildRuns.get(childId) ?? session._rlmParentRun; - if (run) session._rlmParentRun = run; - this._rlmChildSessions.set(childId, { session, run }); + this._rlmChildSessions.set(childId, { session, run: this._activeRlmChildRuns.get(childId) }); if (unsubscribe) { this._rlmChildUnsubscribes.set(childId, unsubscribe); } @@ -9970,15 +9967,19 @@ export class AgentSession { const run = this._activeRlmChildRuns.get(childId); if (run?.session === session && run.status === "done") { const unsubscribe = run.unsubscribe ?? noopRlmChildEventUnsubscribe; - run.unsubscribe = undefined; - this._activeRlmChildRuns.delete(childId); - return unsubscribe; + return () => { + run.unsubscribe = undefined; + this._activeRlmChildRuns.delete(childId); + unsubscribe(); + }; } if (this._rlmChildSessions.get(childId)?.session !== session) return false; const unsubscribe = this._rlmChildUnsubscribes.get(childId) ?? noopRlmChildEventUnsubscribe; - this._rlmChildUnsubscribes.delete(childId); - this._rlmChildSessions.delete(childId); - return unsubscribe; + return () => { + this._rlmChildUnsubscribes.delete(childId); + this._rlmChildSessions.delete(childId); + unsubscribe(); + }; } private _rlmChildSnapshotForRun( diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index bc6e83cd32..55bd37858e 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -2803,12 +2803,9 @@ export class AgentDaemon { try { await this.closeSession(state, "shutdown", true, false); } catch (error) { - // A pre-removal close failure must not strand a resident child outside its - // parent's ownership map or disconnect its event forwarder. if ( this.sessions.get(state.activeSessionId) === state && - this.sessions.get(parentActiveSessionId) === parentState && - parentState.runtime.session.registerRlmChildSession(childId, state.runtime.session, unsubscribeChild) + this.sessions.get(parentActiveSessionId) === parentState ) { throw error; } diff --git a/packages/coding-agent/test/agent-session-recursion.test.ts b/packages/coding-agent/test/agent-session-recursion.test.ts index 7da365d767..f1797da7f7 100644 --- a/packages/coding-agent/test/agent-session-recursion.test.ts +++ b/packages/coding-agent/test/agent-session-recursion.test.ts @@ -2222,9 +2222,8 @@ describe("AgentSession rlm recursion", () => { } const rootInternals = root as unknown as InspectableRlmSession; await waitFor(() => !rootInternals._activeRlmChildRuns.has(childId)); - const unsubscribe = root.releaseRlmChildSession(childId, child); - if (!unsubscribe) throw new Error("Failed to release retained child"); - expect(root.registerRlmChildSession(childId, child, unsubscribe)).toBe(true); + const completeRelease = root.releaseRlmChildSession(childId, child); + if (!completeRelease) throw new Error("Failed to release retained child"); child.setCurrentRecap("retained recap"); child.setSessionName("renamed-worker"); diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index 3e2b696e2d..8d29c74585 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -7041,7 +7041,7 @@ describe("daemon mode helpers", () => { } }); - it("re-adopts a resident child when its passivation close fails", async () => { + it("keeps ownership of a resident child until passivation succeeds", async () => { const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-passivation-close-failure-")); let releaseAbort!: () => void; const abortGate = new Promise((resolve) => { @@ -7065,7 +7065,6 @@ describe("daemon mode helpers", () => { const childState = await internals.createRuntime({ type: "create", sessionPath: fixture.childSessionFile }); const parentSession = parentState.runtime.session as unknown as { releaseRlmChildSession: ReturnType; - registerRlmChildSession: ReturnType; }; let parentOwnsChild = true; let forwarderActive = true; @@ -7078,16 +7077,11 @@ describe("daemon mode helpers", () => { }); parentSession.releaseRlmChildSession = vi.fn(() => { if (!parentOwnsChild) return false; - parentOwnsChild = false; - return unsubscribeForwarder; - }); - parentSession.registerRlmChildSession = vi.fn( - (_childId: string, _childSession: unknown, unsubscribe: () => void) => { - parentOwnsChild = true; - forwarderActive = unsubscribe === unsubscribeForwarder; - return true; - }, - ); + return () => { + parentOwnsChild = false; + unsubscribeForwarder(); + }; + }); childState.unsubscribe = vi .fn() .mockImplementationOnce(() => { @@ -7113,11 +7107,6 @@ describe("daemon mode helpers", () => { await expect(delivery).resolves.toMatchObject({ deliveryStatus: "delivered" }); expect(internals.sessions.get(childState.activeSessionId)).toBe(childState); expect(parentOwnsChild).toBe(true); - expect(parentSession.registerRlmChildSession).toHaveBeenCalledWith( - fixture.childId, - childState.runtime.session, - unsubscribeForwarder, - ); expect(unsubscribeForwarder).not.toHaveBeenCalled(); emitChildUpdate("recap after failed close"); expect(parentUpdates).toEqual(["recap after failed close"]); From 20c2482a9aca8748f9831e1af81b03be34f592b4 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 27 Aug 2026 11:03:08 +0200 Subject: [PATCH 38/39] fix(coding-agent): reconcile queue selection after snapshots --- .../src/modes/interactive/interactive-mode.ts | 31 ++++++---- .../test/interactive-mode-status.test.ts | 60 +++++++++++++++++++ .../test/interactive-queue-edit.test.ts | 17 ++++++ .../regressions/4509-side-questions.test.ts | 1 + 4 files changed, 97 insertions(+), 12 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 0cb50c3ea4..59de91fc4c 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -2637,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; @@ -2648,14 +2655,10 @@ export class InteractiveMode { case "agent_end": this.patchConnectionState({ isStreaming: false, activeToolNames: [] }); break; - case "session_action_update": { + case "session_action_update": this.patchConnectionState({ sessionActions: event.actions }); - const selected = this.queueSelection.selected; - if (selected && !this.pendingQueueEdit && !this.pendingQueueMove) { - this.refreshQueueSelectionAt(this.getConnectionQueue(), selected, selected.index); - } + this.refreshQueueSelectionFromState(); break; - } case "compaction_start": this.patchConnectionState({ isCompacting: true }); break; @@ -2895,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; @@ -7025,12 +7029,15 @@ export class InteractiveMode { }, ); if (sessionGeneration !== this.sessionEventGeneration) return; - if (status === "applied") { - await this.sessionEventQueue; - if (sessionGeneration !== this.sessionEventGeneration) return; - this.refreshQueueSelectionAt(this.getConnectionQueue(), selected, selected.index + direction); - this.ui.requestRender(); - } else if (status === "unsupported") this.showStatus("Queue editing requires a newer daemon"); + 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; diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index b7aad8620f..c9cac26fce 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -1673,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() }]]); @@ -1706,6 +1764,7 @@ describe("InteractiveMode connection events", () => { streamingComponent: {}, streamingMessage: {}, applyConnectionStateSnapshot: vi.fn(), + refreshQueueSelectionFromState: vi.fn(), updateWorkingLoaderMessage: vi.fn(), replaceSubagentSummary: vi.fn(), getSessionContextFromConnectionSnapshot: vi.fn(() => ({ @@ -1762,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(() => ({ diff --git a/packages/coding-agent/test/interactive-queue-edit.test.ts b/packages/coding-agent/test/interactive-queue-edit.test.ts index ca79d849a4..67519c288d 100644 --- a/packages/coding-agent/test/interactive-queue-edit.test.ts +++ b/packages/coding-agent/test/interactive-queue-edit.test.ts @@ -42,6 +42,7 @@ type Harness = { 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; @@ -90,6 +91,7 @@ function createHarness(queue: { steering: string[]; followUp: string[] }, mutate moveQueueSelection: proto.moveQueueSelection, getConnectionQueue: proto.getConnectionQueue, refreshQueueSelectionAt: proto.refreshQueueSelectionAt, + refreshQueueSelectionFromState: proto.refreshQueueSelectionFromState, updateConnectionStateFromEvent: proto.updateConnectionStateFromEvent, patchConnectionState: () => {}, setEditorTextFromQueueSelection: proto.setEditorTextFromQueueSelection, @@ -400,6 +402,21 @@ describe("interactive queued-message editing", () => { expect(harness.editor.getText()).toBe("draft"); }); + 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; + + expect(harness.queueSelection.isBrowsing).toBe(false); + expect(harness.editor.getText()).toBe("draft"); + }); + 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 () => { 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 aff5396798..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: [], From f80ddd117e173b50c9034f3394e08c49946b43ee Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 27 Aug 2026 12:07:20 +0200 Subject: [PATCH 39/39] chore(ai): add changelog fragment for overflow helper removal --- packages/ai/.changes/remove-overflow-pattern.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/ai/.changes/remove-overflow-pattern.md diff --git a/packages/ai/.changes/remove-overflow-pattern.md b/packages/ai/.changes/remove-overflow-pattern.md new file mode 100644 index 0000000000..3b412e4d0a --- /dev/null +++ b/packages/ai/.changes/remove-overflow-pattern.md @@ -0,0 +1 @@ +- Removed the unused overflow pattern helper from `utils/overflow.ts`.