From 40fac3ff56a76b677b2220041f6bbfbb994d8f60 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 11:17:34 +0200 Subject: [PATCH 01/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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 372ec9468803710febc4c0fad1922a70e875f872 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 25 Aug 2026 00:15:55 +0200 Subject: [PATCH 34/53] Use session message count for new-chat state --- .../.changes/use-message-count.md | 1 + .../src/modes/interactive/interactive-mode.ts | 25 +++++++--------- .../test/interactive-mode-startup.test.ts | 26 ++++++++--------- .../test/interactive-mode-status.test.ts | 29 +++++++++++++++++-- 4 files changed, 51 insertions(+), 30 deletions(-) create mode 100644 packages/coding-agent/.changes/use-message-count.md diff --git a/packages/coding-agent/.changes/use-message-count.md b/packages/coding-agent/.changes/use-message-count.md new file mode 100644 index 0000000000..26bf261cc8 --- /dev/null +++ b/packages/coding-agent/.changes/use-message-count.md @@ -0,0 +1 @@ +- Fixed new-chat hints to use the session message count. diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 5f912e3446..3b034afb2a 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -990,7 +990,6 @@ export class InteractiveMode { private connectionModelsRefreshInFlight: { version: number; promise: Promise } | undefined; private connectionState: AgentConnectionState | undefined; private connectionResourceSnapshot: AgentConnectionResourceSnapshot | undefined; - private sessionHasMessages = false; private heartbeatCatalog: AgentConnectionHeartbeat[] = []; private heartbeats: AgentConnectionHeartbeat[] = []; private heartbeatRefreshPromise: Promise | undefined; @@ -1609,7 +1608,7 @@ export class InteractiveMode { // The agents view owns these for daemon sessions. When there is no agents view, // show them once at the top of a fresh session, but never append them under a // restored conversation where they read as disconnected clutter. - if (!ownsGlobalStartupNotices || this.sessionHasMessages) { + if (!ownsGlobalStartupNotices || !this.isNewChat()) { return; } @@ -2645,6 +2644,15 @@ export class InteractiveMode { case "agent_start": this.patchConnectionState({ isStreaming: true, activeToolNames: [] }); break; + case "message_start": { + const wasNewChat = this.connectionState.messageCount === 0; + this.patchConnectionState({ messageCount: this.connectionState.messageCount + 1 }); + if (wasNewChat) { + this.builtInHeader?.invalidate(); + this.subagentSummaryLine.invalidate(); + } + break; + } case "agent_end": this.patchConnectionState({ isStreaming: false, activeToolNames: [] }); break; @@ -5321,7 +5329,6 @@ export class InteractiveMode { } if (event.type === "message_start" && (event.message.role === "user" || isAgentSessionMessage(event.message))) { this.contextUsageTokenBaseline = 0; - this.setSessionHasMessages(true); this.clearShortcutGuide(); this.agentRunFileChanges.clear(); this.renderRecap(); @@ -6037,16 +6044,7 @@ export class InteractiveMode { } private isNewChat(): boolean { - return !this.sessionHasMessages; - } - - private setSessionHasMessages(hasMessages: boolean): void { - if (this.sessionHasMessages === hasMessages) { - return; - } - this.sessionHasMessages = hasMessages; - this.builtInHeader?.invalidate(); - this.subagentSummaryLine.invalidate(); + return (this.connectionState?.messageCount ?? 0) === 0; } private getModelTrayLabel(): string { @@ -6566,7 +6564,6 @@ export class InteractiveMode { const streamingMessage = snapshot.streamingMessage; this.rlmNodeId = snapshot.parent?.childId; this.seedSubagentSummary(snapshot.children); - this.setSessionHasMessages(context.messages.length > 0); this.applyConnectionStateSnapshot(state); this.restoreTurnStartFromMessages(context.messages); await this.renderSessionContext(context, { diff --git a/packages/coding-agent/test/interactive-mode-startup.test.ts b/packages/coding-agent/test/interactive-mode-startup.test.ts index c29a166a87..5b9fb342b3 100644 --- a/packages/coding-agent/test/interactive-mode-startup.test.ts +++ b/packages/coding-agent/test/interactive-mode-startup.test.ts @@ -20,14 +20,14 @@ describe("InteractiveMode startup hints", () => { setKeybindings(new KeybindingsManager()); }); - function createMode(sessionHasMessages = false, returnToAgentsView = false, getEditorText = () => "") { + function createMode(messageCount = 0, returnToAgentsView = false, getEditorText = () => "") { const mode = { - sessionHasMessages, options: { returnToAgentsView }, editor: { getText: getEditorText }, connectionState: { model: { name: "test-model", reasoning: true }, thinkingLevel: "high", + messageCount, }, }; Object.setPrototypeOf(mode, InteractiveMode.prototype); @@ -85,7 +85,7 @@ describe("InteractiveMode startup hints", () => { it("routes session-view requests through the existing agents-view return path", async () => { const returnToAgentsView = vi.fn(async () => {}); - const mode = Object.assign(createMode(false, true), { returnToAgentsView }); + const mode = Object.assign(createMode(0, true), { returnToAgentsView }); await Reflect.get(InteractiveMode.prototype, "requestAgentsView").call(mode); @@ -96,7 +96,7 @@ describe("InteractiveMode startup hints", () => { const returnToAgentsView = vi.fn(async () => {}); const showStatus = vi.fn(); const mode = Object.assign( - createMode(false, true, () => "draft prompt"), + createMode(0, true, () => "draft prompt"), { returnToAgentsView, showStatus }, ); @@ -110,7 +110,7 @@ describe("InteractiveMode startup hints", () => { const returnToAgentsView = vi.fn(async () => {}); const showStatus = vi.fn(); const mode = Object.assign( - createMode(false, true, () => "scoped draft"), + createMode(0, true, () => "scoped draft"), { returnToAgentsView, showStatus }, ); @@ -127,7 +127,7 @@ describe("InteractiveMode startup hints", () => { resolveDispose = resolve; }); const mode = Object.assign( - createMode(false, true, () => "draft prompt"), + createMode(0, true, () => "draft prompt"), { promptStashState, pastedImages: new Map(), @@ -152,7 +152,7 @@ describe("InteractiveMode startup hints", () => { it("opens the shared session view on back navigation for process-local chats", async () => { const requestAgentsView = vi.fn(async () => {}); const returnToAgentsView = vi.fn(async () => {}); - const mode = Object.assign(createMode(false, false), { requestAgentsView, returnToAgentsView }); + const mode = Object.assign(createMode(0, false), { requestAgentsView, returnToAgentsView }); const handled = Reflect.get(InteractiveMode.prototype, "handleAgentsBack").call(mode) as boolean; @@ -164,7 +164,7 @@ describe("InteractiveMode startup hints", () => { it("returns to the daemon agents view on back navigation for daemon chats", async () => { const requestAgentsView = vi.fn(async () => {}); const returnToAgentsView = vi.fn(async () => {}); - const mode = Object.assign(createMode(false, true), { requestAgentsView, returnToAgentsView }); + const mode = Object.assign(createMode(0, true), { requestAgentsView, returnToAgentsView }); const handled = Reflect.get(InteractiveMode.prototype, "handleAgentsBack").call(mode) as boolean; @@ -176,7 +176,7 @@ describe("InteractiveMode startup hints", () => { it("leaves back navigation to the editor while a draft exists", async () => { const requestAgentsView = vi.fn(async () => {}); const mode = Object.assign( - createMode(false, false, () => "draft prompt"), + createMode(0, false, () => "draft prompt"), { requestAgentsView }, ); @@ -189,7 +189,7 @@ describe("InteractiveMode startup hints", () => { it("explains that the agents view needs the daemon for non-daemon chats", async () => { const showStatus = vi.fn(); const shutdown = vi.fn(async () => {}); - const mode = Object.assign(createMode(false, false), { + const mode = Object.assign(createMode(0, false), { returnToAgentsView: vi.fn(async () => {}), showStatus, shutdown, @@ -203,7 +203,7 @@ describe("InteractiveMode startup hints", () => { it("keeps the lowercase agents hint while typing", () => { let editorText = ""; - const mode = createMode(false, true, () => editorText); + const mode = createMode(0, true, () => editorText); const getLabel = () => Reflect.get(InteractiveMode.prototype, "getTrayLocationLabel").call(mode); expect(stripAnsi(getLabel())).toBe("← agents/resume test-model • high ? for shortcuts"); @@ -214,7 +214,7 @@ describe("InteractiveMode startup hints", () => { it("hides the fresh-chat shortcut hint while the prompt has text", () => { let editorText = ""; - const mode = createMode(false, false, () => editorText); + const mode = createMode(0, false, () => editorText); const getLabel = () => Reflect.get(InteractiveMode.prototype, "getTrayLocationLabel").call(mode); expect(stripAnsi(getLabel())).toBe("test-model • high ? for shortcuts"); @@ -230,7 +230,7 @@ describe("InteractiveMode startup hints", () => { }); it("hides the tray shortcut guidance for chats with history", () => { - const mode = createMode(true); + const mode = createMode(1); const label = Reflect.get(InteractiveMode.prototype, "getTrayLocationLabel").call(mode); expect(stripAnsi(label)).toBe("test-model • high"); diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index b7aad8620f..2e5d1b1c14 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -711,7 +711,6 @@ describe("InteractiveMode working timer", () => { model: null, })), seedSubagentSummary: vi.fn(), - setSessionHasMessages: vi.fn(), applyConnectionStateSnapshot: vi.fn((state: AgentConnectionState) => { streaming = state.isStreaming; }), @@ -1435,6 +1434,31 @@ describe("InteractiveMode pending bash components", () => { }); describe("InteractiveMode connection events", () => { + test("updates the session message count from message starts", () => { + const builtInHeader = { invalidate: vi.fn() }; + const subagentSummaryLine = { invalidate: vi.fn() }; + const harness = { + connectionState: createConnectionState(), + builtInHeader, + subagentSummaryLine, + patchConnectionState(patch: Partial) { + this.connectionState = { ...this.connectionState, ...patch }; + }, + }; + const updateConnectionStateFromEvent = ( + InteractiveMode.prototype as unknown as { + updateConnectionStateFromEvent(this: typeof harness, event: AgentConnectionSessionEvent): void; + } + ).updateConnectionStateFromEvent; + + updateConnectionStateFromEvent.call(harness, { type: "message_start", message: userMessage("one", 1) }); + updateConnectionStateFromEvent.call(harness, { type: "message_start", message: userMessage("two", 2) }); + + expect(harness.connectionState.messageCount).toBe(2); + expect(builtInHeader.invalidate).toHaveBeenCalledOnce(); + expect(subagentSummaryLine.invalidate).toHaveBeenCalledOnce(); + }); + test("rendering a switched session updates the pending display from its snapshot", async () => { const harness = { resetCurrentSessionRenderState: vi.fn(), @@ -1501,7 +1525,6 @@ describe("InteractiveMode connection events", () => { model: null, })), seedSubagentSummary: vi.fn(), - setSessionHasMessages: vi.fn(), applyConnectionStateSnapshot: vi.fn(), renderSessionContext: renderSessionContextMock, restoreStreamingMessageFromSnapshot, @@ -3262,7 +3285,7 @@ describe("InteractiveMode Prime CLI onboarding", () => { showWarning: vi.fn(), showError: vi.fn(), getCurrentCwd: () => startupRunResult.source.cwd, - sessionHasMessages: false, + connectionState: { messageCount: 0 }, ...overrides, }; } From f795a0a6eb289babd0129722c41761ed3e3cf74f Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 25 Aug 2026 00:47:37 +0200 Subject: [PATCH 35/53] Remove stale setSessionHasMessages test mocks --- packages/coding-agent/test/interactive-mode-streaming.test.ts | 2 -- .../test/suite/regressions/4533-preserve-recap.test.ts | 2 -- 2 files changed, 4 deletions(-) diff --git a/packages/coding-agent/test/interactive-mode-streaming.test.ts b/packages/coding-agent/test/interactive-mode-streaming.test.ts index 6b628a4946..494c1f29c6 100644 --- a/packages/coding-agent/test/interactive-mode-streaming.test.ts +++ b/packages/coding-agent/test/interactive-mode-streaming.test.ts @@ -51,7 +51,6 @@ type HandleEventThis = { checkShutdownRequested(): Promise; applyOptimisticContextUsage(): void; refreshConnectionContextUsage(): Promise; - setSessionHasMessages(hasMessages: boolean): void; clearShortcutGuide(): void; addMessageToChat(): void; }; @@ -102,7 +101,6 @@ function createFakeInteractiveModeThis(): HandleEventThis { checkShutdownRequested: vi.fn(async () => {}), applyOptimisticContextUsage: vi.fn(), refreshConnectionContextUsage: vi.fn(async () => {}), - setSessionHasMessages: vi.fn(), clearShortcutGuide: vi.fn(), addMessageToChat: vi.fn(), }; diff --git a/packages/coding-agent/test/suite/regressions/4533-preserve-recap.test.ts b/packages/coding-agent/test/suite/regressions/4533-preserve-recap.test.ts index 49253a889f..4110fedae0 100644 --- a/packages/coding-agent/test/suite/regressions/4533-preserve-recap.test.ts +++ b/packages/coding-agent/test/suite/regressions/4533-preserve-recap.test.ts @@ -21,7 +21,6 @@ type MessageStartMode = { footer: { invalidate: () => void }; updateConnectionStateFromEvent: (event: unknown) => void; contextUsageTokenBaseline: number; - setSessionHasMessages: (hasMessages: boolean) => void; clearShortcutGuide: () => void; activityTracker: { handleEvent: (event: unknown) => void }; updateWorkingLoaderMessage: () => void; @@ -56,7 +55,6 @@ function createMessageStartMode(): MessageStartMode { footer: { invalidate: vi.fn() }, updateConnectionStateFromEvent: vi.fn(), contextUsageTokenBaseline: 12, - setSessionHasMessages: vi.fn(), clearShortcutGuide: vi.fn(), activityTracker: { handleEvent: vi.fn() }, updateWorkingLoaderMessage: vi.fn(), From fbb1037fb8e1cfc72f8cc40c462eff3dede59cd2 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 25 Aug 2026 01:23:52 +0200 Subject: [PATCH 36/53] Derive available connection models on read --- .../.changes/derive-connection-models.md | 1 + .../src/modes/interactive/interactive-mode.ts | 11 ++++++----- .../test/interactive-mode-status.test.ts | 19 +++++++------------ .../4575-model-auth-selection.test.ts | 8 +++----- 4 files changed, 17 insertions(+), 22 deletions(-) create mode 100644 packages/coding-agent/.changes/derive-connection-models.md diff --git a/packages/coding-agent/.changes/derive-connection-models.md b/packages/coding-agent/.changes/derive-connection-models.md new file mode 100644 index 0000000000..dc0d1874f0 --- /dev/null +++ b/packages/coding-agent/.changes/derive-connection-models.md @@ -0,0 +1 @@ +- Kept available model lists in sync with the current catalog and configured providers. diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 3b034afb2a..7de598a7b4 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -982,7 +982,6 @@ export class InteractiveMode { private skillCommands = new Map(); private connectionCommands: AgentConnectionSlashCommand[] = []; - private connectionModels: AgentConnectionModel[] = []; private connectionModelCatalog: AgentConnectionModel[] = []; private connectionConfiguredProviders = new Set(); private connectionModelsFetchedAt = 0; @@ -7717,7 +7716,10 @@ export class InteractiveMode { private applyConnectionModelCatalog(catalog: AgentConnectionModelCatalog): void { this.connectionModelCatalog = [...catalog.models]; this.connectionConfiguredProviders = new Set(catalog.configuredProviders); - this.connectionModels = catalog.models.filter((model) => this.connectionConfiguredProviders.has(model.provider)); + } + + private getAvailableConnectionModels(): AgentConnectionModel[] { + return this.connectionModelCatalog.filter((model) => this.connectionConfiguredProviders.has(model.provider)); } private async getConnectionAvailableModels(): Promise { @@ -7729,11 +7731,11 @@ export class InteractiveMode { const version = this.connectionModelsRefreshVersion; const promise = this.agentConnection.getModelCatalog().then((catalog) => { if (version !== this.connectionModelsRefreshVersion) { - return [...this.connectionModels]; + return this.getAvailableConnectionModels(); } this.applyConnectionModelCatalog(catalog); this.connectionModelsFetchedAt = Date.now(); - return [...this.connectionModels]; + return this.getAvailableConnectionModels(); }); this.connectionModelsRefreshInFlight = { version, promise }; @@ -7784,7 +7786,6 @@ export class InteractiveMode { } private invalidateConnectionModels(): void { - this.connectionModels = []; this.connectionConfiguredProviders = new Set(); this.connectionModelsFetchedAt = 0; this.invalidateConnectionModelRefresh(); diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 2e5d1b1c14..157b875d96 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -2172,7 +2172,6 @@ describe("InteractiveMode startup onboarding warnings", () => { describe("InteractiveMode model candidates", () => { type ModelCandidatesHarness = { agentConnection: { getModelCatalog: () => Promise }; - connectionModels: AgentConnectionModel[]; connectionModelCatalog: AgentConnectionModel[]; connectionConfiguredProviders: Set; connectionModelsFetchedAt: number; @@ -2180,6 +2179,7 @@ describe("InteractiveMode model candidates", () => { connectionModelsRefreshInFlight: { version: number; promise: Promise } | undefined; getScopedModelState(): AgentConnectionState["scopedModels"]; applyConnectionModelCatalog(catalog: AgentConnectionModelCatalog): void; + getAvailableConnectionModels(): AgentConnectionModel[]; getConnectionAvailableModels(): Promise; getModelCandidates(): Promise; getScopedModelsFromModelIds( @@ -2201,7 +2201,6 @@ describe("InteractiveMode model candidates", () => { const getModelCatalog = vi.fn(async () => ({ models: [model], configuredProviders: [model.provider] })); const fakeThis: ModelCandidatesHarness = { agentConnection: { getModelCatalog }, - connectionModels: [], connectionModelCatalog: [], connectionConfiguredProviders: new Set(), connectionModelsFetchedAt: 0, @@ -2209,6 +2208,7 @@ describe("InteractiveMode model candidates", () => { connectionModelsRefreshInFlight: undefined, getScopedModelState: () => [], applyConnectionModelCatalog: prototype.applyConnectionModelCatalog, + getAvailableConnectionModels: prototype.getAvailableConnectionModels, getConnectionAvailableModels: prototype.getConnectionAvailableModels, getModelCandidates: prototype.getModelCandidates, getScopedModelsFromModelIds: prototype.getScopedModelsFromModelIds, @@ -2218,7 +2218,7 @@ describe("InteractiveMode model candidates", () => { expect(result).toEqual([model]); expect(getModelCatalog).toHaveBeenCalledTimes(1); - expect(fakeThis.connectionModels).toEqual([model]); + expect(fakeThis.getAvailableConnectionModels()).toEqual([model]); }); test("uses connection state for scoped model candidates", async () => { @@ -2229,7 +2229,6 @@ describe("InteractiveMode model candidates", () => { }); const fakeThis: ModelCandidatesHarness = { agentConnection: { getModelCatalog }, - connectionModels: [], connectionModelCatalog: [], connectionConfiguredProviders: new Set(), connectionModelsFetchedAt: 0, @@ -2237,6 +2236,7 @@ describe("InteractiveMode model candidates", () => { connectionModelsRefreshInFlight: undefined, getScopedModelState: () => [{ model, thinkingLevel: "medium" }], applyConnectionModelCatalog: prototype.applyConnectionModelCatalog, + getAvailableConnectionModels: prototype.getAvailableConnectionModels, getConnectionAvailableModels: prototype.getConnectionAvailableModels, getModelCandidates: prototype.getModelCandidates, getScopedModelsFromModelIds: prototype.getScopedModelsFromModelIds, @@ -2299,7 +2299,6 @@ describe("InteractiveMode model selection persistence", () => { getModelCatalog(): Promise; setModel(provider: string, modelId: string): Promise; }; - connectionModels: AgentConnectionModel[]; connectionModelCatalog: AgentConnectionModel[]; connectionConfiguredProviders: Set; connectionModelsFetchedAt: number; @@ -2321,6 +2320,7 @@ describe("InteractiveMode model selection persistence", () => { getScopedModelState(): AgentConnectionState["scopedModels"]; getCurrentModel(): AgentConnectionModel | undefined; applyConnectionModelCatalog(catalog: AgentConnectionModelCatalog): void; + getAvailableConnectionModels(): AgentConnectionModel[]; findExactModelMatch(searchTerm: string): Promise; getConnectionAvailableModels(): Promise; getCachedModelCandidates(): AgentConnectionModel[]; @@ -2409,7 +2409,6 @@ describe("InteractiveMode model selection persistence", () => { }), setModel: vi.fn(async () => {}), }; - fakeThis.connectionModels = [...options.connectionModels]; fakeThis.connectionModelCatalog = catalogModels; fakeThis.connectionConfiguredProviders = configuredProviders; fakeThis.connectionModelsFetchedAt = options.connectionModelsFetchedAt ?? 0; @@ -2947,7 +2946,6 @@ describe("InteractiveMode model selection persistence", () => { getResourceSnapshot: vi.fn(async () => ({})), setModel: vi.fn(async () => {}), } as never; - fakeThis.connectionModels = []; fakeThis.connectionModelCatalog = []; fakeThis.connectionConfiguredProviders = new Set(); fakeThis.connectionModelsFetchedAt = 0; @@ -2973,7 +2971,7 @@ describe("InteractiveMode model selection persistence", () => { await expect(staleRefresh).resolves.toEqual([freshModel]); - expect(fakeThis.connectionModels).toEqual([freshModel]); + expect(fakeThis.getAvailableConnectionModels()).toEqual([freshModel]); }); test("keeps the cached model catalog when a catalog refresh fails", async () => { @@ -2997,7 +2995,6 @@ describe("InteractiveMode model selection persistence", () => { getResourceSnapshot: vi.fn(async () => ({})), setModel: vi.fn(async () => {}), } as never; - fakeThis.connectionModels = [cachedModel]; fakeThis.connectionModelCatalog = [cachedModel]; fakeThis.connectionConfiguredProviders = new Set([cachedModel.provider]); fakeThis.connectionModelsFetchedAt = Date.now(); @@ -3015,7 +3012,7 @@ describe("InteractiveMode model selection persistence", () => { await expect(fakeThis.refreshConnectionCatalog()).resolves.toBeUndefined(); expect(fakeThis.connectionCommands).toEqual([]); - expect(fakeThis.connectionModels).toEqual([expect.objectContaining({ id: "fresh" })]); + expect(fakeThis.getAvailableConnectionModels()).toEqual([expect.objectContaining({ id: "fresh" })]); expect(fakeThis.connectionModelsFetchedAt).toBeGreaterThan(0); }); @@ -3223,7 +3220,6 @@ describe("InteractiveMode Prime CLI onboarding", () => { }; type OnboardingFake = OnboardingHarness & { connectionState: AgentConnectionState; - connectionModels: AgentConnectionModel[]; agentConnection: { getAvailableModels?: () => Promise; setModel?: (provider: string, modelId: string) => Promise; @@ -3898,7 +3894,6 @@ describe("InteractiveMode Prime CLI onboarding", () => { function createPrimeCliHarness(shown: boolean): OnboardingFake { const fakeThis = Object.create(InteractiveMode.prototype) as OnboardingFake; fakeThis.connectionState = createConnectionState({ model: primeModel }); - fakeThis.connectionModels = [primeModel]; fakeThis.agentConnection = { getAvailableModels: vi.fn(async () => [primeModel]), }; diff --git a/packages/coding-agent/test/suite/regressions/4575-model-auth-selection.test.ts b/packages/coding-agent/test/suite/regressions/4575-model-auth-selection.test.ts index cd87112ba6..93b41ed17e 100644 --- a/packages/coding-agent/test/suite/regressions/4575-model-auth-selection.test.ts +++ b/packages/coding-agent/test/suite/regressions/4575-model-auth-selection.test.ts @@ -11,7 +11,6 @@ import { createHarness, type Harness } from "../harness.js"; interface ConnectionAuthRefreshHarness { agentConnection: { getModelCatalog(): Promise }; - connectionModels: AgentConnectionModel[]; connectionModelCatalog: AgentConnectionModel[]; connectionConfiguredProviders: Set; connectionModelsFetchedAt: number; @@ -19,6 +18,7 @@ interface ConnectionAuthRefreshHarness { connectionModelsRefreshInFlight: { version: number; promise: Promise } | undefined; invalidateConnectionModels(): void; applyConnectionModelCatalog(catalog: AgentConnectionModelCatalog): void; + getAvailableConnectionModels(): AgentConnectionModel[]; getConnectionAvailableModels(): Promise; getConnectionModelCatalog(): Promise; refreshConnectionModelsAfterAuthChange(): Promise; @@ -124,7 +124,6 @@ describe("ENG-4575 model authentication", () => { const getModelCatalog = vi.fn(async () => ({ models: [model], configuredProviders: [] })); const fakeThis = Object.create(InteractiveMode.prototype) as ConnectionAuthRefreshHarness; fakeThis.agentConnection = { getModelCatalog }; - fakeThis.connectionModels = [model]; fakeThis.connectionModelCatalog = [model]; fakeThis.connectionConfiguredProviders = new Set([model.provider]); fakeThis.connectionModelsFetchedAt = Date.now(); @@ -135,7 +134,7 @@ describe("ENG-4575 model authentication", () => { expect(getModelCatalog).toHaveBeenCalledOnce(); expect(fakeThis.connectionConfiguredProviders).toEqual(new Set()); - expect(fakeThis.connectionModels).toEqual([]); + expect(fakeThis.getAvailableConnectionModels()).toEqual([]); expect(fakeThis.connectionModelCatalog).toEqual([model]); }); @@ -147,7 +146,6 @@ describe("ENG-4575 model authentication", () => { fakeThis.agentConnection = { getModelCatalog: vi.fn(async () => ({ models: [model], configuredProviders: [] })), }; - fakeThis.connectionModels = []; fakeThis.connectionModelCatalog = []; fakeThis.connectionConfiguredProviders = new Set(); fakeThis.connectionModelsFetchedAt = 0; @@ -155,7 +153,7 @@ describe("ENG-4575 model authentication", () => { fakeThis.connectionModelsRefreshInFlight = undefined; await expect(fakeThis.getConnectionModelCatalog()).resolves.toEqual([model]); - expect(fakeThis.connectionModels).toEqual([]); + expect(fakeThis.getAvailableConnectionModels()).toEqual([]); }); test("uses the full public catalog for scoped-session model autocomplete", async () => { From 322c57269d9ad33ba0e7c5f4586c3a03b3f507d0 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 25 Aug 2026 03:15:01 +0200 Subject: [PATCH 37/53] Remove duplicate heartbeat cache stale flag --- .../coding-agent/.changes/heartbeat-cache-presence.md | 1 + .../coding-agent/src/modes/daemon/daemon-supervisor.ts | 8 +++----- .../test/daemon-supervisor-heartbeats.test.ts | 3 +-- 3 files changed, 5 insertions(+), 7 deletions(-) create mode 100644 packages/coding-agent/.changes/heartbeat-cache-presence.md diff --git a/packages/coding-agent/.changes/heartbeat-cache-presence.md b/packages/coding-agent/.changes/heartbeat-cache-presence.md new file mode 100644 index 0000000000..6ad1f6baec --- /dev/null +++ b/packages/coding-agent/.changes/heartbeat-cache-presence.md @@ -0,0 +1 @@ +- Prevented outdated heartbeat snapshots from being retained after worker changes. diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index c6b9254e47..1d8070d1c3 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -274,7 +274,6 @@ interface ResidentWorker { descriptorPath: string; client?: DaemonWorkerClient; heartbeatSnapshot?: AgentConnectionHeartbeat[]; - heartbeatSnapshotStale?: boolean; summaries: Map; snapshotCache: Map; transcriptCaches: Map; @@ -1870,15 +1869,14 @@ export class DaemonSupervisor { if (response.success) { const snapshot = heartbeatsFromResponse(response); worker.heartbeatSnapshot = snapshot; - worker.heartbeatSnapshotStale = false; return { heartbeats: snapshot }; } this.log(`Could not list heartbeats from a worker: ${response.error}`); - if (worker.heartbeatSnapshot === undefined || worker.heartbeatSnapshotStale === true) { + if (worker.heartbeatSnapshot === undefined) { return { response }; } } - if (worker.heartbeatSnapshot !== undefined && worker.heartbeatSnapshotStale !== true) { + if (worker.heartbeatSnapshot !== undefined) { return { heartbeats: worker.heartbeatSnapshot }; } const state = @@ -4166,7 +4164,7 @@ export class DaemonSupervisor { snapshotPurpose, } = frame.header; if (outboundType === "heartbeats_changed") { - worker.heartbeatSnapshotStale = true; + worker.heartbeatSnapshot = undefined; this.broadcastHeartbeatsChanged(); return; } diff --git a/packages/coding-agent/test/daemon-supervisor-heartbeats.test.ts b/packages/coding-agent/test/daemon-supervisor-heartbeats.test.ts index 68f8f98e45..8ed45ace9b 100644 --- a/packages/coding-agent/test/daemon-supervisor-heartbeats.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-heartbeats.test.ts @@ -99,7 +99,6 @@ describe("daemon supervisor heartbeat aggregation", () => { const target = { ...worker("ready"), heartbeatSnapshot: [{ job: { id: "heartbeat-1" } }], - heartbeatSnapshotStale: false, }; supervisor.workers.set("target", target); supervisor.forwardToWorker = vi.fn(async (_worker, command) => @@ -115,7 +114,7 @@ describe("daemon supervisor heartbeat aggregation", () => { type: "heartbeats_list", }); - expect(target.heartbeatSnapshotStale).toBe(true); + expect(target.heartbeatSnapshot).toBeUndefined(); expect(response).toMatchObject({ success: false, error: "worker unavailable" }); }); From d7a7f07c4ab84b57f888cf73cc094e8c02d66168 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 25 Aug 2026 04:15:35 +0200 Subject: [PATCH 38/53] Remove RPC startup readiness delay --- packages/coding-agent/.changes/rpc-start-readiness.md | 1 + packages/coding-agent/src/modes/rpc/rpc-client.ts | 11 +++++------ packages/coding-agent/test/rpc-client-timeout.test.ts | 8 ++++++++ 3 files changed, 14 insertions(+), 6 deletions(-) create mode 100644 packages/coding-agent/.changes/rpc-start-readiness.md diff --git a/packages/coding-agent/.changes/rpc-start-readiness.md b/packages/coding-agent/.changes/rpc-start-readiness.md new file mode 100644 index 0000000000..c2be30c87c --- /dev/null +++ b/packages/coding-agent/.changes/rpc-start-readiness.md @@ -0,0 +1 @@ +- Removed the fixed delay when starting an RPC client. diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index fa3ea7de1a..007f2ea348 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -5,6 +5,7 @@ */ import { type ChildProcess, spawn } from "node:child_process"; +import { once } from "node:events"; import type { AgentEvent, AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { ImageContent } from "@earendil-works/pi-ai"; import type { AgentSessionMessageReceipt, AgentSessionMessageSafetyStatus } from "../../core/agent-messages.js"; @@ -135,12 +136,10 @@ export class RpcClient { this.handleLine(line); }); - // Wait a moment for process to initialize - await new Promise((resolve) => setTimeout(resolve, 100)); - - if (this.transportError) throw this.transportError; - if (child.exitCode !== null) { - throw new Error(`Agent process exited immediately with code ${child.exitCode}. Stderr: ${this.stderr}`); + try { + await once(child, "spawn"); + } catch (error) { + throw this.transportError ?? error; } } diff --git a/packages/coding-agent/test/rpc-client-timeout.test.ts b/packages/coding-agent/test/rpc-client-timeout.test.ts index f505a53b2c..b75d30cbc0 100644 --- a/packages/coding-agent/test/rpc-client-timeout.test.ts +++ b/packages/coding-agent/test/rpc-client-timeout.test.ts @@ -85,6 +85,14 @@ describe("RpcClient operation completion", () => { await expect(state).resolves.toEqual({}); }); + it("starts from the child spawn signal without waiting for a timer", async () => { + vi.useFakeTimers(); + const client = new RpcClient({ cliPath: fixturePath }); + clients.add(client); + + await expect(client.start()).resolves.toBeUndefined(); + }); + it("rejects start when the child cannot spawn", async () => { const client = new RpcClient({ cliPath: fixturePath, env: { PATH: "" } }); From d175536886dffc6a70a35fff667e08c20a5051d2 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 25 Aug 2026 04:40:04 +0200 Subject: [PATCH 39/53] Remove empty selector cancellation timers --- .../coding-agent/.changes/remove-empty-selector-timers.md | 1 + .../src/modes/interactive/components/tree-selector.ts | 4 ---- .../src/modes/interactive/components/user-message-selector.ts | 4 ---- 3 files changed, 1 insertion(+), 8 deletions(-) create mode 100644 packages/coding-agent/.changes/remove-empty-selector-timers.md diff --git a/packages/coding-agent/.changes/remove-empty-selector-timers.md b/packages/coding-agent/.changes/remove-empty-selector-timers.md new file mode 100644 index 0000000000..96a3643832 --- /dev/null +++ b/packages/coding-agent/.changes/remove-empty-selector-timers.md @@ -0,0 +1 @@ +- Removed delayed cancellation callbacks from empty interactive selectors. diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index a4a196fa58..e01bdfb79d 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -1198,10 +1198,6 @@ export class TreeSelectorComponent extends Container implements Focusable { this.addChild(this.labelInputContainer); this.addChild(new Spacer(1)); this.addChild(new DynamicBorder()); - - if (tree.length === 0) { - setTimeout(() => onCancel(), 100); - } } private showLabelInput(entryId: string, currentLabel: string | undefined): void { diff --git a/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts b/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts index 7dae13e12f..999d830344 100644 --- a/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts @@ -117,10 +117,6 @@ export class UserMessageSelectorComponent extends Container { this.addChild(new Spacer(1)); this.addChild(new DynamicBorder()); - - if (messages.length === 0) { - setTimeout(() => onCancel(), 100); - } } getMessageList(): UserMessageList { From 6e33a6ed0846c54fcb7cec63a785a90db95931de Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 25 Aug 2026 05:09:40 +0200 Subject: [PATCH 40/53] Query supervisor agent peers on demand --- .../.changes/on-demand-agent-peers.md | 1 + .../src/modes/daemon/daemon-mode.ts | 57 ++++--- .../src/modes/daemon/daemon-protocol.ts | 10 +- .../src/modes/daemon/daemon-supervisor.ts | 69 +++------ .../modes/daemon/daemon-worker-protocol.ts | 7 +- .../coding-agent/test/daemon-mode.test.ts | 144 ++++-------------- .../daemon-supervisor-lazy-subagents.test.ts | 21 ++- .../test/daemon-supervisor-monitor.test.ts | 91 ----------- ...4602-snapshot-transfer-idempotency.test.ts | 3 - .../4685-daemon-client-modes.test.ts | 10 +- 10 files changed, 101 insertions(+), 312 deletions(-) create mode 100644 packages/coding-agent/.changes/on-demand-agent-peers.md diff --git a/packages/coding-agent/.changes/on-demand-agent-peers.md b/packages/coding-agent/.changes/on-demand-agent-peers.md new file mode 100644 index 0000000000..64605c47d9 --- /dev/null +++ b/packages/coding-agent/.changes/on-demand-agent-peers.md @@ -0,0 +1 @@ +- Made cross-worker agent lists current without broadcasting duplicate peer rosters. diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index ab204aa723..b5d4cfb2da 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -517,7 +517,6 @@ export class AgentDaemon { private readonly agentDir: string; private readonly cronScheduler: AgentCronScheduler; private readonly agentMessageRateLimiter = new AgentSessionMessageRateLimiter(); - private readonly remoteAgentPeers = new Map(); private readonly agentMessagePendingReservations = new Map(); private readonly agentMessageTargetLocks = new Map>(); private readonly agentMessageAcceptingTargets = new Set(); @@ -3613,13 +3612,6 @@ export class AgentDaemon { this.write(client, success(command.id, "detach")); return; } - case "worker_sync_agent_peers": - this.remoteAgentPeers.clear(); - for (const peer of command.peers) { - this.remoteAgentPeers.set(peer.activeSessionId, peer); - } - this.writeWorkerSuccess(client, command); - return; case "worker_archive_and_shutdown": { for (const state of [...this.sessions.values()]) { await this.closeSession(state, "killed"); @@ -5322,7 +5314,30 @@ export class AgentDaemon { }; } - private async createAgentMessageListResult(current: ActiveSessionState): Promise { + private async listSupervisorAgentPeers(): Promise { + const supervisorSocketPath = this.supervisorSocketPathFromEnv(); + if (!this.options.worker || !supervisorSocketPath) return []; + const client = new DaemonClient(supervisorSocketPath); + try { + await client.connect(1000); + await client.waitForHello(1000); + const response = await client.request( + { type: "list_agent_peers", workerToken: this.options.worker.authenticationToken }, + 5000, + ); + if (!response.success) throw deserializeDaemonError(response); + // SAFETY: The authenticated supervisor constructs the peer response. + return (response.data as { peers: AgentSessionMessageAgentSummary[] }).peers; + } finally { + client.close(); + } + } + + private async createAgentMessageListResult( + current: ActiveSessionState, + peers?: AgentSessionMessageAgentSummary[], + ): Promise { + peers ??= await this.listSupervisorAgentPeers(); const localAgents = this.listTargetableSessionStates(current).map((state) => this.createAgentMessageAgentSummary(state), ); @@ -5355,28 +5370,24 @@ export class AgentDaemon { }); } const localIds = new Set(localAgents.map((agent) => agent.activeSessionId)); + const remoteAgents = peers.filter( + (peer) => !localIds.has(peer.activeSessionId) && !this.closingSessions.has(peer.activeSessionId), + ); return { current: this.createAgentSessionMessageEndpoint(current), - agents: [ - ...localAgents, - ...[...this.remoteAgentPeers.values()].filter( - (peer) => - peer.status !== "inactive" && - !localIds.has(peer.activeSessionId) && - !this.closingSessions.has(peer.activeSessionId), - ), - ], + agents: [...localAgents, ...remoteAgents], }; } private async createAgentFamilyCatalog(currentState?: ActiveSessionState): Promise { const current = currentState ?? [...this.sessions.values()].find((state) => !this.bindingSessions.has(state.activeSessionId)); - const listed = current ? await this.createAgentMessageListResult(current) : { agents: [] }; - const remotePeers = new Set(this.remoteAgentPeers.values()); + const remotePeers = current ? await this.listSupervisorAgentPeers() : []; + const listed = current ? await this.createAgentMessageListResult(current, remotePeers) : { agents: [] }; + const remotePeerSet = new Set(remotePeers); const localAgents = current - ? [this.createAgentMessageAgentSummary(current), ...listed.agents.filter((agent) => !remotePeers.has(agent))] - : listed.agents.filter((agent) => !remotePeers.has(agent)); + ? [this.createAgentMessageAgentSummary(current), ...listed.agents.filter((agent) => !remotePeerSet.has(agent))] + : listed.agents; const activePaths = new Set( localAgents.flatMap((agent) => (agent.sessionPath ? [canonicalSessionPath(agent.sessionPath)] : [])), ); @@ -5417,7 +5428,7 @@ export class AgentDaemon { ...(agent.sessionPath ? { sessionPath: canonicalSessionPath(agent.sessionPath) } : {}), }); }; - for (const peer of this.remoteAgentPeers.values()) addAgent(peer); + for (const peer of remotePeers) addAgent(peer); for (const agent of localAgents) addAgent(agent); for (const state of this.sessions.values()) { const entry = byId.get(state.runtime.session.sessionId); diff --git a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts index 1613538a06..cfeac408b4 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts @@ -66,9 +66,9 @@ export const DAEMON_COMMAND_ENVELOPE_MIN_PROTOCOL_VERSION = 7; // Revision 19 adds daemon-held session input pauses. // Revision 20 lets cancellation target a prompt the session owns but has not started. // Revision 21 adds capability-gated, session-scoped ACP MCP server replacement. -// Revision 22 scopes ACP MCP replacement and cleanup to a connection owner. -export const DAEMON_SCHEMA_REVISION = 22; -export const DAEMON_SCHEMA_ID = "protocol-7-schema-22-4d515169dc6b"; +// Revision 23 lets workers query the supervisor agent roster on demand. +export const DAEMON_SCHEMA_REVISION = 23; +export const DAEMON_SCHEMA_ID = "protocol-7-schema-23-649fe649d15e"; export type DaemonProtocolName = typeof DAEMON_PROTOCOL_NAME; export type DaemonProtocolVersion = number; @@ -377,6 +377,7 @@ export type DaemonCommand = includeClientOwned?: boolean; } | DaemonSavedSessionListCommand + | { id?: string; type: "list_agent_peers"; workerToken: string } | ({ id?: string; type: "create"; @@ -712,11 +713,13 @@ const SESSION_INPUT_PAUSE_COMMAND = { minSchemaRevision: 19, capability: "session_input_pause", } as const; +const AGENT_PEER_LIST_COMMAND = { minProtocol: 7, minSchemaRevision: 23 } as const; export const DAEMON_COMMAND_COMPATIBILITY = { ack_result: LEGACY_DAEMON_COMMAND, list: LEGACY_DAEMON_COMMAND, list_saved_sessions: LEGACY_DAEMON_COMMAND, + list_agent_peers: AGENT_PEER_LIST_COMMAND, create: LEGACY_DAEMON_COMMAND, attach: LEGACY_DAEMON_COMMAND, reattach: LEGACY_DAEMON_COMMAND, @@ -1106,6 +1109,7 @@ const READ_ONLY_DAEMON_COMMANDS: ReadonlySet = new Set([ "ack_result", "list", "list_saved_sessions", + "list_agent_peers", "attach", "reattach", "agent_messages_status", diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 1d8070d1c3..6c2168c0fe 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -636,7 +636,6 @@ export class DaemonSupervisor { private commandJournal!: CommandRecoveryJournal; private readonly streamReconstructor = new CompactAssistantStreamReconstructor(); private readonly compactCatchupInProgress = new Set(); - private agentPeerSyncQueue: Promise = Promise.resolve(); private readonly pendingSessionNames = new Set(); private readonly catalog: DaemonCatalogClient; private readonly settingsManager: SettingsManager; @@ -735,7 +734,6 @@ export class DaemonSupervisor { if (adoptionFailed) { throw adoptionFailure; } - await this.syncAgentPeers().catch((error) => this.log(`Could not synchronize agent peers: ${String(error)}`)); for (const worker of this.workers.values()) { this.scheduleOwnedWorkerCleanup(worker); } @@ -1532,6 +1530,22 @@ export class DaemonSupervisor { return undefined; case "list": return this.handleList(client, command); + case "list_agent_peers": { + const requester = [...this.workers.values()].find( + (worker) => worker.descriptor.authenticationToken === command.workerToken, + ); + if (!requester) throw new Error("Worker authentication failed"); + const peers = [...this.workers.values()] + .filter( + (worker) => + worker !== requester && this.isLiveWorker(worker) && worker.descriptor.lifecycle === "ready", + ) + .flatMap((worker) => { + const root = worker.summaries.get(worker.descriptor.rootActiveSessionId); + return root ? [this.agentPeerSummary(root)] : []; + }); + return success(command.id, command.type, { peers }); + } case "list_saved_sessions": return this.handleSavedSessionList(client, command); case "create": { @@ -1547,7 +1561,6 @@ export class DaemonSupervisor { const response = await this.forwardToWorker(worker, withoutSupervisorCreateFields(command)); if (response.success && isSessionSummary(response.data)) { await this.refreshWorkerSummaries(worker); - await this.syncAgentPeers().catch(() => undefined); return { ...response, id: command.id, data: this.publicSummary(worker, response.data) }; } return responseWithId(response, command.id); @@ -2160,7 +2173,6 @@ export class DaemonSupervisor { .filter((worker) => !this.isWorkerStopping(worker)) .map((worker) => this.refreshWorkerSummaries(worker).catch(() => undefined)), ); - await this.syncAgentPeers().catch((error) => this.log(`Could not synchronize agent peers: ${String(error)}`)); const clientOwnedWorkers = [...this.workers.values()].filter((worker) => !this.isVisibleWorker(worker)); // Stopping workers stay listed (with an honest workerState) because this // list also feeds busy-daemon safety checks in daemon-launch. @@ -2337,7 +2349,6 @@ export class DaemonSupervisor { this.invalidateWorkerSessionInputPauses(worker, "Session worker stopped while input was paused"); this.workers.delete(worker.descriptor.workerId); this.deleteWorkerDescriptor(worker); - await this.syncAgentPeers().catch(() => undefined); return true; } // Fail fast before waiting on anything: only a confirmed-dead process is @@ -2389,7 +2400,6 @@ export class DaemonSupervisor { } worker.launchEnv = undefined; worker.transientCreateCommand = undefined; - await this.syncAgentPeers().catch((error) => this.log(`Could not synchronize agent peers: ${String(error)}`)); } private async launchWorker( @@ -2571,7 +2581,6 @@ export class DaemonSupervisor { worker.launchEnv = undefined; worker.transientCreateCommand = undefined; } - await this.syncAgentPeers().catch((error) => this.log(`Could not synchronize agent peers: ${String(error)}`)); this.broadcastHeartbeatsChanged(); return worker; } catch (error) { @@ -2798,7 +2807,6 @@ export class DaemonSupervisor { worker.descriptor.lifecycle = "recovering"; worker.descriptor.lastError = error.message; this.persistWorker(worker); - void this.syncAgentPeers().catch(() => undefined); void this.recoverWorker(worker); } @@ -2851,7 +2859,6 @@ export class DaemonSupervisor { worker.descriptor.lifecycle = "recovering"; worker.descriptor.lastError = disconnectError.message; this.persistWorker(worker); - void this.syncAgentPeers().catch(() => undefined); void this.recoverWorker(worker); return; } @@ -3079,9 +3086,6 @@ export class DaemonSupervisor { worker.descriptor.lifecycle = "ready"; worker.descriptor.consecutiveFailures = 0; this.persistWorker(worker); - await this.syncAgentPeers().catch((error) => - this.log(`Could not synchronize agent peers after worker recovery: ${String(error)}`), - ); this.broadcastHeartbeatsChanged(); return; } catch (error) { @@ -3110,7 +3114,6 @@ export class DaemonSupervisor { worker.descriptor.lifecycle = "failed"; worker.descriptor.lastError = "Waiting for a client with fresh runtime context"; this.persistWorker(worker); - await this.syncAgentPeers().catch(() => undefined); return; } const safeToKillWorkerProcess = @@ -3145,7 +3148,6 @@ export class DaemonSupervisor { } worker.descriptor.lifecycle = "failed"; this.persistWorker(worker); - await this.syncAgentPeers().catch(() => undefined); this.log(`Worker ${worker.descriptor.workerId} failed after three recovery attempts`); })().finally(() => { worker.recovery = undefined; @@ -3420,35 +3422,6 @@ export class DaemonSupervisor { ); } - private syncAgentPeers(): Promise { - const sync = this.agentPeerSyncQueue - .catch(() => undefined) - .then(async () => { - const readyWorkers = [...this.workers.values()].filter( - (worker): worker is ResidentWorker & { client: DaemonWorkerClient } => - this.isLiveWorker(worker) && worker.descriptor.lifecycle === "ready" && worker.client !== undefined, - ); - await Promise.all( - readyWorkers.map(async (worker) => { - const peers = [ - ...readyWorkers - .filter((candidate) => candidate !== worker) - .flatMap((candidate) => { - const root = candidate.summaries.get(candidate.descriptor.rootActiveSessionId); - return root ? [this.agentPeerSummary(root)] : []; - }), - ]; - const response = await worker.client.requestWorker({ type: "worker_sync_agent_peers", peers }, 5000); - if (!response.success) { - throw new Error(response.error); - } - }), - ); - }); - this.agentPeerSyncQueue = sync; - return sync; - } - private isVisibleWorker(worker: ResidentWorker): boolean { return worker.descriptor.ownerClientId === undefined; } @@ -4552,17 +4525,13 @@ export class DaemonSupervisor { this.writeSerialized(client, publicPayload); } if (outboundType === "session_replaced" || outboundType === "session_closed") { - void this.refreshWorkerSummaries(worker) - .then(() => this.syncAgentPeers()) - .catch(() => undefined); + void this.refreshWorkerSummaries(worker).catch(() => undefined); } else if ( sessionEventType === "turn_start" || sessionEventType === "turn_end" || sessionEventType === "rlm_child_update" ) { - void this.refreshWorkerSummaries(worker) - .then(() => this.syncAgentPeers()) - .catch(() => undefined); + void this.refreshWorkerSummaries(worker).catch(() => undefined); } if ( decodedOutbound?.type === "session_closed" && @@ -4578,7 +4547,6 @@ export class DaemonSupervisor { this.invalidateWorkerSessionInputPauses(worker, "Session worker stopped while input was paused"); this.workers.delete(worker.descriptor.workerId); this.deleteWorkerDescriptor(worker); - void this.syncAgentPeers().catch(() => undefined); } } } @@ -5143,7 +5111,6 @@ export class DaemonSupervisor { this.deleteWorkerDescriptor(worker); } if (!this.shuttingDown) { - void this.syncAgentPeers().catch(() => undefined); this.broadcastHeartbeatsChanged(); } } diff --git a/packages/coding-agent/src/modes/daemon/daemon-worker-protocol.ts b/packages/coding-agent/src/modes/daemon/daemon-worker-protocol.ts index 31762531e0..7ce97bc04e 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-worker-protocol.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-worker-protocol.ts @@ -1,9 +1,5 @@ import { closeSync, readFileSync } from "node:fs"; -import type { - AgentSessionMessageAgentSummary, - AgentSessionMessageDeliveryMode, - AgentSessionMessageSender, -} from "../../core/agent-messages.js"; +import type { AgentSessionMessageDeliveryMode, AgentSessionMessageSender } from "../../core/agent-messages.js"; import type { IdleEvictionMinutes } from "../../core/session-action-store.js"; export { SESSION_LEASE_OWNER_ID_ENV, SESSION_LEASES_ENABLED_ENV } from "../../core/session-lease.js"; @@ -70,7 +66,6 @@ export type DaemonWorkerCommand = supportsExtensionUi?: boolean; } | { id?: string; type: "worker_unsubscribe"; activeSessionId: string } - | { id?: string; type: "worker_sync_agent_peers"; peers: AgentSessionMessageAgentSummary[] } | { id?: string; type: "worker_archive_and_shutdown" } | { id?: string; diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index 1a2df0cbb5..1828394308 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -582,120 +582,26 @@ describe("daemon mode helpers", () => { } }); - it("keeps fresh local rows over stale synced peers in the family catalog", async () => { - const daemon = new AgentDaemon("/tmp/prime-agent-local-precedence.sock", { - defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, - createRuntime: vi.fn(), - }); - const state = makeState("local-active"); - state.runtime = { - ...state.runtime, - cwd: "/tmp", - metadata: { kind: "top-level", createdAt: 1 }, - session: { - sessionId: "shared-session", - sessionName: "fresh-local", - isSessionActive: false, - isStreaming: false, - unfinishedActionCount: 0, - hasRunningRlmChildren: () => false, - }, - } as never; - const internals = daemon as unknown as { - sessions: Map; - remoteAgentPeers: Map>; - createAgentFamilyCatalog(): Promise>; - }; - internals.sessions.set(state.activeSessionId, state); - internals.remoteAgentPeers.set("stale-active", { - activeSessionId: "stale-active", - sessionId: "shared-session", - sessionName: "stale-peer", - runtimeKind: "top-level", - cwd: "/tmp", - isStreaming: false, - unfinishedActionCount: 0, - }); - const listAll = vi.spyOn(SessionManager, "listAll").mockResolvedValue([]); - try { - expect(await internals.createAgentFamilyCatalog()).toContainEqual( - expect.objectContaining({ id: "shared-session", name: "fresh-local" }), - ); - } finally { - listAll.mockRestore(); - } - }); - - it("canonicalizes symlinked paths in the family catalog and name reservations", async () => { + it("canonicalizes symlinked parent paths in name reservations", () => { const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-family-catalog-paths-")); try { const realDir = join(tempDir, "real"); const aliasDir = join(tempDir, "alias"); mkdirSync(realDir); symlinkSync(realDir, aliasDir, "dir"); - const parentPath = join(realDir, "parent.jsonl"); - writeFileSync(parentPath, ""); - const daemon = new AgentDaemon(join(tempDir, "daemon.sock"), { - defaultSessionConfig: { agentDir: tempDir, cwd: tempDir, sessionDir: tempDir }, - createRuntime: vi.fn(), - }); - const parent = makeState("parent"); - parent.runtime = { - ...parent.runtime, - cwd: tempDir, - metadata: { kind: "top-level", createdAt: 1 }, - session: { - sessionId: "session-parent", - sessionName: "parent", - sessionFile: parentPath, - sessionManager: { getSessionArtifactDir: () => undefined }, - rlmDepth: 0, - isStreaming: false, - isSessionActive: false, - unfinishedActionCount: 0, - hasRunningRlmChildren: () => false, - }, - } as never; - const child = { - activeSessionId: "child-active", - sessionId: "session-child", - sessionName: "child", - runtimeKind: "subagent", - cwd: tempDir, - isStreaming: false, - unfinishedActionCount: 0, - parentSessionPath: join(aliasDir, "parent.jsonl"), - rlmDepth: 1, - status: "idle", - }; - const internals = daemon as unknown as { - sessions: Map; - remoteAgentPeers: Map; - createAgentFamilyRoster(state: ActiveSessionState): Promise<{ entries: Array<{ id: string }> }>; - }; - internals.sessions.set(parent.activeSessionId, parent); - internals.remoteAgentPeers.set(child.activeSessionId, child); - const listAll = vi.spyOn(SessionManager, "listAll").mockResolvedValue([]); - try { - expect(await internals.createAgentFamilyRoster(parent)).toMatchObject({ - entries: [expect.objectContaining({ id: "session-child" })], - }); - expect( - sessionNameReservationKey({ - name: "worker", - depth: 1, - parentSessionPath: parentPath, - }), - ).toBe( - sessionNameReservationKey({ - name: "worker", - depth: 1, - parentSessionPath: join(aliasDir, "parent.jsonl"), - }), - ); - } finally { - listAll.mockRestore(); - } + expect( + sessionNameReservationKey({ + name: "worker", + depth: 1, + parentSessionPath: join(realDir, "parent.jsonl"), + }), + ).toBe( + sessionNameReservationKey({ + name: "worker", + depth: 1, + parentSessionPath: join(aliasDir, "parent.jsonl"), + }), + ); } finally { rmSync(tempDir, { recursive: true, force: true }); } @@ -1505,7 +1411,7 @@ describe("daemon mode helpers", () => { const sendRemoteAgentSessionMessage = vi.fn().mockResolvedValue(receipt); const internals = daemon as unknown as { sessions: Map; - remoteAgentPeers: Map>; + listSupervisorAgentPeers: ReturnType; createAgentMessageListResult( current: ActiveSessionState, ): Promise<{ agents: Array<{ activeSessionId: string }> }>; @@ -1518,15 +1424,17 @@ describe("daemon mode helpers", () => { }): Promise; }; internals.sessions.set(source.activeSessionId, source); - internals.remoteAgentPeers.set(remoteSelector, { - activeSessionId: remoteSelector, - sessionId: "session-remote", - sessionName: "Remote", - runtimeKind: "top-level", - cwd: "/tmp/remote", - isStreaming: false, - sessionActions: { queuedCount: 0, steering: [], followUps: [] }, - }); + internals.listSupervisorAgentPeers = vi.fn(async () => [ + { + activeSessionId: remoteSelector, + sessionId: "session-remote", + sessionName: "Remote", + runtimeKind: "top-level", + cwd: "/tmp/remote", + isStreaming: false, + unfinishedActionCount: 0, + }, + ]); internals.sendRemoteAgentSessionMessage = sendRemoteAgentSessionMessage; expect((await internals.createAgentMessageListResult(source)).agents).toContainEqual( diff --git a/packages/coding-agent/test/daemon-supervisor-lazy-subagents.test.ts b/packages/coding-agent/test/daemon-supervisor-lazy-subagents.test.ts index 7ca17f5c4d..58d2187a7d 100644 --- a/packages/coding-agent/test/daemon-supervisor-lazy-subagents.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-lazy-subagents.test.ts @@ -16,7 +16,6 @@ import { DaemonSupervisor } from "../src/modes/daemon/daemon-supervisor.js"; interface SupervisorInternals { workers: Map; refreshWorkerSummaries(worker: WorkerFixture): Promise; - syncAgentPeers(): Promise; findSummaryInWorker(worker: WorkerFixture, selector: string): SessionSummary | undefined; createOrReuseWorker( clientId: string, @@ -84,7 +83,7 @@ function worker(workerId: string, summaries: SessionSummary[] = []): WorkerFixtu }, client: { request: vi.fn(), - requestWorker: vi.fn(async () => ({ type: "response", command: "worker_sync_agent_peers", success: true })), + requestWorker: vi.fn(), }, summaries: new Map(summaries.map((entry) => [entry.activeSessionId ?? entry.id, entry])), }; @@ -645,7 +644,7 @@ describe("daemon supervisor passive subagent topology", () => { await expect(first).resolves.toBe(launched); }); - it("retains passive worker summaries but syncs only roots to cross-worker peer maps", async () => { + it("returns only other worker roots for an authenticated peer query", async () => { const directory = mkdtempSync(join(tmpdir(), "prime-supervisor-passive-peers-")); tempDirs.push(directory); const supervisor = new DaemonSupervisor(join(directory, "daemon.sock"), { @@ -687,11 +686,17 @@ describe("daemon supervisor passive subagent topology", () => { }); first.summaries.set(first.descriptor.rootActiveSessionId, firstRoot); - await supervisor.syncAgentPeers(); - const secondPeerCommand = second.client.requestWorker.mock.calls[0]?.[0] as - | { peers: AgentSessionMessageAgentSummary[] } - | undefined; - expect(secondPeerCommand?.peers).toEqual([ + await expect( + supervisor.handleCommand({}, { type: "list_agent_peers", workerToken: "invalid-token" }), + ).rejects.toThrow("Worker authentication failed"); + const response = (await supervisor.handleCommand( + {}, + { + type: "list_agent_peers", + workerToken: second.descriptor.authenticationToken, + }, + )) as { data: { peers: AgentSessionMessageAgentSummary[] } }; + expect(response.data.peers).toEqual([ expect.objectContaining({ activeSessionId: "first-root-active", sessionId: "first-root-session", diff --git a/packages/coding-agent/test/daemon-supervisor-monitor.test.ts b/packages/coding-agent/test/daemon-supervisor-monitor.test.ts index 6310feeffb..6a2c987547 100644 --- a/packages/coding-agent/test/daemon-supervisor-monitor.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-monitor.test.ts @@ -155,7 +155,6 @@ interface DeferredRecoveryHarness { shuttingDown: boolean; assertRecoveryAllowed: ReturnType; persistWorker: ReturnType; - syncAgentPeers: ReturnType; recoverWorker: ReturnType; handleWorkerClose(worker: DeferredRecoveryWorker, client: object, error: Error): Promise; deferWorkerRecovery(worker: DeferredRecoveryWorker, error: Error): void; @@ -610,7 +609,6 @@ describe("daemon worker supervisor monitoring", () => { } }), connectWorker, - syncAgentPeers: vi.fn(async () => undefined), log: vi.fn(), }) as { launchWorker(command: { type: "create"; config: { cwd: string; agentDir: string } }): Promise; @@ -673,7 +671,6 @@ describe("daemon worker supervisor monitoring", () => { connectWorker, subscribeWorker: vi.fn(async () => undefined), refreshWorkerSummaries: vi.fn(async () => undefined), - syncAgentPeers: vi.fn(async () => undefined), log: vi.fn(), }) as { launchWorker(command: { @@ -736,7 +733,6 @@ describe("daemon worker supervisor monitoring", () => { } Reflect.apply(persistWorker, this, [worker]); }), - syncAgentPeers: vi.fn(async () => undefined), log: vi.fn(), }) as { launchWorker(command: { type: "create"; config: { cwd: string; agentDir: string } }): Promise; @@ -797,7 +793,6 @@ describe("daemon worker supervisor monitoring", () => { Reflect.apply(persistWorker, this, [worker]); }), deferWorkerRecovery, - syncAgentPeers: vi.fn(async () => undefined), log: vi.fn(), }) as { launchWorker( @@ -883,7 +878,6 @@ describe("daemon worker supervisor monitoring", () => { connectWorker, stopWorker: controlledStopWorker, deferWorkerRecovery, - syncAgentPeers: vi.fn(async () => undefined), log: vi.fn(), }) as { shuttingDown: boolean; @@ -1124,7 +1118,6 @@ describe("daemon worker supervisor monitoring", () => { shuttingDown: false, assertRecoveryAllowed, persistWorker, - syncAgentPeers: vi.fn(async () => undefined), recoverWorker, }) as DeferredRecoveryHarness; @@ -1183,7 +1176,6 @@ describe("daemon worker supervisor monitoring", () => { shuttingDown: false, assertRecoveryAllowed, persistWorker, - syncAgentPeers: vi.fn(async () => undefined), recoverWorker, }) as DeferredRecoveryHarness; @@ -1254,7 +1246,6 @@ describe("daemon worker supervisor monitoring", () => { shuttingDown: false, assertRecoveryAllowed, persistWorker, - syncAgentPeers: vi.fn(async () => undefined), recoverWorker, }) as DeferredRecoveryHarness; @@ -1299,7 +1290,6 @@ describe("daemon worker supervisor monitoring", () => { resolveAssertion = resolve; }); const persistWorker = vi.fn(); - const syncAgentPeers = vi.fn(async () => undefined); const recoverWorker = vi.fn(async () => undefined); const supervisor = Object.assign(Object.create(DaemonSupervisor.prototype), { ...createSupervisorSnapshotState(), @@ -1307,7 +1297,6 @@ describe("daemon worker supervisor monitoring", () => { shuttingDown: false, assertRecoveryAllowed: vi.fn(() => assertion), persistWorker, - syncAgentPeers, recoverWorker, }) as DeferredRecoveryHarness; @@ -1319,7 +1308,6 @@ describe("daemon worker supervisor monitoring", () => { expect(worker.descriptor.lifecycle).toBe("ready"); expect(worker.descriptor.lastError).toBeUndefined(); expect(persistWorker).not.toHaveBeenCalled(); - expect(syncAgentPeers).not.toHaveBeenCalled(); expect(recoverWorker).not.toHaveBeenCalled(); }, ); @@ -1494,7 +1482,6 @@ describe("daemon worker supervisor monitoring", () => { streamReconstructor: { observe: vi.fn() }, invalidateWorkerSnapshot: vi.fn(), refreshWorkerSummaries: vi.fn(async () => undefined), - syncAgentPeers: vi.fn(async () => undefined), persistWorkerStopTombstone: vi.fn(), deleteWorkerDescriptor, broadcastHeartbeatsChanged: vi.fn(), @@ -1591,7 +1578,6 @@ describe("daemon worker supervisor monitoring", () => { recoverUncertainWorkerOperations: ReturnType; launchWorker: ReturnType; persistWorker: ReturnType; - syncAgentPeers: ReturnType; assertRecoveryAllowed: ReturnType; recoverWorker(worker: RecoveryWorker): Promise; }; @@ -1613,7 +1599,6 @@ describe("daemon worker supervisor monitoring", () => { recoverUncertainWorkerOperations: vi.fn(async () => {}), launchWorker: vi.fn(async () => worker), persistWorker: vi.fn(), - syncAgentPeers: vi.fn(async () => {}), assertRecoveryAllowed: vi.fn(async () => {}), }) as RecoveryHarness; @@ -1686,7 +1671,6 @@ describe("daemon worker supervisor monitoring", () => { processIdentity: vi.fn(() => "gone"), recoverUncertainWorkerOperations, deleteWorkerDescriptor, - syncAgentPeers: vi.fn(async () => {}), }) as { reclaimStaleWorkerRegistration(target: typeof worker): Promise; }; @@ -1747,7 +1731,6 @@ describe("daemon worker supervisor monitoring", () => { recoverUncertainWorkerOperations: ReturnType; launchWorker: ReturnType; persistWorker: ReturnType; - syncAgentPeers: ReturnType; broadcastHeartbeatsChanged: ReturnType; log: ReturnType; assertRecoveryAllowed: ReturnType; @@ -1773,7 +1756,6 @@ describe("daemon worker supervisor monitoring", () => { recoverUncertainWorkerOperations: vi.fn(async () => {}), launchWorker: vi.fn(async () => worker), persistWorker: vi.fn(), - syncAgentPeers: vi.fn(async () => {}), log: vi.fn(), assertRecoveryAllowed: vi.fn(async () => {}), }) as RecoveryHarness; @@ -1788,75 +1770,6 @@ describe("daemon worker supervisor monitoring", () => { expect(worker.descriptor.lifecycle).toBe("failed"); }); - it("keeps a recovered worker ready when peer synchronization fails", async () => { - vi.useFakeTimers(); - type RecoveryWorker = { - descriptor: { - workerId: string; - pid: number; - processStartId?: string; - rootActiveSessionId: string; - createCommand: { type: "create" }; - lifecycle?: string; - consecutiveFailures: number; - }; - intentionalStop: boolean; - stopRevision: number; - recovery?: Promise; - }; - type RecoveryHarness = { - workers: Map; - shuttingDown: boolean; - connectWorker: ReturnType; - subscribeWorker: ReturnType; - refreshWorkerSummaries: ReturnType; - recoverUncertainWorkerOperations: ReturnType; - launchWorker: ReturnType; - persistWorker: ReturnType; - syncAgentPeers: ReturnType; - log: ReturnType; - assertRecoveryAllowed: ReturnType; - recoverWorker(worker: RecoveryWorker): Promise; - }; - const worker: RecoveryWorker = { - descriptor: { - workerId: "worker-peer-sync-failure", - pid: process.pid, - rootActiveSessionId: "active-1", - createCommand: { type: "create" }, - consecutiveFailures: 1, - }, - intentionalStop: false, - stopRevision: 0, - }; - const supervisor = Object.assign(Object.create(DaemonSupervisor.prototype), { - workers: new Map([[worker.descriptor.workerId, worker]]), - shuttingDown: false, - connectWorker: vi.fn(async () => ({})), - subscribeWorker: vi.fn(async () => {}), - refreshWorkerSummaries: vi.fn(async () => {}), - recoverUncertainWorkerOperations: vi.fn(async () => {}), - launchWorker: vi.fn(async () => worker), - persistWorker: vi.fn(), - syncAgentPeers: vi.fn(async () => { - throw new Error("peer unavailable"); - }), - broadcastHeartbeatsChanged: vi.fn(), - log: vi.fn(), - assertRecoveryAllowed: vi.fn(async () => {}), - }) as RecoveryHarness; - - const recovery = supervisor.recoverWorker(worker); - await vi.advanceTimersByTimeAsync(250); - await recovery; - - expect(supervisor.connectWorker).toHaveBeenCalledOnce(); - expect(supervisor.recoverUncertainWorkerOperations).not.toHaveBeenCalled(); - expect(supervisor.launchWorker).not.toHaveBeenCalled(); - expect(worker.descriptor.lifecycle).toBe("ready"); - expect(worker.descriptor.consecutiveFailures).toBe(0); - }); - it("reports a stop-tombstoned worker as stopping, not ready", () => { const worker = { descriptor: { @@ -1933,7 +1846,6 @@ describe("daemon worker supervisor monitoring", () => { ]), clients: new Set(), refreshWorkerSummaries: vi.fn(async () => {}), - syncAgentPeers: vi.fn(async () => {}), log: vi.fn(), }) as { handleList( @@ -2284,7 +2196,6 @@ describe("daemon worker supervisor monitoring", () => { worker.descriptor.stopRequestedAt = undefined; }), deleteWorkerDescriptor: vi.fn(), - syncAgentPeers: vi.fn(async () => {}), broadcastHeartbeatsChanged: vi.fn(), log: vi.fn(), reportCleanupFailure: vi.fn(), @@ -2343,7 +2254,6 @@ describe("daemon worker supervisor monitoring", () => { worker.descriptor.stopRequestedAt = undefined; }), deleteWorkerDescriptor: vi.fn(), - syncAgentPeers: vi.fn(async () => {}), broadcastHeartbeatsChanged: vi.fn(), log: vi.fn(), reportCleanupFailure: vi.fn(), @@ -2395,7 +2305,6 @@ describe("daemon worker supervisor monitoring", () => { persistWorker: vi.fn(), persistWorkerStopTombstone: vi.fn(), scheduleWorkerStopFinalization: vi.fn(), - syncAgentPeers: vi.fn(async () => {}), broadcastHeartbeatsChanged: vi.fn(), }) as unknown as { stopWorker(target: object, removeDescriptor: boolean, force?: boolean): Promise; diff --git a/packages/coding-agent/test/suite/regressions/4602-snapshot-transfer-idempotency.test.ts b/packages/coding-agent/test/suite/regressions/4602-snapshot-transfer-idempotency.test.ts index b982eea2b8..deb503d15c 100644 --- a/packages/coding-agent/test/suite/regressions/4602-snapshot-transfer-idempotency.test.ts +++ b/packages/coding-agent/test/suite/regressions/4602-snapshot-transfer-idempotency.test.ts @@ -581,19 +581,16 @@ describe("ENG-4602 snapshot transfer containment", () => { }); const recoverWorker = vi.fn(async () => {}); const persistWorker = vi.fn(); - const syncAgentPeers = vi.fn(async () => {}); const assertRecoveryAllowed = vi.fn(async () => {}); const internals = supervisor as unknown as { workers: Map; recoverWorker: typeof recoverWorker; persistWorker: typeof persistWorker; - syncAgentPeers: typeof syncAgentPeers; assertRecoveryAllowed: typeof assertRecoveryAllowed; handleWorkerFrame(worker: WorkerHarness, frame: PrivateFrame): void; }; internals.recoverWorker = recoverWorker; internals.persistWorker = persistWorker; - internals.syncAgentPeers = syncAgentPeers; internals.assertRecoveryAllowed = assertRecoveryAllowed; const frames = snapshotFrames([{ role: "user", content: "stable", timestamp: 1 }]); diff --git a/packages/coding-agent/test/suite/regressions/4685-daemon-client-modes.test.ts b/packages/coding-agent/test/suite/regressions/4685-daemon-client-modes.test.ts index a0da85d256..1dee7be89c 100644 --- a/packages/coding-agent/test/suite/regressions/4685-daemon-client-modes.test.ts +++ b/packages/coding-agent/test/suite/regressions/4685-daemon-client-modes.test.ts @@ -149,22 +149,16 @@ async function runRpc( } describe("ENG-4685 daemon-backed client modes", () => { - it("commits owned-worker promotion before best-effort peer synchronization", async () => { + it("commits owned-worker promotion once", async () => { const client = { id: "client-1" } as DaemonSocketClient; const worker = { descriptor: { ownerClientId: "protocol-client" }, launchEnv: { TEST: "value" }, }; const persistWorker = vi.fn(); - const syncAgentPeers = vi.fn(async () => { - throw new Error("peer unavailable"); - }); - const log = vi.fn(); const supervisor = Object.assign(Object.create(DaemonSupervisor.prototype), { protocolClientId: () => "protocol-client", persistWorker, - syncAgentPeers, - log, }) as { promoteOwnedWorker(client: DaemonSocketClient, resident: typeof worker): Promise; }; @@ -175,8 +169,6 @@ describe("ENG-4685 daemon-backed client modes", () => { expect(worker.descriptor.ownerClientId).toBeUndefined(); expect(worker.launchEnv).toBeUndefined(); expect(persistWorker).toHaveBeenCalledOnce(); - expect(syncAgentPeers).toHaveBeenCalledOnce(); - expect(log).toHaveBeenCalledWith(expect.stringContaining("peer unavailable")); }); it("rolls back owned-worker promotion when persistence fails", async () => { From e3964d0a5735af37c97a7e3904f18c5ed25c5d7b Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 25 Aug 2026 05:55:47 +0200 Subject: [PATCH 41/53] Derive scoped heartbeats on demand --- .../.changes/derive-scoped-heartbeats.md | 1 + .../components/heartbeat-manager.ts | 65 ++++++++++--------- .../src/modes/interactive/interactive-mode.ts | 37 ++++------- .../test/heartbeat-manager.test.ts | 32 +++++++-- .../interactive-heartbeat-management.test.ts | 23 +++---- .../test/interactive-mode-status.test.ts | 25 +++++-- 6 files changed, 102 insertions(+), 81 deletions(-) create mode 100644 packages/coding-agent/.changes/derive-scoped-heartbeats.md diff --git a/packages/coding-agent/.changes/derive-scoped-heartbeats.md b/packages/coding-agent/.changes/derive-scoped-heartbeats.md new file mode 100644 index 0000000000..d917a1484e --- /dev/null +++ b/packages/coding-agent/.changes/derive-scoped-heartbeats.md @@ -0,0 +1 @@ +- Kept heartbeat lists current when session or subagent scope changes. diff --git a/packages/coding-agent/src/modes/interactive/components/heartbeat-manager.ts b/packages/coding-agent/src/modes/interactive/components/heartbeat-manager.ts index 0c6b8efea9..28e9344ec7 100644 --- a/packages/coding-agent/src/modes/interactive/components/heartbeat-manager.ts +++ b/packages/coding-agent/src/modes/interactive/components/heartbeat-manager.ts @@ -14,6 +14,7 @@ const HEARTBEAT_SCROLL_INDICATOR_ROWS = 1; type HeartbeatManagerMode = { type: "list" } | { type: "actions"; heartbeatId: string; selectedIndex: number }; export interface HeartbeatManagerOptions { + getHeartbeats: () => readonly AgentConnectionHeartbeat[]; getRows: () => number; onAction: (heartbeat: AgentConnectionHeartbeat, action: AgentHeartbeatManagementAction) => Promise; onClose: () => void; @@ -21,19 +22,13 @@ export interface HeartbeatManagerOptions { } export class HeartbeatManagerComponent implements Component, Focusable { - private heartbeats: AgentConnectionHeartbeat[] = []; - private selectedIndex = 0; + private selectedHeartbeatId: string | undefined; private mode: HeartbeatManagerMode = { type: "list" }; private busy = false; private error: string | undefined; private _focused = false; - constructor( - heartbeats: readonly AgentConnectionHeartbeat[], - private readonly options: HeartbeatManagerOptions, - ) { - this.setHeartbeats(heartbeats); - } + constructor(private readonly options: HeartbeatManagerOptions) {} get focused(): boolean { return this._focused; @@ -45,22 +40,13 @@ export class HeartbeatManagerComponent implements Component, Focusable { invalidate(): void {} - setHeartbeats(heartbeats: readonly AgentConnectionHeartbeat[]): void { - const selectedId = this.heartbeats[this.selectedIndex]?.job.id; - this.heartbeats = [...heartbeats].sort((left, right) => { + private get heartbeats(): AgentConnectionHeartbeat[] { + return [...this.options.getHeartbeats()].sort((left, right) => { const sessionOrder = this.sessionLabel(left).localeCompare(this.sessionLabel(right)); if (sessionOrder !== 0) return sessionOrder; if (left.job.source !== right.job.source) return left.job.source === "heartbeat" ? -1 : 1; return left.job.createdAt.localeCompare(right.job.createdAt); }); - const nextIndex = selectedId - ? this.heartbeats.findIndex((heartbeat) => heartbeat.job.id === selectedId) - : this.selectedIndex; - this.selectedIndex = Math.max(0, Math.min(nextIndex < 0 ? 0 : nextIndex, this.heartbeats.length - 1)); - if (this.mode.type !== "list" && !this.findHeartbeat(this.mode.heartbeatId)) { - this.mode = { type: "list" }; - } - this.options.requestRender(); } handleInput(data: string): void { @@ -98,6 +84,14 @@ export class HeartbeatManagerComponent implements Component, Focusable { } render(width: number): string[] { + const heartbeats = this.heartbeats; + if (!heartbeats.some((heartbeat) => heartbeat.job.id === this.selectedHeartbeatId)) { + this.selectedHeartbeatId = heartbeats[0]?.job.id; + } + if (this.mode.type !== "list") { + const heartbeatId = this.mode.heartbeatId; + if (!heartbeats.some((heartbeat) => heartbeat.job.id === heartbeatId)) this.mode = { type: "list" }; + } const panel = this.mode.type === "list" ? this.createHeartbeatListPanel() : this.createActionPanel(this.mode); const safeWidth = Math.max(1, width); const panelWidth = Math.min(safeWidth, HEARTBEAT_PANEL_MAX_WIDTH); @@ -127,19 +121,21 @@ export class HeartbeatManagerComponent implements Component, Focusable { } private populateHeartbeatList(list: MenuList): void { - if (this.heartbeats.length === 0) { + const heartbeats = this.heartbeats; + if (heartbeats.length === 0) { list.addChild(new TruncatedText(theme.fg("muted", "No running or paused heartbeats"), 1, 0)); return; } + const selectedIndex = this.getSelectedIndex(heartbeats); const visibleItems = this.getListLayout().visibleItems; const startIndex = Math.max( 0, - Math.min(this.selectedIndex - Math.floor(visibleItems / 2), this.heartbeats.length - visibleItems), + Math.min(selectedIndex - Math.floor(visibleItems / 2), heartbeats.length - visibleItems), ); - const endIndex = Math.min(startIndex + visibleItems, this.heartbeats.length); + const endIndex = Math.min(startIndex + visibleItems, heartbeats.length); for (let index = startIndex; index < endIndex; index++) { - const heartbeat = this.heartbeats[index]; + const heartbeat = heartbeats[index]; if (!heartbeat) continue; const source = this.sourceLabel(heartbeat); const label = heartbeat.job.label?.trim(); @@ -152,15 +148,13 @@ export class HeartbeatManagerComponent implements Component, Focusable { primary: label || this.singleLine(heartbeat.job.prompt) || this.defaultHeartbeatName(heartbeat), secondary: details, meta: this.formatStatus(heartbeat), - selected: index === this.selectedIndex, + selected: index === selectedIndex, }), ); } - if (startIndex > 0 || endIndex < this.heartbeats.length) { - list.addChild( - new TruncatedText(theme.fg("muted", ` (${this.selectedIndex + 1}/${this.heartbeats.length})`), 1, 0), - ); + if (startIndex > 0 || endIndex < heartbeats.length) { + list.addChild(new TruncatedText(theme.fg("muted", ` (${selectedIndex + 1}/${heartbeats.length})`), 1, 0)); } } @@ -198,8 +192,11 @@ export class HeartbeatManagerComponent implements Component, Focusable { private moveSelection(delta: number): void { if (this.mode.type === "list") { - if (this.heartbeats.length === 0) return; - this.selectedIndex = Math.max(0, Math.min(this.selectedIndex + delta, this.heartbeats.length - 1)); + const heartbeats = this.heartbeats; + if (heartbeats.length === 0) return; + const selectedIndex = this.getSelectedIndex(heartbeats); + const nextIndex = Math.max(0, Math.min(selectedIndex + delta, heartbeats.length - 1)); + this.selectedHeartbeatId = heartbeats[nextIndex]?.job.id; } else { const count = this.availableActions(this.findHeartbeat(this.mode.heartbeatId)).length; this.mode = { ...this.mode, selectedIndex: Math.max(0, Math.min(this.mode.selectedIndex + delta, count - 1)) }; @@ -209,7 +206,8 @@ export class HeartbeatManagerComponent implements Component, Focusable { private async confirmSelection(): Promise { if (this.mode.type === "list") { - const heartbeat = this.heartbeats[this.selectedIndex]; + const heartbeats = this.heartbeats; + const heartbeat = heartbeats[this.getSelectedIndex(heartbeats)]; if (heartbeat) { this.mode = { type: "actions", heartbeatId: heartbeat.job.id, selectedIndex: 0 }; this.options.requestRender(); @@ -254,6 +252,11 @@ export class HeartbeatManagerComponent implements Component, Focusable { ]; } + private getSelectedIndex(heartbeats: readonly AgentConnectionHeartbeat[]): number { + const index = heartbeats.findIndex((heartbeat) => heartbeat.job.id === this.selectedHeartbeatId); + return index < 0 ? 0 : index; + } + private findHeartbeat(id: string): AgentConnectionHeartbeat | undefined { return this.heartbeats.find((heartbeat) => heartbeat.job.id === id); } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 7de598a7b4..b2f8b65c00 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -990,7 +990,6 @@ export class InteractiveMode { private connectionState: AgentConnectionState | undefined; private connectionResourceSnapshot: AgentConnectionResourceSnapshot | undefined; private heartbeatCatalog: AgentConnectionHeartbeat[] = []; - private heartbeats: AgentConnectionHeartbeat[] = []; private heartbeatRefreshPromise: Promise | undefined; private heartbeatRefreshRequested = false; private heartbeatManager: HeartbeatManagerComponent | undefined; @@ -2553,32 +2552,18 @@ export class InteractiveMode { private applyHeartbeatCatalog(heartbeats: AgentConnectionHeartbeat[]): void { this.heartbeatCatalog = heartbeats; - this.updateScopedHeartbeats(); - } - - private updateScopedHeartbeats(): void { - const heartbeats = scopeHeartbeatsToSession( - this.heartbeatCatalog, - this.connectionState, - this.subagentSnapshots.values(), - ); - if ( - heartbeats.length === this.heartbeats.length && - heartbeats.every((heartbeat, index) => heartbeat === this.heartbeats[index]) - ) { - return; - } - this.heartbeats = heartbeats; - this.heartbeatManager?.setHeartbeats(heartbeats); this.scheduleHeartbeatManagerRefresh(); this.updateSubagentSummaryLine(); this.ui.requestRender(); } + private getScopedHeartbeats(): AgentConnectionHeartbeat[] { + return scopeHeartbeatsToSession(this.heartbeatCatalog, this.connectionState, this.subagentSnapshots.values()); + } + private applyConnectionStateSnapshot(state: AgentConnectionState): void { this.bindPromptStashSession(state.sessionId); this.connectionState = state; - this.updateScopedHeartbeats(); // Don't touch contextUsageTokenBaseline: a mid-stream snapshot reflects only completed // turns (the in-flight message isn't persisted yet), so the in-flight delta must keep // accumulating. The baseline is managed at turn end (refreshConnectionContextUsage) and @@ -5931,7 +5916,6 @@ export class InteractiveMode { } private refreshSubagentSummary(): void { - this.updateScopedHeartbeats(); this.updateSubagentSummaryLine(); this.updateWorkingPulse(); this.syncWorkingLoader(); @@ -5962,7 +5946,6 @@ export class InteractiveMode { this.subagentSnapshots.clear(); this.rlmNodeId = undefined; this.updateSubagentSummaryLine(); - this.updateScopedHeartbeats(); // Clearing snapshots can drop the last running subagent; reconcile the // pulse and loader so neither lingers when nothing is in flight. this.updateWorkingPulse(); @@ -6083,11 +6066,12 @@ export class InteractiveMode { } private getTrayHeartbeatLabel(): string | undefined { - if (this.heartbeats.length === 0) { + const heartbeats = this.getScopedHeartbeats(); + if (heartbeats.length === 0) { return undefined; } - const paused = this.heartbeats.filter((heartbeat) => heartbeat.job.status === "paused").length; - const count = `${this.heartbeats.length} heartbeat${this.heartbeats.length === 1 ? "" : "s"}`; + const paused = heartbeats.filter((heartbeat) => heartbeat.job.status === "paused").length; + const count = `${heartbeats.length} heartbeat${heartbeats.length === 1 ? "" : "s"}`; const pausedLabel = paused ? ` · ${paused} paused` : ""; const shortcut = keyText("app.heartbeats.open"); return `${count}${pausedLabel}${shortcut ? ` (${shortcut})` : ""}`; @@ -9531,7 +9515,8 @@ export class InteractiveMode { this.showError(error instanceof Error ? error.message : String(error)); return; } - const manager = new HeartbeatManagerComponent(this.heartbeats, { + const manager = new HeartbeatManagerComponent({ + getHeartbeats: () => this.getScopedHeartbeats(), getRows: () => this.ui.terminal.rows, onAction: (heartbeat, action) => this.manageHeartbeat(heartbeat, action), onClose: () => this.closeHeartbeatManager(), @@ -9564,7 +9549,7 @@ export class InteractiveMode { if (!this.heartbeatManager) { return; } - const nextRunAt = this.heartbeats + const nextRunAt = this.getScopedHeartbeats() .filter((heartbeat) => heartbeat.job.status === "active" && heartbeat.job.nextRunAt) .map((heartbeat) => Date.parse(heartbeat.job.nextRunAt!)) .filter(Number.isFinite) diff --git a/packages/coding-agent/test/heartbeat-manager.test.ts b/packages/coding-agent/test/heartbeat-manager.test.ts index bc8ae2e711..58901c6c91 100644 --- a/packages/coding-agent/test/heartbeat-manager.test.ts +++ b/packages/coding-agent/test/heartbeat-manager.test.ts @@ -48,8 +48,8 @@ describe("HeartbeatManagerComponent", () => { }); it("groups user and agent heartbeats and stays within terminal width", () => { - const component = new HeartbeatManagerComponent( - [ + const component = new HeartbeatManagerComponent({ + getHeartbeats: () => [ heartbeat("user", { source: "heartbeat" }), heartbeat("agent", { source: "rlm_heartbeat", @@ -59,8 +59,11 @@ describe("HeartbeatManagerComponent", () => { lastError: "the previous delivery failed", }), ], - { getRows: () => 20, onAction: async () => {}, onClose: () => {}, requestRender: () => {} }, - ); + getRows: () => 20, + onAction: async () => {}, + onClose: () => {}, + requestRender: () => {}, + }); for (const width of [32, 48, 80]) { const lines = component.render(width); expect(lines.every((line) => visibleWidth(line) === width)).toBe(true); @@ -81,9 +84,25 @@ describe("HeartbeatManagerComponent", () => { expect(rendered.find((line) => line.includes("Esc close"))?.indexOf("Esc close")).toBe(titleColumn); }); + it("reads the current heartbeat list when rendered", () => { + let heartbeats = [heartbeat("user", { source: "heartbeat" })]; + const component = new HeartbeatManagerComponent({ + getHeartbeats: () => heartbeats, + getRows: () => 20, + onAction: async () => {}, + onClose: () => {}, + requestRender: () => {}, + }); + + expect(stripAnsi(component.render(80).join("\n"))).toContain("1 heartbeat."); + heartbeats = [heartbeat("user", { source: "heartbeat" }), heartbeat("agent", { source: "rlm_heartbeat" })]; + expect(stripAnsi(component.render(80).join("\n"))).toContain("2 heartbeats."); + }); + it("uses arrows to open and go back, and closes with escape or the toggle shortcut", () => { let closeCount = 0; - const component = new HeartbeatManagerComponent([heartbeat("user", { source: "heartbeat" })], { + const component = new HeartbeatManagerComponent({ + getHeartbeats: () => [heartbeat("user", { source: "heartbeat" })], getRows: () => 20, onAction: async () => {}, onClose: () => closeCount++, @@ -114,7 +133,8 @@ describe("HeartbeatManagerComponent", () => { it("pauses and stops individual heartbeats immediately", async () => { const actions: Array<{ id: string; action: AgentHeartbeatManagementAction }> = []; - const component = new HeartbeatManagerComponent([heartbeat("user", { source: "heartbeat" })], { + const component = new HeartbeatManagerComponent({ + getHeartbeats: () => [heartbeat("user", { source: "heartbeat" })], getRows: () => 20, onAction: async (entry, action) => { actions.push({ id: entry.job.id, action }); diff --git a/packages/coding-agent/test/interactive-heartbeat-management.test.ts b/packages/coding-agent/test/interactive-heartbeat-management.test.ts index c7923fd8db..7605c7d4d6 100644 --- a/packages/coding-agent/test/interactive-heartbeat-management.test.ts +++ b/packages/coding-agent/test/interactive-heartbeat-management.test.ts @@ -8,7 +8,6 @@ import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js"; interface HeartbeatManagementHarness { heartbeatCatalog: AgentConnectionHeartbeat[]; - heartbeats: AgentConnectionHeartbeat[]; agentConnection: { manageHeartbeat( activeSessionId: string, @@ -25,14 +24,13 @@ interface HeartbeatManagementHarness { interface HeartbeatScopeHarness { heartbeatCatalog: AgentConnectionHeartbeat[]; - heartbeats: AgentConnectionHeartbeat[]; connectionState: { activeSessionId: string; sessionId: string }; subagentSnapshots: Map; - heartbeatManager: { setHeartbeats(heartbeats: AgentConnectionHeartbeat[]): void } | undefined; ui: { requestRender(): void }; scheduleHeartbeatManagerRefresh(): void; updateSubagentSummaryLine(): void; applyHeartbeatCatalog(heartbeats: AgentConnectionHeartbeat[]): void; + getScopedHeartbeats(): AgentConnectionHeartbeat[]; } interface ChildIdentityUpdateHarness { @@ -42,7 +40,9 @@ interface ChildIdentityUpdateHarness { } interface HeartbeatRefreshHarness { - heartbeats: AgentConnectionHeartbeat[]; + heartbeatCatalog: AgentConnectionHeartbeat[]; + connectionState: { activeSessionId: string; sessionId: string }; + subagentSnapshots: Map; heartbeatManager: object | undefined; heartbeatManagerRefreshTimer: ReturnType | undefined; refreshHeartbeatCatalog(): Promise; @@ -74,8 +74,7 @@ describe("interactive heartbeat management", () => { const stopped = { ...current, status: "cancelled" as const, nextRunAt: undefined }; const patches: Array<{ heartbeat: AgentCronJob | null }> = []; const harness = Object.create(InteractiveMode.prototype) as HeartbeatManagementHarness; - harness.heartbeats = [{ job: current }]; - harness.heartbeatCatalog = harness.heartbeats; + harness.heartbeatCatalog = [{ job: current }]; harness.connectionState = { activeSessionId: current.activeSessionId }; harness.agentConnection = { manageHeartbeat: vi.fn(async () => stopped), @@ -95,8 +94,7 @@ describe("interactive heartbeat management", () => { const current = heartbeat(); const paused = { ...current, status: "paused" as const, nextRunAt: undefined }; const harness = Object.create(InteractiveMode.prototype) as HeartbeatManagementHarness; - harness.heartbeats = [{ job: current, sessionName: "Primary session" }]; - harness.heartbeatCatalog = harness.heartbeats; + harness.heartbeatCatalog = [{ job: current, sessionName: "Primary session" }]; harness.connectionState = { activeSessionId: current.activeSessionId }; harness.agentConnection = { manageHeartbeat: vi.fn(async () => paused) }; harness.patchConnectionState = vi.fn(); @@ -123,7 +121,6 @@ describe("interactive heartbeat management", () => { }; const harness = Object.create(InteractiveMode.prototype) as HeartbeatScopeHarness; harness.heartbeatCatalog = []; - harness.heartbeats = []; harness.connectionState = { activeSessionId: "active-1", sessionId: "session-1" }; harness.subagentSnapshots = new Map([ [ @@ -137,7 +134,6 @@ describe("interactive heartbeat management", () => { }, ], ]); - harness.heartbeatManager = { setHeartbeats: vi.fn() }; harness.ui = { requestRender: vi.fn() }; harness.scheduleHeartbeatManagerRefresh = vi.fn(); harness.updateSubagentSummaryLine = vi.fn(); @@ -145,8 +141,7 @@ describe("interactive heartbeat management", () => { harness.applyHeartbeatCatalog([own, child, unrelated]); expect(harness.heartbeatCatalog).toEqual([own, child, unrelated]); - expect(harness.heartbeats).toEqual([own, child]); - expect(harness.heartbeatManager.setHeartbeats).toHaveBeenCalledWith([own, child]); + expect(harness.getScopedHeartbeats()).toEqual([own, child]); expect(harness.updateSubagentSummaryLine).toHaveBeenCalledOnce(); }); @@ -172,7 +167,9 @@ describe("interactive heartbeat management", () => { try { vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); const harness = Object.create(InteractiveMode.prototype) as HeartbeatRefreshHarness; - harness.heartbeats = [{ job: { ...heartbeat(), nextRunAt: "2026-01-01T00:00:01.000Z" } }]; + harness.heartbeatCatalog = [{ job: { ...heartbeat(), nextRunAt: "2026-01-01T00:00:01.000Z" } }]; + harness.connectionState = { activeSessionId: "active-1", sessionId: "session-1" }; + harness.subagentSnapshots = new Map(); harness.heartbeatManager = {}; harness.heartbeatManagerRefreshTimer = undefined; harness.refreshHeartbeatCatalog = vi.fn(async () => {}); diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 157b875d96..e6b34a7548 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -4208,8 +4208,11 @@ describe("InteractiveMode goal status announcements", () => { describe("InteractiveMode tray goal label", () => { type TrayUsage = { contextWindow: number; tokens: number | null; percent: number | null }; type TrayLabelHarness = { - heartbeats: AgentConnectionHeartbeat[]; + heartbeatCatalog: AgentConnectionHeartbeat[]; + subagentSnapshots: Map; connectionState: { + activeSessionId: string; + sessionId: string; goal: GoalState; heartbeat?: AgentCronJob | null; contextUsage: TrayUsage | undefined; @@ -4239,8 +4242,11 @@ describe("InteractiveMode tray goal label", () => { test("shows active goals in the lower tray without an objective", () => { const fakeThis = Object.create(InteractiveMode.prototype) as TrayLabelHarness; - fakeThis.heartbeats = []; + fakeThis.heartbeatCatalog = []; + fakeThis.subagentSnapshots = new Map(); fakeThis.connectionState = { + activeSessionId: "active-1", + sessionId: "session-1", goal: { active: true, status: "active", @@ -4258,8 +4264,11 @@ describe("InteractiveMode tray goal label", () => { test("combines active goals with token/context usage in one lower-tray label", () => { const fakeThis = Object.create(InteractiveMode.prototype) as TrayLabelHarness; - fakeThis.heartbeats = []; + fakeThis.heartbeatCatalog = []; + fakeThis.subagentSnapshots = new Map(); fakeThis.connectionState = { + activeSessionId: "active-1", + sessionId: "session-1", goal: { active: true, status: "active", @@ -4277,8 +4286,11 @@ describe("InteractiveMode tray goal label", () => { test("combines active goals, active heartbeats, and context usage in one lower-tray label", () => { const fakeThis = Object.create(InteractiveMode.prototype) as TrayLabelHarness; - fakeThis.heartbeats = [{ job: createHeartbeat("active") }]; + fakeThis.heartbeatCatalog = [{ job: createHeartbeat("active") }]; + fakeThis.subagentSnapshots = new Map(); fakeThis.connectionState = { + activeSessionId: "active-1", + sessionId: "session-1", goal: { active: true, status: "active", @@ -4297,8 +4309,11 @@ describe("InteractiveMode tray goal label", () => { test("omits the usage segment when token count is unknown", () => { const fakeThis = Object.create(InteractiveMode.prototype) as TrayLabelHarness; - fakeThis.heartbeats = []; + fakeThis.heartbeatCatalog = []; + fakeThis.subagentSnapshots = new Map(); fakeThis.connectionState = { + activeSessionId: "active-1", + sessionId: "session-1", goal: { active: true, status: "active", From 906b2ea810b0a68c8f5dfadec6de088f12252150 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 25 Aug 2026 06:09:07 +0200 Subject: [PATCH 42/53] Remove stale updateScopedHeartbeats test stubs --- packages/coding-agent/test/subagent-summary-line.test.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/coding-agent/test/subagent-summary-line.test.ts b/packages/coding-agent/test/subagent-summary-line.test.ts index 38959d6eeb..71037d7a6b 100644 --- a/packages/coding-agent/test/subagent-summary-line.test.ts +++ b/packages/coding-agent/test/subagent-summary-line.test.ts @@ -133,7 +133,6 @@ describe("SubagentSummaryLine", () => { rlmNodeId: undefined, heartbeatCatalog: [], subagentSummaryLine: line, - updateScopedHeartbeats: vi.fn(), updateWorkingPulse: vi.fn(), syncWorkingLoader: vi.fn(), updateWorkingLoaderMessage: vi.fn(), @@ -159,7 +158,6 @@ describe("SubagentSummaryLine", () => { rlmNodeId: undefined, heartbeatCatalog: [], subagentSummaryLine: line, - updateScopedHeartbeats: vi.fn(), updateWorkingPulse: vi.fn(), syncWorkingLoader: vi.fn(), updateWorkingLoaderMessage: vi.fn(), @@ -188,7 +186,6 @@ describe("SubagentSummaryLine", () => { rlmNodeId: undefined, heartbeatCatalog: [], subagentSummaryLine: line, - updateScopedHeartbeats: vi.fn(), updateWorkingPulse: vi.fn(), syncWorkingLoader: vi.fn(), updateWorkingLoaderMessage: vi.fn(), @@ -220,7 +217,6 @@ describe("SubagentSummaryLine", () => { rlmNodeId: undefined, heartbeatCatalog: [], subagentSummaryLine: line, - updateScopedHeartbeats: vi.fn(), updateWorkingPulse: vi.fn(), syncWorkingLoader: vi.fn(), updateWorkingLoaderMessage: vi.fn(), From 1ee7043a340894a8c36be7190e2d2ae642882e7e Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 26 Aug 2026 11:11:23 +0200 Subject: [PATCH 43/53] Degrade peer query failures to local agents --- .../src/modes/daemon/daemon-mode.ts | 2 ++ .../coding-agent/test/daemon-mode.test.ts | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index b5d4cfb2da..f5e384706a 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -5328,6 +5328,8 @@ export class AgentDaemon { if (!response.success) throw deserializeDaemonError(response); // SAFETY: The authenticated supervisor constructs the peer response. return (response.data as { peers: AgentSessionMessageAgentSummary[] }).peers; + } catch { + return []; } finally { client.close(); } diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index 1828394308..314f56e117 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -1379,6 +1379,28 @@ describe("daemon mode helpers", () => { expect(internals.closingSessions.has(state.activeSessionId)).toBe(false); }); + it("returns no peers when the supervisor query fails", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-peer-query-failure-")); + const previousSupervisorSocket = process.env[DAEMON_WORKER_SUPERVISOR_SOCKET_ENV]; + try { + process.env[DAEMON_WORKER_SUPERVISOR_SOCKET_ENV] = join(tempDir, "missing.sock"); + const daemon = new AgentDaemon("/tmp/prime-agent-worker-test.sock", { + defaultSessionConfig: { agentDir: tempDir, cwd: tempDir }, + createRuntime: vi.fn(), + worker: { authenticationToken: "worker-token" }, + }); + const listSupervisorAgentPeers = ( + daemon as unknown as { listSupervisorAgentPeers(): Promise } + ).listSupervisorAgentPeers.bind(daemon); + + await expect(listSupervisorAgentPeers()).resolves.toEqual([]); + } finally { + if (previousSupervisorSocket === undefined) delete process.env[DAEMON_WORKER_SUPERVISOR_SOCKET_ENV]; + else process.env[DAEMON_WORKER_SUPERVISOR_SOCKET_ENV] = previousSupervisorSocket; + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("lists and routes agent messages to peers hosted by another worker", async () => { const daemon = new AgentDaemon("/tmp/prime-agent-worker-test.sock", { defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, From 938b0a79898fab19d95a1c3b1cc91b4bbf05ccfd Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 26 Aug 2026 11:12:03 +0200 Subject: [PATCH 44/53] 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 79806f70568e35dbcdc5ea2c8eff7197bf983ed9 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 26 Aug 2026 11:12:27 +0200 Subject: [PATCH 45/53] Reschedule heartbeat refresh on scope changes --- .../src/modes/interactive/interactive-mode.ts | 3 +++ .../interactive-heartbeat-management.test.ts | 16 +++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index b2f8b65c00..7b687ad41a 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -2564,6 +2564,7 @@ export class InteractiveMode { private applyConnectionStateSnapshot(state: AgentConnectionState): void { this.bindPromptStashSession(state.sessionId); this.connectionState = state; + this.scheduleHeartbeatManagerRefresh(); // Don't touch contextUsageTokenBaseline: a mid-stream snapshot reflects only completed // turns (the in-flight message isn't persisted yet), so the in-flight delta must keep // accumulating. The baseline is managed at turn end (refreshConnectionContextUsage) and @@ -5916,6 +5917,7 @@ export class InteractiveMode { } private refreshSubagentSummary(): void { + this.scheduleHeartbeatManagerRefresh(); this.updateSubagentSummaryLine(); this.updateWorkingPulse(); this.syncWorkingLoader(); @@ -5946,6 +5948,7 @@ export class InteractiveMode { this.subagentSnapshots.clear(); this.rlmNodeId = undefined; this.updateSubagentSummaryLine(); + this.scheduleHeartbeatManagerRefresh(); // Clearing snapshots can drop the last running subagent; reconcile the // pulse and loader so neither lingers when nothing is in flight. this.updateWorkingPulse(); diff --git a/packages/coding-agent/test/interactive-heartbeat-management.test.ts b/packages/coding-agent/test/interactive-heartbeat-management.test.ts index 7605c7d4d6..3af4a8c03c 100644 --- a/packages/coding-agent/test/interactive-heartbeat-management.test.ts +++ b/packages/coding-agent/test/interactive-heartbeat-management.test.ts @@ -35,8 +35,13 @@ interface HeartbeatScopeHarness { interface ChildIdentityUpdateHarness { subagentSnapshots: Map; - refreshSubagentSummary(): void; + ui: { requestRender(): void }; updateSubagentSummary(child: AgentConnectionRlmChildAgentSnapshot): void; + scheduleHeartbeatManagerRefresh(): void; + updateSubagentSummaryLine(): void; + updateWorkingPulse(): void; + syncWorkingLoader(): void; + updateWorkingLoaderMessage(): void; } interface HeartbeatRefreshHarness { @@ -154,12 +159,17 @@ describe("interactive heartbeat management", () => { }; const harness = Object.create(InteractiveMode.prototype) as ChildIdentityUpdateHarness; harness.subagentSnapshots = new Map([[existing.id, existing]]); - harness.refreshSubagentSummary = vi.fn(); + harness.ui = { requestRender: vi.fn() }; + harness.scheduleHeartbeatManagerRefresh = vi.fn(); + harness.updateSubagentSummaryLine = vi.fn(); + harness.updateWorkingPulse = vi.fn(); + harness.syncWorkingLoader = vi.fn(); + harness.updateWorkingLoaderMessage = vi.fn(); harness.updateSubagentSummary({ ...existing, activeSessionId: "active-2" }); expect(harness.subagentSnapshots.get(existing.id)?.activeSessionId).toBe("active-2"); - expect(harness.refreshSubagentSummary).toHaveBeenCalledOnce(); + expect(harness.scheduleHeartbeatManagerRefresh).toHaveBeenCalledOnce(); }); it("refreshes an open manager after the next scheduled run", async () => { From 3fb6ee2a2f40afcc252d4e58665e2b6701669cce Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 26 Aug 2026 11:12:58 +0200 Subject: [PATCH 46/53] Revert "Remove duplicate heartbeat cache stale flag" This reverts commit 322c57269d9ad33ba0e7c5f4586c3a03b3f507d0. --- .../coding-agent/.changes/heartbeat-cache-presence.md | 1 - .../coding-agent/src/modes/daemon/daemon-supervisor.ts | 8 +++++--- .../test/daemon-supervisor-heartbeats.test.ts | 3 ++- 3 files changed, 7 insertions(+), 5 deletions(-) delete mode 100644 packages/coding-agent/.changes/heartbeat-cache-presence.md diff --git a/packages/coding-agent/.changes/heartbeat-cache-presence.md b/packages/coding-agent/.changes/heartbeat-cache-presence.md deleted file mode 100644 index 6ad1f6baec..0000000000 --- a/packages/coding-agent/.changes/heartbeat-cache-presence.md +++ /dev/null @@ -1 +0,0 @@ -- Prevented outdated heartbeat snapshots from being retained after worker changes. diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 1d8070d1c3..c6b9254e47 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -274,6 +274,7 @@ interface ResidentWorker { descriptorPath: string; client?: DaemonWorkerClient; heartbeatSnapshot?: AgentConnectionHeartbeat[]; + heartbeatSnapshotStale?: boolean; summaries: Map; snapshotCache: Map; transcriptCaches: Map; @@ -1869,14 +1870,15 @@ export class DaemonSupervisor { if (response.success) { const snapshot = heartbeatsFromResponse(response); worker.heartbeatSnapshot = snapshot; + worker.heartbeatSnapshotStale = false; return { heartbeats: snapshot }; } this.log(`Could not list heartbeats from a worker: ${response.error}`); - if (worker.heartbeatSnapshot === undefined) { + if (worker.heartbeatSnapshot === undefined || worker.heartbeatSnapshotStale === true) { return { response }; } } - if (worker.heartbeatSnapshot !== undefined) { + if (worker.heartbeatSnapshot !== undefined && worker.heartbeatSnapshotStale !== true) { return { heartbeats: worker.heartbeatSnapshot }; } const state = @@ -4164,7 +4166,7 @@ export class DaemonSupervisor { snapshotPurpose, } = frame.header; if (outboundType === "heartbeats_changed") { - worker.heartbeatSnapshot = undefined; + worker.heartbeatSnapshotStale = true; this.broadcastHeartbeatsChanged(); return; } diff --git a/packages/coding-agent/test/daemon-supervisor-heartbeats.test.ts b/packages/coding-agent/test/daemon-supervisor-heartbeats.test.ts index 8ed45ace9b..68f8f98e45 100644 --- a/packages/coding-agent/test/daemon-supervisor-heartbeats.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-heartbeats.test.ts @@ -99,6 +99,7 @@ describe("daemon supervisor heartbeat aggregation", () => { const target = { ...worker("ready"), heartbeatSnapshot: [{ job: { id: "heartbeat-1" } }], + heartbeatSnapshotStale: false, }; supervisor.workers.set("target", target); supervisor.forwardToWorker = vi.fn(async (_worker, command) => @@ -114,7 +115,7 @@ describe("daemon supervisor heartbeat aggregation", () => { type: "heartbeats_list", }); - expect(target.heartbeatSnapshot).toBeUndefined(); + expect(target.heartbeatSnapshotStale).toBe(true); expect(response).toMatchObject({ success: false, error: "worker unavailable" }); }); From e7b00bc0ae3080eed5b2403c2260b331d90b8804 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 27 Aug 2026 10:03:33 +0200 Subject: [PATCH 47/53] 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 48/53] 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 4e4c86da732797082cdb40a0bfcf21e967a9c6b3 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 27 Aug 2026 11:01:38 +0200 Subject: [PATCH 49/53] Keep new-chat state stable during streaming --- .../src/modes/interactive/interactive-mode.ts | 14 +++++++--- .../test/interactive-mode-startup.test.ts | 27 +++++++++++++++++++ .../test/interactive-mode-status.test.ts | 25 ----------------- 3 files changed, 37 insertions(+), 29 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index df13051d55..a6469f1d94 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -2641,11 +2641,17 @@ export class InteractiveMode { return; } switch (event.type) { - case "agent_start": + case "agent_start": { + const wasNewChat = this.isNewChat(); this.patchConnectionState({ isStreaming: true, activeToolNames: [] }); + if (wasNewChat) { + this.builtInHeader?.invalidate(); + this.subagentSummaryLine.invalidate(); + } break; - case "message_start": { - const wasNewChat = this.connectionState.messageCount === 0; + } + case "message_end": { + const wasNewChat = this.isNewChat(); this.patchConnectionState({ messageCount: this.connectionState.messageCount + 1 }); if (wasNewChat) { this.builtInHeader?.invalidate(); @@ -6044,7 +6050,7 @@ export class InteractiveMode { } private isNewChat(): boolean { - return (this.connectionState?.messageCount ?? 0) === 0; + return (this.connectionState?.messageCount ?? 0) === 0 && this.connectionState?.isStreaming !== true; } private getModelTrayLabel(): string { diff --git a/packages/coding-agent/test/interactive-mode-startup.test.ts b/packages/coding-agent/test/interactive-mode-startup.test.ts index 5b9fb342b3..35444ce746 100644 --- a/packages/coding-agent/test/interactive-mode-startup.test.ts +++ b/packages/coding-agent/test/interactive-mode-startup.test.ts @@ -28,6 +28,7 @@ describe("InteractiveMode startup hints", () => { model: { name: "test-model", reasoning: true }, thinkingLevel: "high", messageCount, + isStreaming: false, }, }; Object.setPrototypeOf(mode, InteractiveMode.prototype); @@ -83,6 +84,32 @@ describe("InteractiveMode startup hints", () => { expect(stripAnsi(label)).toBe("test-model • high ? for shortcuts"); }); + it("keeps fresh-chat guidance hidden when a mid-turn snapshot still has no committed messages", () => { + const mode = createMode(); + const patchConnectionState = (patch: Record) => Object.assign(mode.connectionState, patch); + Object.assign(mode, { + patchConnectionState, + builtInHeader: { invalidate: vi.fn() }, + subagentSummaryLine: { invalidate: vi.fn() }, + }); + const updateConnectionStateFromEvent = Reflect.get( + InteractiveMode.prototype, + "updateConnectionStateFromEvent", + ) as (event: unknown) => void; + const getLabel = () => stripAnsi(Reflect.get(InteractiveMode.prototype, "getTrayLocationLabel").call(mode)); + const message = { role: "user", content: "hello", timestamp: 1 }; + + updateConnectionStateFromEvent.call(mode, { type: "agent_start" }); + updateConnectionStateFromEvent.call(mode, { type: "message_start", message }); + Object.assign(mode.connectionState, { messageCount: 0, isStreaming: true }); + + expect(getLabel()).not.toContain("for shortcuts"); + + updateConnectionStateFromEvent.call(mode, { type: "message_end", message }); + updateConnectionStateFromEvent.call(mode, { type: "agent_end", messages: [message] }); + expect(getLabel()).not.toContain("for shortcuts"); + }); + it("routes session-view requests through the existing agents-view return path", async () => { const returnToAgentsView = vi.fn(async () => {}); const mode = Object.assign(createMode(0, true), { returnToAgentsView }); diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 2e5d1b1c14..cf1bcd691e 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -1434,31 +1434,6 @@ describe("InteractiveMode pending bash components", () => { }); describe("InteractiveMode connection events", () => { - test("updates the session message count from message starts", () => { - const builtInHeader = { invalidate: vi.fn() }; - const subagentSummaryLine = { invalidate: vi.fn() }; - const harness = { - connectionState: createConnectionState(), - builtInHeader, - subagentSummaryLine, - patchConnectionState(patch: Partial) { - this.connectionState = { ...this.connectionState, ...patch }; - }, - }; - const updateConnectionStateFromEvent = ( - InteractiveMode.prototype as unknown as { - updateConnectionStateFromEvent(this: typeof harness, event: AgentConnectionSessionEvent): void; - } - ).updateConnectionStateFromEvent; - - updateConnectionStateFromEvent.call(harness, { type: "message_start", message: userMessage("one", 1) }); - updateConnectionStateFromEvent.call(harness, { type: "message_start", message: userMessage("two", 2) }); - - expect(harness.connectionState.messageCount).toBe(2); - expect(builtInHeader.invalidate).toHaveBeenCalledOnce(); - expect(subagentSummaryLine.invalidate).toHaveBeenCalledOnce(); - }); - test("rendering a switched session updates the pending display from its snapshot", async () => { const harness = { resetCurrentSessionRenderState: vi.fn(), From 9dfcc8ec5d6d2c73bc6eeb1394b72fb572477661 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 27 Aug 2026 11:02:05 +0200 Subject: [PATCH 50/53] Dispatch peer queries to connected workers --- .../src/modes/daemon/daemon-supervisor.ts | 6 +- .../daemon-supervisor-lazy-subagents.test.ts | 78 ++++++++++++------- 2 files changed, 53 insertions(+), 31 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 45d3960189..61bc39485a 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -168,6 +168,7 @@ const WORKER_STARTUP_GATE_FD = 3; const DAEMON_COMMAND_TYPES: ReadonlySet = new Set([ "ack_result", "list", + "list_agent_peers", "list_saved_sessions", "create", "attach", @@ -1540,7 +1541,10 @@ export class DaemonSupervisor { const peers = [...this.workers.values()] .filter( (worker) => - worker !== requester && this.isLiveWorker(worker) && worker.descriptor.lifecycle === "ready", + worker !== requester && + this.isLiveWorker(worker) && + worker.descriptor.lifecycle === "ready" && + worker.client !== undefined, ) .flatMap((worker) => { const root = worker.summaries.get(worker.descriptor.rootActiveSessionId); diff --git a/packages/coding-agent/test/daemon-supervisor-lazy-subagents.test.ts b/packages/coding-agent/test/daemon-supervisor-lazy-subagents.test.ts index 58d2187a7d..3233a551da 100644 --- a/packages/coding-agent/test/daemon-supervisor-lazy-subagents.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-lazy-subagents.test.ts @@ -4,17 +4,20 @@ import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { type AgentFamilyCatalogEntry, - type AgentSessionMessageAgentSummary, assertAgentFamilyReach, sessionNameReservationKey, } from "../src/core/agent-messages.js"; import { readSessionInfo, SessionManager } from "../src/core/session-manager.js"; +import { DaemonCatalogClient } from "../src/modes/daemon/daemon-catalog-process.js"; +import { DaemonClient } from "../src/modes/daemon/daemon-client.js"; import { success } from "../src/modes/daemon/daemon-protocol.js"; import type { SessionSummary } from "../src/modes/daemon/daemon-session-list.js"; import { DaemonSupervisor } from "../src/modes/daemon/daemon-supervisor.js"; interface SupervisorInternals { workers: Map; + start(): Promise; + cleanupSupervisorResources(): Promise; refreshWorkerSummaries(worker: WorkerFixture): Promise; findSummaryInWorker(worker: WorkerFixture, selector: string): SessionSummary | undefined; createOrReuseWorker( @@ -644,13 +647,16 @@ describe("daemon supervisor passive subagent topology", () => { await expect(first).resolves.toBe(launched); }); - it("returns only other worker roots for an authenticated peer query", async () => { + it("dispatches authenticated peer queries and excludes disconnected workers", async () => { const directory = mkdtempSync(join(tmpdir(), "prime-supervisor-passive-peers-")); tempDirs.push(directory); - const supervisor = new DaemonSupervisor(join(directory, "daemon.sock"), { + const socketPath = join(directory, "daemon.sock"); + const supervisor = new DaemonSupervisor(socketPath, { defaultSessionConfig: { agentDir: directory, cwd: directory }, descriptorDir: join(directory, "workers"), }) as unknown as SupervisorInternals; + const client = new DaemonClient(socketPath); + vi.spyOn(DaemonCatalogClient.prototype, "start").mockResolvedValue(); const passive = summary({ id: "passive-session", @@ -666,42 +672,54 @@ describe("daemon supervisor passive subagent topology", () => { sessionId: "first-root-session", runtimeKind: "top-level", }); - const first = worker("first"); - first.client.request.mockResolvedValue(success(undefined, "list", { sessions: [passive] })); const secondRoot = summary({ id: "second-root-active", activeSessionId: "second-root-active", sessionId: "second-root-session", }); + const disconnectedRoot = summary({ + id: "disconnected-root-active", + activeSessionId: "disconnected-root-active", + sessionId: "disconnected-root-session", + }); + const first = worker("first", [firstRoot, passive]); const second = worker("second", [secondRoot]); - supervisor.workers.set("first", first); - supervisor.workers.set("second", second); + const disconnected = worker("disconnected", [disconnectedRoot]); + Object.assign(disconnected, { client: undefined }); - await supervisor.refreshWorkerSummaries(first); - expect(first.client.request).toHaveBeenCalledWith({ type: "list" }, 5000); - expect(first.summaries.get("passive-session")).toMatchObject({ - sessionFile: passive.sessionFile, - runtimeKind: "subagent", - rlmChildId: "passive-child", - }); - first.summaries.set(first.descriptor.rootActiveSessionId, firstRoot); + try { + await supervisor.start(); + supervisor.workers.set("first", first); + supervisor.workers.set("second", second); + supervisor.workers.set("disconnected", disconnected); + await client.connect(); - await expect( - supervisor.handleCommand({}, { type: "list_agent_peers", workerToken: "invalid-token" }), - ).rejects.toThrow("Worker authentication failed"); - const response = (await supervisor.handleCommand( - {}, - { + await expect( + client.request({ type: "list_agent_peers", workerToken: "invalid-token" }), + ).resolves.toMatchObject({ + success: false, + error: "Worker authentication failed", + }); + const response = await client.request({ type: "list_agent_peers", workerToken: second.descriptor.authenticationToken, - }, - )) as { data: { peers: AgentSessionMessageAgentSummary[] } }; - expect(response.data.peers).toEqual([ - expect.objectContaining({ - activeSessionId: "first-root-active", - sessionId: "first-root-session", - runtimeKind: "top-level", - }), - ]); + }); + expect(response).toMatchObject({ + success: true, + data: { + peers: [ + expect.objectContaining({ + activeSessionId: "first-root-active", + sessionId: "first-root-session", + runtimeKind: "top-level", + }), + ], + }, + }); + } finally { + client.close(); + supervisor.workers.clear(); + await supervisor.cleanupSupervisorResources(); + } }); }); From 019a7dfa04d62f38f3bd017766a90fe50e7a72ef Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 27 Aug 2026 11:02:38 +0200 Subject: [PATCH 51/53] 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 52/53] 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 53/53] 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`.