From e189dbd58045b3ea3eb11bb4aeae7cf83478a868 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Fri, 16 Oct 2026 22:43:44 +0000 Subject: [PATCH] fix(agent): wait for session file in agent send --wait agent send --wait executed right after agent start failed with "No session file found" because the registry entry exists at start while the session file only materializes when the harness writes its first entry (on the first message). The wait path resolved the session file at dispatch time and failed immediately. Send the prompt first, then poll listAgents() for the session file to appear, bounded by the caller's --timeout or a 30s default grace period. Session-file appearance doubles as the harness readiness signal, so sends into a still-booting TUI are no longer silently dropped either. A distinct error is raised when the agent process exits without ever creating a session file. Closes #400 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- e2e/agent-send-wait.e2e.ts | 170 ++++++++++++++++++ .../cli/src/__tests__/commands/agent.test.ts | 94 +++++++++- .../cli/src/services/agent/agent.service.ts | 84 +++++++-- 3 files changed, 333 insertions(+), 15 deletions(-) create mode 100644 e2e/agent-send-wait.e2e.ts diff --git a/e2e/agent-send-wait.e2e.ts b/e2e/agent-send-wait.e2e.ts new file mode 100644 index 000000000..de9e61951 --- /dev/null +++ b/e2e/agent-send-wait.e2e.ts @@ -0,0 +1,170 @@ +/** + * Regression coverage for https://github.com/codeaholicguy/ai-devkit/issues/400 + * + * `agent send --wait` immediately after `agent start` used to fail with + * "No session file found" because a fresh agent only writes its session file + * after its first message. + * + * A real `agent start` boot requires tmux plus a live harness binary, which is + * too heavy for e2e CI. These tests instead drive the compiled `sendToAgent` + * service with simulated collaborators: a scripted `listAgents` sequence + * reproduces the "session file appears later" condition end-to-end through + * the real send → poll → wait code path. The trade-off is that tmux delivery + * and harness process detection are stubbed rather than real. + */ +import { join } from 'path'; + +// Compiled service under test (dist mirrors src layout). +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { sendToAgent } = await import( + join(__dirname, '..', 'packages', 'cli', 'dist', 'services', 'agent', 'agent.service.js') +); +const { AgentStatus } = await import('@ai-devkit/agent-manager'); + +interface FakeAgent { + name: string; + type: string; + status: string; + pid: number; + sessionId: string; + sessionFilePath?: string; + lastActive: Date; +} + +const BOOTING_AGENT: FakeAgent = { + name: 'boot-agent', + type: 'claude', + status: AgentStatus.RUNNING, + pid: 4242, + sessionId: 'session-1', + lastActive: new Date('2026-01-01T00:00:00.000Z'), +}; + +const READY_AGENT: FakeAgent = { + ...BOOTING_AGENT, + status: AgentStatus.WAITING, + sessionFilePath: '/tmp/boot-agent-session.jsonl', +}; + +function makeHarness(listAgentsSequence: FakeAgent[][], conversations: object[][]) { + const listAgentsCalls: number[] = []; + let listIndex = 0; + let convoIndex = 0; + + const adapter = { + getConversation: () => conversations[Math.min(convoIndex++, conversations.length - 1)], + }; + const manager = { + listAgents: async () => { + listAgentsCalls.push(1); + return listAgentsSequence[Math.min(listIndex++, listAgentsSequence.length - 1)]; + }, + resolveAgent: () => BOOTING_AGENT, + getAdapter: () => adapter, + }; + return { manager, adapter, listAgentsCalls }; +} + +function makeRuntime(sent: string[]) { + return { + provider: 'tmux', + send: async ({ prompt }: { prompt: string }) => { + sent.push(prompt); + }, + focus: async () => true, + stop: async () => {}, + }; +} + +const silentReporter = { info: () => {}, warning: () => {}, success: () => {}, error: () => {} }; + +describe('agent send --wait session-file race (issue #400)', () => { + it('sends the prompt, then waits for the session file to appear', async () => { + const { manager } = makeHarness( + [ + [BOOTING_AGENT], // initial resolution — no session file yet + [BOOTING_AGENT], // first poll — still booting + [READY_AGENT], // session file appeared after the send + [READY_AGENT], // wait loop + ], + [ + [], // initial transcript read + [{ role: 'assistant', content: 'hello back' }], + ], + ); + const sent: string[] = []; + const assistantMessages: string[] = []; + + await sendToAgent({ + id: 'boot-agent', + prompt: 'hello', + manager, + focusManager: { findTerminal: async () => ({ type: 'tmux', identifier: '0:1.0' }) }, + registry: { lookup: () => null }, + runtime: makeRuntime(sent), + wait: true, + timeout: '5000', + reporter: silentReporter, + writeWaitStatus: () => {}, + writeAssistantMessage: (message: { content: string }) => { + assistantMessages.push(message.content); + }, + }); + + expect(sent).toEqual(['hello']); + expect(assistantMessages).toEqual(['hello back']); + }); + + it('fails with a distinct error when the agent exits before creating a session file', async () => { + const { manager } = makeHarness( + [ + [BOOTING_AGENT], // initial resolution + [], // agent process exited before the session file appeared + ], + [[]], + ); + const sent: string[] = []; + + await expect( + sendToAgent({ + id: 'boot-agent', + prompt: 'hello', + manager, + focusManager: { findTerminal: async () => ({ type: 'tmux', identifier: '0:1.0' }) }, + registry: { lookup: () => null }, + runtime: makeRuntime(sent), + wait: true, + timeout: '5000', + reporter: silentReporter, + writeWaitStatus: () => {}, + writeAssistantMessage: () => {}, + }), + ).rejects.toThrow('exited before creating a session file'); + expect(sent).toEqual(['hello']); + }); + + it('fails after the grace period when no session file ever appears', async () => { + const { manager } = makeHarness( + [[BOOTING_AGENT]], // never creates a session file + [[]], + ); + const sent: string[] = []; + + await expect( + sendToAgent({ + id: 'boot-agent', + prompt: 'hello', + manager, + focusManager: { findTerminal: async () => ({ type: 'tmux', identifier: '0:1.0' }) }, + registry: { lookup: () => null }, + runtime: makeRuntime(sent), + wait: true, + timeout: '150', + reporter: silentReporter, + writeWaitStatus: () => {}, + writeAssistantMessage: () => {}, + }), + ).rejects.toThrow('did not create a session file within 150ms'); + expect(sent).toEqual(['hello']); + }); +}); diff --git a/packages/cli/src/__tests__/commands/agent.test.ts b/packages/cli/src/__tests__/commands/agent.test.ts index d7d06d9b6..df7702ff1 100644 --- a/packages/cli/src/__tests__/commands/agent.test.ts +++ b/packages/cli/src/__tests__/commands/agent.test.ts @@ -2453,8 +2453,8 @@ Waiting on user input`, }); }); - it("fails and does not send when --wait target has no session file", async () => { - const agent = { + it("sends first and waits for the session file to appear when --wait target has none yet", async () => { + const bootingAgent = { name: "repo-a", type: "claude", status: AgentStatus.WAITING, @@ -2463,8 +2463,92 @@ Waiting on user input`, pid: 10, sessionId: "session-1", }; + const readyAgent = { ...bootingAgent, sessionFilePath: "/tmp/session.jsonl" }; + mockManager.listAgents + .mockResolvedValueOnce([bootingAgent]) + .mockResolvedValueOnce([bootingAgent]) + .mockResolvedValue([readyAgent]); + mockManager.resolveAgent.mockReturnValue(bootingAgent); + mockManager.getAdapter.mockReturnValue(mockAgentAdapter); + mockFocusManager.findTerminal.mockResolvedValue({ type: "tmux", identifier: "0:1.0" }); + mockAgentAdapter.getConversation + .mockReturnValueOnce([]) + .mockReturnValue([{ role: "assistant", content: "done" }]); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync([ + "node", + "test", + "agent", + "send", + "hello", + "--id", + "repo-a", + "--wait", + "--timeout", + "5000", + ]); + + expect(mockSendAgentPrompt).toHaveBeenCalled(); + expect(stdoutSpy).toHaveBeenCalledWith("done\n"); + expect(process.exit).not.toHaveBeenCalledWith(1); + }); + + it("fails with a distinct error when the --wait target exits before creating a session file", async () => { + const bootingAgent = { + name: "repo-a", + type: "claude", + status: AgentStatus.RUNNING, + summary: "Booting", + lastActive: new Date(), + pid: 10, + sessionId: "session-1", + }; + mockManager.listAgents.mockResolvedValueOnce([bootingAgent]).mockResolvedValue([]); + mockManager.resolveAgent.mockReturnValue(bootingAgent); + mockManager.getAdapter.mockReturnValue(mockAgentAdapter); + mockFocusManager.findTerminal.mockResolvedValue({ type: "tmux", identifier: "0:1.0" }); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync([ + "node", + "test", + "agent", + "send", + "hello", + "--id", + "repo-a", + "--wait", + "--timeout", + "5000", + ]); + + expect(mockSendAgentPrompt).toHaveBeenCalled(); + expect(ui.error).toHaveBeenCalledWith( + 'Failed to send message: Agent "repo-a" exited before creating a session file; cannot wait for response.', + ); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("fails after the grace period when the --wait target never creates a session file", async () => { + // Earlier tests freeze Date.now with vi.spyOn; restore it so the grace + // window can actually elapse. + (Date.now as unknown as Mock).mockRestore?.(); + const agent = { + name: "repo-a", + type: "claude", + status: AgentStatus.RUNNING, + summary: "Booting", + lastActive: new Date(), + pid: 10, + sessionId: "session-1", + }; mockManager.listAgents.mockResolvedValue([agent]); mockManager.resolveAgent.mockReturnValue(agent); + mockManager.getAdapter.mockReturnValue(mockAgentAdapter); + mockFocusManager.findTerminal.mockResolvedValue({ type: "tmux", identifier: "0:1.0" }); const program = new Command(); registerAgentCommand(program); @@ -2477,13 +2561,15 @@ Waiting on user input`, "--id", "repo-a", "--wait", + "--timeout", + "150", ]); + expect(mockSendAgentPrompt).toHaveBeenCalled(); expect(ui.error).toHaveBeenCalledWith( - 'Failed to send message: No session file found for agent "repo-a"; cannot wait for response.', + 'Failed to send message: Agent "repo-a" did not create a session file within 150ms; cannot wait for response.', ); expect(process.exit).toHaveBeenCalledWith(1); - expect(mockTtyWriterSend).not.toHaveBeenCalled(); }); it("fails and does not send when --wait target has no adapter", async () => { diff --git a/packages/cli/src/services/agent/agent.service.ts b/packages/cli/src/services/agent/agent.service.ts index eb0ec4587..c26a8a662 100644 --- a/packages/cli/src/services/agent/agent.service.ts +++ b/packages/cli/src/services/agent/agent.service.ts @@ -266,7 +266,7 @@ export async function sendToAgent({ } } - const waitContext = wait ? prepareWaitMode(manager, agent) : undefined; + const waitPrep = wait ? prepareWaitMode(manager, agent) : undefined; const sendFailed = await sendAgentPrompt(agent, prompt, { registry, runtime, @@ -291,22 +291,37 @@ export async function sendToAgent({ return; } - if (!waitContext) { + if (!waitPrep) { throw new Error("Wait mode was not prepared."); } + // A freshly started agent may not have written its session file yet — it + // only materializes once the harness writes its first entry. Poll for it + // (bounded by --timeout or a short default grace) so the send we just made + // is what triggers the file to appear. + const sessionFilePath = + waitPrep.sessionFilePath ?? + (await waitForSessionFile(manager, agent, { + pollIntervalMs: AGENT_SEND_WAIT_SESSION_FILE_POLL_INTERVAL_MS, + maxWaitMs: Math.min(waitTimeout.maxWaitMs, AGENT_SEND_WAIT_SESSION_FILE_GRACE_MS), + onStatus: writeWaitStatus, + })); + const initialMessageCount = + waitPrep.initialMessageCount ?? + waitPrep.adapter.getConversation(sessionFilePath, { verbose: false }).length; + const waitResult = await waitForAgentResponse({ manager, - adapter: waitContext.adapter, + adapter: waitPrep.adapter, target: { id, name: agent.name, type: agent.type, pid: agent.pid, sessionId: agent.sessionId, - sessionFilePath: waitContext.sessionFilePath, + sessionFilePath, }, - initialMessageCount: waitContext.initialMessageCount, + initialMessageCount, options: { pollIntervalMs: AGENT_SEND_WAIT_POLL_INTERVAL_MS, maxWaitMs: waitTimeout.maxWaitMs, @@ -374,18 +389,18 @@ function prepareWaitMode( agent: AgentInfo, ): { adapter: AgentAdapter; - sessionFilePath: string; - initialMessageCount: number; + sessionFilePath?: string; + initialMessageCount?: number; } { - if (!agent.sessionFilePath) { - throw new Error(`No session file found for agent "${agent.name}"; cannot wait for response.`); - } - const adapter = manager.getAdapter(agent.type); if (!adapter) { throw new Error(`Unsupported agent type: ${agent.type}`); } + if (!agent.sessionFilePath) { + return { adapter }; + } + return { adapter, sessionFilePath: agent.sessionFilePath, @@ -393,6 +408,51 @@ function prepareWaitMode( }; } +async function waitForSessionFile( + manager: Pick, + agent: AgentInfo, + options: { + pollIntervalMs: number; + maxWaitMs: number; + onStatus?: (message: string) => void; + }, +): Promise { + if (agent.sessionFilePath) { + return agent.sessionFilePath; + } + + options.onStatus?.(`Waiting for agent "${agent.name}" to create its session file...`); + const startedAt = Date.now(); + const target: AgentSendWaitTarget = { + id: agent.name, + name: agent.name, + type: agent.type, + pid: agent.pid, + sessionId: agent.sessionId, + sessionFilePath: "", + }; + + while (Date.now() - startedAt < options.maxWaitMs) { + const agents = await manager.listAgents(); + const current = findSameAgent(target, agents); + if (!current) { + throw new Error( + `Agent "${agent.name}" exited before creating a session file; cannot wait for response.`, + ); + } + if (current.sessionFilePath) { + return current.sessionFilePath; + } + + const remainingMs = options.maxWaitMs - (Date.now() - startedAt); + await sleep(Math.min(options.pollIntervalMs, remainingMs)); + } + + throw new Error( + `Agent "${agent.name}" did not create a session file within ${options.maxWaitMs}ms; cannot wait for response.`, + ); +} + function toAgentSendWaitJson( result: AgentSendWaitResult, agent: AgentInfo, @@ -556,3 +616,5 @@ function targetKey(agent: AgentInfo): string { const AGENT_SEND_WAIT_POLL_INTERVAL_MS = 2000; const AGENT_SEND_WAIT_MAX_WAIT_MS = 10 * 60 * 1000; const AGENT_SEND_WAIT_EMPTY_WAITING_GRACE_MS = 1000; +const AGENT_SEND_WAIT_SESSION_FILE_GRACE_MS = 30_000; +const AGENT_SEND_WAIT_SESSION_FILE_POLL_INTERVAL_MS = 500;