Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions e2e/agent-send-wait.e2e.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
94 changes: 90 additions & 4 deletions packages/cli/src/__tests__/commands/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand All @@ -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 () => {
Expand Down
Loading
Loading