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;