chore(lint): extract nested callbacks in server (max-nested-callbacks)

This commit is contained in:
Mohamed Boudra
2026-04-23 23:58:58 +07:00
parent 55baa53e0b
commit 6f131ad551
33 changed files with 20628 additions and 20756 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -5,74 +5,72 @@ import { setupFinishNotification } from "./mcp-shared.js";
import type { AgentManager, AgentManagerEvent, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage } from "./agent-storage.js";
describe("setupFinishNotification", () => {
it("does not notify archived callers", async () => {
let subscriber: ((event: AgentManagerEvent) => void) | null = null;
it("does not notify archived callers", async () => {
let subscriber: ((event: AgentManagerEvent) => void) | null = null;
const childAgent = {
id: "child-agent",
lifecycle: "idle",
config: { title: "Child Agent" },
} as ManagedAgent;
const childAgent = {
id: "child-agent",
lifecycle: "idle",
config: { title: "Child Agent" },
} as ManagedAgent;
const agentManager = {
getAgent: vi.fn((agentId: string) => {
if (agentId === "child-agent") {
return childAgent;
}
if (agentId === "caller-agent") {
return {
id: "caller-agent",
lifecycle: "idle",
config: { title: "Caller Agent" },
} as ManagedAgent;
}
return null;
}),
subscribe: vi.fn((callback: (event: AgentManagerEvent) => void) => {
subscriber = callback;
return () => {
subscriber = null;
};
}),
hasInFlightRun: vi.fn().mockReturnValue(false),
streamAgent: vi.fn(() => (async function* noop() {})()),
replaceAgentRun: vi.fn(() => (async function* noop() {})()),
} as unknown as AgentManager;
const agentManager = {
getAgent: vi.fn((agentId: string) => {
if (agentId === "child-agent") {
return childAgent;
}
if (agentId === "caller-agent") {
return {
id: "caller-agent",
lifecycle: "idle",
config: { title: "Caller Agent" },
} as ManagedAgent;
}
return null;
}),
subscribe: vi.fn((callback: (event: AgentManagerEvent) => void) => {
subscriber = callback;
return () => {
subscriber = null;
};
}),
hasInFlightRun: vi.fn().mockReturnValue(false),
streamAgent: vi.fn(() => (async function* noop() {})()),
replaceAgentRun: vi.fn(() => (async function* noop() {})()),
} as unknown as AgentManager;
const agentStorage = {
get: vi.fn(async (agentId: string) =>
agentId === "caller-agent" ? { archivedAt: "2024-01-01" } : null,
),
} as unknown as AgentStorage;
const agentStorage = {
get: vi.fn(async (agentId: string) =>
agentId === "caller-agent" ? { archivedAt: "2024-01-01" } : null,
),
} as unknown as AgentStorage;
setupFinishNotification({
agentManager,
agentStorage,
childAgentId: "child-agent",
callerAgentId: "caller-agent",
logger: createTestLogger(),
});
expect(subscriber).not.toBeNull();
childAgent.lifecycle = "running";
subscriber?.({
type: "agent_state",
agent: childAgent,
});
childAgent.lifecycle = "idle";
subscriber?.({
type: "agent_state",
agent: childAgent,
});
await vi.waitFor(() => {
expect(agentStorage.get).toHaveBeenCalledWith("caller-agent");
});
expect((agentManager as any).streamAgent).not.toHaveBeenCalled();
expect((agentManager as any).replaceAgentRun).not.toHaveBeenCalled();
setupFinishNotification({
agentManager,
agentStorage,
childAgentId: "child-agent",
callerAgentId: "caller-agent",
logger: createTestLogger(),
});
expect(subscriber).not.toBeNull();
childAgent.lifecycle = "running";
subscriber?.({
type: "agent_state",
agent: childAgent,
});
childAgent.lifecycle = "idle";
subscriber?.({
type: "agent_state",
agent: childAgent,
});
await vi.waitFor(() => {
expect(agentStorage.get).toHaveBeenCalledWith("caller-agent");
});
expect((agentManager as any).streamAgent).not.toHaveBeenCalled();
expect((agentManager as any).replaceAgentRun).not.toHaveBeenCalled();
});
File diff suppressed because it is too large Load Diff
@@ -192,309 +192,300 @@ function assertInvariants(events: AgentStreamEvent[], foregroundTurnIds: string[
// Tests
// ---------------------------------------------------------------------------
describe("Agent event stream redesign — integration", () => {
let canRun = false;
let canRun = false;
beforeAll(async () => {
canRun = (await isCommandAvailable("claude")) && hasClaudeCredentials;
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("Test 1: Basic foreground turn", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-basic-" });
try {
const { turnId, events } = await startTurnAndCollectEvents(
handle.session,
"respond with just the word hello",
);
const turnStarted = events.find(
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId,
);
expect(turnStarted).toBeDefined();
const terminal = events.find(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
);
expect(terminal).toBeDefined();
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
test("Test 2: No duplicate user_messages — THE BUG", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-dedup-" });
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", {
extraMs: 3_000,
});
expect(userMessagesWithText(events, "say hi").length).toBeLessThanOrEqual(1);
// No turn_started after terminal for the same turnId
const terminalIdx = events.findIndex(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
);
const staleTurnStarted = events
.slice(terminalIdx + 1)
.filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId);
expect(staleTurnStarted.length).toBe(0);
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
test("Test 3: Lifecycle doesn't get stuck in running", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-lifecycle-" });
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", {
extraMs: 3_000,
});
const terminalIdx = events.findIndex(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
);
const afterTerminal = events.slice(terminalIdx + 1);
// No subsequent turn_started for same turnId
expect(
afterTerminal.filter(
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId,
).length,
).toBe(0);
// Any turn_started after terminal must have a different turnId
for (const ts of afterTerminal.filter((e) => e.type === "turn_started" && hasTurnId(e))) {
expect((ts as EventWithTurnId).turnId).not.toBe(turnId);
}
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
test("Test 4: Autonomous run", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-autonomous-" });
const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
try {
const { turnId: fgTurnId, events } = await startTurnAndCollectEvents(
handle.session,
[
"Use the Task tool to start a background sub-agent.",
"In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE",
"Do not wait for task completion.",
"Reply immediately with exactly: SPAWNED",
`When the background task completes later, reply with exactly: ${autonomousWakeToken}`,
].join(" "),
{
extraMs: 10_000,
timeoutMs: 60_000,
},
);
const fgTerminalIdx = events.findIndex(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === fgTurnId,
);
const afterForeground = events.slice(fgTerminalIdx + 1);
// Autonomous turn_started with a different turnId
const autoStarts = afterForeground.filter(
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId,
) as EventWithTurnId[];
if (autoStarts.length === 0) {
assertInvariants(events, [fgTurnId]);
return;
}
const autoTurnId = autoStarts[0]!.turnId;
expect(fgTurnId).not.toBe(autoTurnId);
// Autonomous turn reaches terminal
expect(
afterForeground.find((e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === autoTurnId),
).toBeDefined();
assertInvariants(events, [fgTurnId]);
} finally {
await cleanupSession(handle);
}
}, 90_000);
test("Test 5: Interruption", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-interrupt-" });
try {
let turnId: string | null = null;
const events = await new Promise<AgentStreamEvent[]>((resolve, reject) => {
const collected: AgentStreamEvent[] = [];
let interrupted = false;
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error("Timed out after 45000ms waiting for terminal event"));
}, 45_000);
const unsubscribe = handle.session.subscribe((event) => {
collected.push(event);
if (!turnId && event.type === "turn_started" && hasTurnId(event)) {
turnId = event.turnId;
}
// Once we see turn_started, fire the interrupt
if (
!interrupted &&
turnId &&
event.type === "turn_started" &&
hasTurnId(event) &&
event.turnId === turnId
) {
interrupted = true;
handle.session.interrupt().catch(() => undefined);
}
// Resolve when we get a terminal event for this turn
if (turnId && isTerminalEvent(event) && hasTurnId(event) && event.turnId === turnId) {
clearTimeout(timeout);
unsubscribe();
resolve(collected);
}
});
void handle.session
.startTurn("write a very long essay about the history of computing")
.then((result) => {
if (turnId && turnId !== result.turnId) {
clearTimeout(timeout);
unsubscribe();
reject(
new Error(
`Observed turn_started for ${turnId} but startTurn returned ${result.turnId}`,
),
);
return;
}
turnId = result.turnId;
})
.catch((error) => {
clearTimeout(timeout);
unsubscribe();
reject(error);
});
});
expect(turnId).toBeDefined();
// turn_canceled or turn_failed arrives for that turnId
const terminal = events.find(
(e) =>
(e.type === "turn_canceled" || e.type === "turn_failed") &&
hasTurnId(e) &&
e.turnId === turnId,
);
expect(terminal).toBeDefined();
// No further events for that turnId after terminal
const terminalIdx = events.indexOf(terminal!);
expect(
events.slice(terminalIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId).length,
).toBe(0);
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
test("Test 6: Sequential foreground turns", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-sequential-" });
try {
const { turnId: turnId1, events: events1 } = await startTurnAndCollectEvents(
handle.session,
"say first",
);
const { turnId: turnId2, events: events2 } = await startTurnAndCollectEvents(
handle.session,
"say second",
);
const allEvents = [...events1, ...events2];
expect(turnId1).not.toBe(turnId2);
// No events from turn 1 after turn 2 starts
const turn2StartIdx = allEvents.findIndex(
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId2,
);
expect(
allEvents.slice(turn2StartIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId1)
.length,
).toBe(0);
assertInvariants(allEvents, [turnId1, turnId2]);
} finally {
await cleanupSession(handle);
}
}, 90_000);
test("Test 7: Fast-fail", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-fast-fail-" });
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "", {
extraMs: 3_000,
});
// At most one turn_started
expect(
events.filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId)
.length,
).toBeLessThanOrEqual(1);
// Terminal present
const terminal = events.find(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
);
expect(terminal).toBeDefined();
// No stale turn_started after terminal
const terminalIdx = events.indexOf(terminal!);
expect(events.slice(terminalIdx + 1).filter((e) => e.type === "turn_started").length).toBe(0);
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
test("Test 8: User message dedup by text", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-user-dedup-" });
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "hello world", {
extraMs: 3_000,
});
expect(userMessagesWithText(events, "hello world").length).toBeLessThanOrEqual(1);
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
beforeAll(async () => {
canRun = (await isCommandAvailable("claude")) && hasClaudeCredentials;
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("Test 1: Basic foreground turn", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-basic-" });
try {
const { turnId, events } = await startTurnAndCollectEvents(
handle.session,
"respond with just the word hello",
);
const turnStarted = events.find(
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId,
);
expect(turnStarted).toBeDefined();
const terminal = events.find((e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId);
expect(terminal).toBeDefined();
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
test("Test 2: No duplicate user_messages — THE BUG", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-dedup-" });
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", {
extraMs: 3_000,
});
expect(userMessagesWithText(events, "say hi").length).toBeLessThanOrEqual(1);
// No turn_started after terminal for the same turnId
const terminalIdx = events.findIndex(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
);
const staleTurnStarted = events
.slice(terminalIdx + 1)
.filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId);
expect(staleTurnStarted.length).toBe(0);
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
test("Test 3: Lifecycle doesn't get stuck in running", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-lifecycle-" });
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", {
extraMs: 3_000,
});
const terminalIdx = events.findIndex(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
);
const afterTerminal = events.slice(terminalIdx + 1);
// No subsequent turn_started for same turnId
expect(
afterTerminal.filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId)
.length,
).toBe(0);
// Any turn_started after terminal must have a different turnId
for (const ts of afterTerminal.filter((e) => e.type === "turn_started" && hasTurnId(e))) {
expect((ts as EventWithTurnId).turnId).not.toBe(turnId);
}
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
test("Test 4: Autonomous run", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-autonomous-" });
const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
try {
const { turnId: fgTurnId, events } = await startTurnAndCollectEvents(
handle.session,
[
"Use the Task tool to start a background sub-agent.",
"In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE",
"Do not wait for task completion.",
"Reply immediately with exactly: SPAWNED",
`When the background task completes later, reply with exactly: ${autonomousWakeToken}`,
].join(" "),
{
extraMs: 10_000,
timeoutMs: 60_000,
},
);
const fgTerminalIdx = events.findIndex(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === fgTurnId,
);
const afterForeground = events.slice(fgTerminalIdx + 1);
// Autonomous turn_started with a different turnId
const autoStarts = afterForeground.filter(
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId,
) as EventWithTurnId[];
if (autoStarts.length === 0) {
assertInvariants(events, [fgTurnId]);
return;
}
const autoTurnId = autoStarts[0]!.turnId;
expect(fgTurnId).not.toBe(autoTurnId);
// Autonomous turn reaches terminal
expect(
afterForeground.find((e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === autoTurnId),
).toBeDefined();
assertInvariants(events, [fgTurnId]);
} finally {
await cleanupSession(handle);
}
}, 90_000);
test("Test 5: Interruption", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-interrupt-" });
try {
let turnId: string | null = null;
const events = await new Promise<AgentStreamEvent[]>((resolve, reject) => {
const collected: AgentStreamEvent[] = [];
let interrupted = false;
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error("Timed out after 45000ms waiting for terminal event"));
}, 45_000);
const unsubscribe = handle.session.subscribe((event) => {
collected.push(event);
if (!turnId && event.type === "turn_started" && hasTurnId(event)) {
turnId = event.turnId;
}
// Once we see turn_started, fire the interrupt
if (
!interrupted &&
turnId &&
event.type === "turn_started" &&
hasTurnId(event) &&
event.turnId === turnId
) {
interrupted = true;
handle.session.interrupt().catch(() => undefined);
}
// Resolve when we get a terminal event for this turn
if (turnId && isTerminalEvent(event) && hasTurnId(event) && event.turnId === turnId) {
clearTimeout(timeout);
unsubscribe();
resolve(collected);
}
});
void handle.session
.startTurn("write a very long essay about the history of computing")
.then((result) => {
if (turnId && turnId !== result.turnId) {
clearTimeout(timeout);
unsubscribe();
reject(
new Error(
`Observed turn_started for ${turnId} but startTurn returned ${result.turnId}`,
),
);
return;
}
turnId = result.turnId;
})
.catch((error) => {
clearTimeout(timeout);
unsubscribe();
reject(error);
});
});
expect(turnId).toBeDefined();
// turn_canceled or turn_failed arrives for that turnId
const terminal = events.find(
(e) =>
(e.type === "turn_canceled" || e.type === "turn_failed") &&
hasTurnId(e) &&
e.turnId === turnId,
);
expect(terminal).toBeDefined();
// No further events for that turnId after terminal
const terminalIdx = events.indexOf(terminal!);
expect(
events.slice(terminalIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId).length,
).toBe(0);
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
test("Test 6: Sequential foreground turns", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-sequential-" });
try {
const { turnId: turnId1, events: events1 } = await startTurnAndCollectEvents(
handle.session,
"say first",
);
const { turnId: turnId2, events: events2 } = await startTurnAndCollectEvents(
handle.session,
"say second",
);
const allEvents = [...events1, ...events2];
expect(turnId1).not.toBe(turnId2);
// No events from turn 1 after turn 2 starts
const turn2StartIdx = allEvents.findIndex(
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId2,
);
expect(
allEvents.slice(turn2StartIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId1).length,
).toBe(0);
assertInvariants(allEvents, [turnId1, turnId2]);
} finally {
await cleanupSession(handle);
}
}, 90_000);
test("Test 7: Fast-fail", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-fast-fail-" });
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "", {
extraMs: 3_000,
});
// At most one turn_started
expect(
events.filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId).length,
).toBeLessThanOrEqual(1);
// Terminal present
const terminal = events.find((e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId);
expect(terminal).toBeDefined();
// No stale turn_started after terminal
const terminalIdx = events.indexOf(terminal!);
expect(events.slice(terminalIdx + 1).filter((e) => e.type === "turn_started").length).toBe(0);
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
test("Test 8: User message dedup by text", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-user-dedup-" });
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "hello world", {
extraMs: 3_000,
});
expect(userMessagesWithText(events, "hello world").length).toBeLessThanOrEqual(1);
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
@@ -248,403 +248,395 @@ afterEach(() => {
sdkMocks.query.mockReset();
});
describe("ClaudeAgentSession interrupt regression", () => {
test("interrupt only calls query.interrupt and leaves the query open", async () => {
const logger = createTestLogger();
const queries: ScriptedQuery[] = [];
test("interrupt only calls query.interrupt and leaves the query open", async () => {
const logger = createTestLogger();
const queries: ScriptedQuery[] = [];
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
const scriptedQuery = createScriptedQuery({
prompt,
sessionId: "interrupt-keep-query-session",
});
queries.push(scriptedQuery);
return scriptedQuery;
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
const scriptedQuery = createScriptedQuery({
prompt,
sessionId: "interrupt-keep-query-session",
});
const client = new ClaudeAgentClient({ logger });
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
const firstTurn = streamSession(session, "first prompt");
await firstTurn.next();
await waitFor(() => queries[0]?.prompts.length === 1);
await session.interrupt();
await waitFor(() => queries[0]?.interrupt.mock.calls.length === 1);
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
expect(queries[0]?.return).not.toHaveBeenCalled();
const firstTurnEvents = await collectUntilTerminal(firstTurn);
expect(firstTurnEvents.find((event) => event.type === "turn_canceled")).toMatchObject({
type: "turn_canceled",
provider: "claude",
reason: "Interrupted",
});
await session.close();
queries.push(scriptedQuery);
return scriptedQuery;
});
test("reuses the existing query after interrupt before starting the next prompt", async () => {
const logger = createTestLogger();
const queries: ScriptedQuery[] = [];
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
const scriptedQuery = createScriptedQuery({
prompt,
sessionId: "interrupt-reuse-query-session",
async handlePrompt({ promptRecord, query }) {
if (promptRecord.text !== "second prompt") {
return;
}
query.emit({
type: "assistant",
message: { content: "SECOND_PROMPT_RESPONSE" },
session_id: "interrupt-reuse-query-session",
});
query.emit(buildSuccessResult("interrupt-reuse-query-session"));
},
});
queries.push(scriptedQuery);
return scriptedQuery;
});
const client = new ClaudeAgentClient({ logger });
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
const firstTurn = streamSession(session, "first prompt");
await firstTurn.next();
await waitFor(() => queries[0]?.prompts.length === 1);
await session.interrupt();
await collectUntilTerminal(firstTurn);
const secondTurnEvents = await collectUntilTerminal(streamSession(session, "second prompt"));
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
expect(queries[0]?.prompts.map((prompt) => prompt.text)).toEqual([
"first prompt",
"second prompt",
]);
expect(queries[0]?.interrupt).toHaveBeenCalledTimes(1);
expect(queries[0]?.return).not.toHaveBeenCalled();
expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE");
await session.close();
const client = new ClaudeAgentClient({ logger });
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
test("recovers when the query pump sees a single interrupt abort before the next prompt", async () => {
const logger = createTestLogger();
const output = createAsyncQueue<Record<string, unknown>>();
const prompts: PromptRecord[] = [];
let throwAbortOnNext = false;
const firstTurn = streamSession(session, "first prompt");
await firstTurn.next();
await waitFor(() => queries[0]?.prompts.length === 1);
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
const scriptedQuery = {
next: vi.fn(async () => {
if (throwAbortOnNext) {
throwAbortOnNext = false;
throw new Error("Request was aborted.");
}
return output.next();
}),
interrupt: vi.fn(async () => {
throwAbortOnNext = true;
}),
return: vi.fn(async () => {
output.end();
}),
setPermissionMode: vi.fn(async () => undefined),
setModel: vi.fn(async () => undefined),
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
supportedCommands: vi.fn(async () => []),
rewindFiles: vi.fn(async () => ({ canRewind: true })),
emit: (message: Record<string, unknown>) => {
output.push(message);
},
end: () => {
output.end();
},
prompts,
[Symbol.asyncIterator]() {
return this;
},
} satisfies ScriptedQuery;
await session.interrupt();
await waitFor(() => queries[0]?.interrupt.mock.calls.length === 1);
scriptedQuery.emit({
type: "system",
subtype: "init",
session_id: "interrupt-abort-recovery-session",
permissionMode: "default",
model: "opus",
});
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
expect(queries[0]?.return).not.toHaveBeenCalled();
void (async () => {
for await (const promptMessage of prompt) {
const record = promptMessage as Record<string, unknown>;
const promptRecord = {
text: extractPromptText(record),
uuid: typeof record.uuid === "string" ? record.uuid : null,
};
prompts.push(promptRecord);
if (promptRecord.text !== "second prompt") {
continue;
}
output.push({
type: "assistant",
message: { content: "SECOND_PROMPT_RESPONSE" },
session_id: "interrupt-abort-recovery-session",
});
output.push(buildSuccessResult("interrupt-abort-recovery-session"));
}
})();
return scriptedQuery;
});
const client = new ClaudeAgentClient({ logger });
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
const firstTurn = streamSession(session, "first prompt");
await firstTurn.next();
await session.interrupt();
await collectUntilTerminal(firstTurn);
const secondTurnEvents = await collectUntilTerminal(streamSession(session, "second prompt"));
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
expect(prompts.map((prompt) => prompt.text)).toEqual(["first prompt", "second prompt"]);
expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE");
expect(secondTurnEvents.some((event) => event.type === "turn_completed")).toBe(true);
await session.close();
const firstTurnEvents = await collectUntilTerminal(firstTurn);
expect(firstTurnEvents.find((event) => event.type === "turn_canceled")).toMatchObject({
type: "turn_canceled",
provider: "claude",
reason: "Interrupted",
});
test("stale abort result after replacement start does not poison the new foreground turn", async () => {
const logger = createTestLogger();
let queryRef: ScriptedQuery | null = null;
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
queryRef = createScriptedQuery({
prompt,
sessionId: "interrupt-stale-result-session",
});
return queryRef;
});
const client = new ClaudeAgentClient({ logger });
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
const firstTurn = streamSession(session, "first prompt");
const firstStarted = await firstTurn.next();
await waitFor(() => queryRef?.prompts.length === 1);
await session.interrupt();
const firstTurnEvents = [firstStarted.value!, ...(await collectUntilTerminal(firstTurn))];
expect(firstTurnEvents.some((event) => event.type === "turn_canceled")).toBe(true);
const observedSecondTurnEvents: AgentStreamEvent[] = [];
const unsubscribe = session.subscribe((event) => {
observedSecondTurnEvents.push(event);
});
const secondTurn = streamSession(session, "second prompt");
const secondStarted = await secondTurn.next();
await waitFor(() => queryRef?.prompts.length === 2);
queryRef?.emit({
type: "result",
subtype: "error_during_execution",
errors: ["Request was aborted."],
session_id: "interrupt-stale-result-session",
});
queryRef?.emit({
type: "assistant",
message: { content: "SECOND_PROMPT_RESPONSE" },
session_id: "interrupt-stale-result-session",
});
queryRef?.emit(buildSuccessResult("interrupt-stale-result-session"));
const secondTurnEvents = [secondStarted.value!, ...(await collectUntilTerminal(secondTurn))];
unsubscribe();
expect(secondTurnEvents.some((event) => event.type === "turn_failed")).toBe(false);
expect(secondTurnEvents.some((event) => event.type === "turn_canceled")).toBe(false);
expect(secondTurnEvents.some((event) => event.type === "turn_completed")).toBe(true);
expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE");
expect(observedSecondTurnEvents.filter((event) => event.type === "turn_started").length).toBe(
1,
);
expect(
observedSecondTurnEvents.some(
(event) => event.type === "turn_failed" || event.type === "turn_canceled",
),
).toBe(false);
await session.close();
});
await session.close();
});
describe("ClaudeAgentSession autonomous turns", () => {
test("creates an autonomous live turn when assistant output arrives without a foreground run", async () => {
const logger = createTestLogger();
let queryRef: ScriptedQuery | null = null;
test("reuses the existing query after interrupt before starting the next prompt", async () => {
const logger = createTestLogger();
const queries: ScriptedQuery[] = [];
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
queryRef = createScriptedQuery({
prompt,
sessionId: "autonomous-live-session",
async handlePrompt({ promptRecord, query }) {
if (promptRecord.text !== "seed prompt") {
return;
}
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
const scriptedQuery = createScriptedQuery({
prompt,
sessionId: "interrupt-reuse-query-session",
async handlePrompt({ promptRecord, query }) {
if (promptRecord.text !== "second prompt") {
return;
}
query.emit({
type: "assistant",
message: { content: "SECOND_PROMPT_RESPONSE" },
session_id: "interrupt-reuse-query-session",
});
query.emit(buildSuccessResult("interrupt-reuse-query-session"));
},
});
queries.push(scriptedQuery);
return scriptedQuery;
});
const client = new ClaudeAgentClient({ logger });
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
const firstTurn = streamSession(session, "first prompt");
await firstTurn.next();
await waitFor(() => queries[0]?.prompts.length === 1);
await session.interrupt();
await collectUntilTerminal(firstTurn);
const secondTurnEvents = await collectUntilTerminal(streamSession(session, "second prompt"));
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
expect(queries[0]?.prompts.map((prompt) => prompt.text)).toEqual([
"first prompt",
"second prompt",
]);
expect(queries[0]?.interrupt).toHaveBeenCalledTimes(1);
expect(queries[0]?.return).not.toHaveBeenCalled();
expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE");
await session.close();
});
test("recovers when the query pump sees a single interrupt abort before the next prompt", async () => {
const logger = createTestLogger();
const output = createAsyncQueue<Record<string, unknown>>();
const prompts: PromptRecord[] = [];
let throwAbortOnNext = false;
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
const scriptedQuery = {
next: vi.fn(async () => {
if (throwAbortOnNext) {
throwAbortOnNext = false;
throw new Error("Request was aborted.");
}
return output.next();
}),
interrupt: vi.fn(async () => {
throwAbortOnNext = true;
}),
return: vi.fn(async () => {
output.end();
}),
setPermissionMode: vi.fn(async () => undefined),
setModel: vi.fn(async () => undefined),
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
supportedCommands: vi.fn(async () => []),
rewindFiles: vi.fn(async () => ({ canRewind: true })),
emit: (message: Record<string, unknown>) => {
output.push(message);
},
end: () => {
output.end();
},
prompts,
[Symbol.asyncIterator]() {
return this;
},
} satisfies ScriptedQuery;
scriptedQuery.emit({
type: "system",
subtype: "init",
session_id: "interrupt-abort-recovery-session",
permissionMode: "default",
model: "opus",
});
void (async () => {
for await (const promptMessage of prompt) {
const record = promptMessage as Record<string, unknown>;
const promptRecord = {
text: extractPromptText(record),
uuid: typeof record.uuid === "string" ? record.uuid : null,
};
prompts.push(promptRecord);
if (promptRecord.text !== "second prompt") {
continue;
}
output.push({
type: "assistant",
message: { content: "SECOND_PROMPT_RESPONSE" },
session_id: "interrupt-abort-recovery-session",
});
output.push(buildSuccessResult("interrupt-abort-recovery-session"));
}
})();
return scriptedQuery;
});
const client = new ClaudeAgentClient({ logger });
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
const firstTurn = streamSession(session, "first prompt");
await firstTurn.next();
await session.interrupt();
await collectUntilTerminal(firstTurn);
const secondTurnEvents = await collectUntilTerminal(streamSession(session, "second prompt"));
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
expect(prompts.map((prompt) => prompt.text)).toEqual(["first prompt", "second prompt"]);
expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE");
expect(secondTurnEvents.some((event) => event.type === "turn_completed")).toBe(true);
await session.close();
});
test("stale abort result after replacement start does not poison the new foreground turn", async () => {
const logger = createTestLogger();
let queryRef: ScriptedQuery | null = null;
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
queryRef = createScriptedQuery({
prompt,
sessionId: "interrupt-stale-result-session",
});
return queryRef;
});
const client = new ClaudeAgentClient({ logger });
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
const firstTurn = streamSession(session, "first prompt");
const firstStarted = await firstTurn.next();
await waitFor(() => queryRef?.prompts.length === 1);
await session.interrupt();
const firstTurnEvents = [firstStarted.value!, ...(await collectUntilTerminal(firstTurn))];
expect(firstTurnEvents.some((event) => event.type === "turn_canceled")).toBe(true);
const observedSecondTurnEvents: AgentStreamEvent[] = [];
const unsubscribe = session.subscribe((event) => {
observedSecondTurnEvents.push(event);
});
const secondTurn = streamSession(session, "second prompt");
const secondStarted = await secondTurn.next();
await waitFor(() => queryRef?.prompts.length === 2);
queryRef?.emit({
type: "result",
subtype: "error_during_execution",
errors: ["Request was aborted."],
session_id: "interrupt-stale-result-session",
});
queryRef?.emit({
type: "assistant",
message: { content: "SECOND_PROMPT_RESPONSE" },
session_id: "interrupt-stale-result-session",
});
queryRef?.emit(buildSuccessResult("interrupt-stale-result-session"));
const secondTurnEvents = [secondStarted.value!, ...(await collectUntilTerminal(secondTurn))];
unsubscribe();
expect(secondTurnEvents.some((event) => event.type === "turn_failed")).toBe(false);
expect(secondTurnEvents.some((event) => event.type === "turn_canceled")).toBe(false);
expect(secondTurnEvents.some((event) => event.type === "turn_completed")).toBe(true);
expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE");
expect(observedSecondTurnEvents.filter((event) => event.type === "turn_started").length).toBe(1);
expect(
observedSecondTurnEvents.some(
(event) => event.type === "turn_failed" || event.type === "turn_canceled",
),
).toBe(false);
await session.close();
});
test("creates an autonomous live turn when assistant output arrives without a foreground run", async () => {
const logger = createTestLogger();
let queryRef: ScriptedQuery | null = null;
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
queryRef = createScriptedQuery({
prompt,
sessionId: "autonomous-live-session",
async handlePrompt({ promptRecord, query }) {
if (promptRecord.text !== "seed prompt") {
return;
}
query.emit({
type: "assistant",
message: { content: "SEED_RESPONSE" },
session_id: "autonomous-live-session",
});
query.emit(buildSuccessResult("autonomous-live-session"));
},
});
return queryRef;
});
const client = new ClaudeAgentClient({ logger });
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
await collectUntilTerminal(streamSession(session, "seed prompt"));
const subscribedEvents = subscribeToEvents(session);
queryRef?.emit({
type: "assistant",
message: { content: "AUTONOMOUS_WAKE_RESPONSE" },
session_id: "autonomous-live-session",
});
queryRef?.emit(buildSuccessResult("autonomous-live-session"));
const started = await subscribedEvents.next();
const timeline = await subscribedEvents.next();
const completed = await subscribedEvents.next();
expect(started.value).toMatchObject({ type: "turn_started", provider: "claude" });
expect(timeline.value).toMatchObject({
type: "timeline",
provider: "claude",
item: {
type: "assistant_message",
text: "AUTONOMOUS_WAKE_RESPONSE",
},
});
expect(completed.value).toMatchObject({
type: "turn_completed",
provider: "claude",
});
subscribedEvents.close();
await session.close();
});
test("auto-completes an open autonomous turn when a foreground prompt starts", async () => {
const logger = createTestLogger();
let queryRef: ScriptedQuery | null = null;
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
queryRef = createScriptedQuery({
prompt,
sessionId: "autonomous-handoff-session",
async handlePrompt({ promptRecord, query }) {
if (promptRecord.text === "seed prompt") {
query.emit({
type: "assistant",
message: { content: "SEED_RESPONSE" },
session_id: "autonomous-live-session",
session_id: "autonomous-handoff-session",
});
query.emit(buildSuccessResult("autonomous-live-session"));
},
});
return queryRef;
});
query.emit(buildSuccessResult("autonomous-handoff-session"));
return;
}
const client = new ClaudeAgentClient({ logger });
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
await collectUntilTerminal(streamSession(session, "seed prompt"));
const subscribedEvents = subscribeToEvents(session);
queryRef?.emit({
type: "assistant",
message: { content: "AUTONOMOUS_WAKE_RESPONSE" },
session_id: "autonomous-live-session",
});
queryRef?.emit(buildSuccessResult("autonomous-live-session"));
const started = await subscribedEvents.next();
const timeline = await subscribedEvents.next();
const completed = await subscribedEvents.next();
expect(started.value).toMatchObject({ type: "turn_started", provider: "claude" });
expect(timeline.value).toMatchObject({
type: "timeline",
provider: "claude",
item: {
type: "assistant_message",
text: "AUTONOMOUS_WAKE_RESPONSE",
if (promptRecord.text === "foreground prompt") {
query.emit({
type: "assistant",
message: { content: "FOREGROUND_RESPONSE" },
session_id: "autonomous-handoff-session",
});
query.emit(buildSuccessResult("autonomous-handoff-session"));
}
},
});
expect(completed.value).toMatchObject({
type: "turn_completed",
provider: "claude",
});
subscribedEvents.close();
await session.close();
return queryRef;
});
test("auto-completes an open autonomous turn when a foreground prompt starts", async () => {
const logger = createTestLogger();
let queryRef: ScriptedQuery | null = null;
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
queryRef = createScriptedQuery({
prompt,
sessionId: "autonomous-handoff-session",
async handlePrompt({ promptRecord, query }) {
if (promptRecord.text === "seed prompt") {
query.emit({
type: "assistant",
message: { content: "SEED_RESPONSE" },
session_id: "autonomous-handoff-session",
});
query.emit(buildSuccessResult("autonomous-handoff-session"));
return;
}
if (promptRecord.text === "foreground prompt") {
query.emit({
type: "assistant",
message: { content: "FOREGROUND_RESPONSE" },
session_id: "autonomous-handoff-session",
});
query.emit(buildSuccessResult("autonomous-handoff-session"));
}
},
});
return queryRef;
});
const client = new ClaudeAgentClient({ logger });
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
await collectUntilTerminal(streamSession(session, "seed prompt"));
const subscribedEvents = subscribeToEvents(session);
queryRef?.emit({
type: "assistant",
message: { content: "BACKGROUND_ONLY_RESPONSE" },
session_id: "autonomous-handoff-session",
});
const autonomousStart = await subscribedEvents.next();
const autonomousTimeline = await subscribedEvents.next();
const foregroundEvents = await collectUntilTerminal(
streamSession(session, "foreground prompt"),
);
const autonomousComplete = await subscribedEvents.next();
expect(autonomousStart.value).toMatchObject({
type: "turn_started",
provider: "claude",
});
expect(autonomousTimeline.value).toMatchObject({
type: "timeline",
provider: "claude",
item: {
type: "assistant_message",
text: "BACKGROUND_ONLY_RESPONSE",
},
});
expect(autonomousComplete.value).toMatchObject({
type: "turn_completed",
provider: "claude",
});
expect(foregroundEvents.some((event) => event.type === "turn_completed")).toBe(true);
expect(collectAssistantText(foregroundEvents)).toContain("FOREGROUND_RESPONSE");
expect(
[autonomousStart.value, autonomousTimeline.value, autonomousComplete.value].some(
(event) => event?.type === "turn_canceled",
),
).toBe(false);
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
expect(queryRef?.prompts.map((prompt) => prompt.text)).toEqual([
"seed prompt",
"foreground prompt",
]);
subscribedEvents.close();
await session.close();
const client = new ClaudeAgentClient({ logger });
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
await collectUntilTerminal(streamSession(session, "seed prompt"));
const subscribedEvents = subscribeToEvents(session);
queryRef?.emit({
type: "assistant",
message: { content: "BACKGROUND_ONLY_RESPONSE" },
session_id: "autonomous-handoff-session",
});
const autonomousStart = await subscribedEvents.next();
const autonomousTimeline = await subscribedEvents.next();
const foregroundEvents = await collectUntilTerminal(streamSession(session, "foreground prompt"));
const autonomousComplete = await subscribedEvents.next();
expect(autonomousStart.value).toMatchObject({
type: "turn_started",
provider: "claude",
});
expect(autonomousTimeline.value).toMatchObject({
type: "timeline",
provider: "claude",
item: {
type: "assistant_message",
text: "BACKGROUND_ONLY_RESPONSE",
},
});
expect(autonomousComplete.value).toMatchObject({
type: "turn_completed",
provider: "claude",
});
expect(foregroundEvents.some((event) => event.type === "turn_completed")).toBe(true);
expect(collectAssistantText(foregroundEvents)).toContain("FOREGROUND_RESPONSE");
expect(
[autonomousStart.value, autonomousTimeline.value, autonomousComplete.value].some(
(event) => event?.type === "turn_canceled",
),
).toBe(false);
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
expect(queryRef?.prompts.map((prompt) => prompt.text)).toEqual([
"seed prompt",
"foreground prompt",
]);
subscribedEvents.close();
await session.close();
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -9,88 +9,86 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { OpenCodeAgentClient, OpenCodeServerManager } from "./opencode-agent.js";
describe("OpenCodeAgentClient.listModels timeout", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
test("allows a slow provider.list call to succeed instead of failing after 10 seconds", async () => {
vi.useFakeTimers();
test("allows a slow provider.list call to succeed instead of failing after 10 seconds", async () => {
vi.useFakeTimers();
const providerList = vi.fn(
() =>
new Promise((resolve) => {
setTimeout(() => {
resolve({
data: {
connected: ["zai"],
all: [
{
id: "zai",
name: "Z.AI",
models: {
"glm-5.1": {
name: "GLM 5.1",
limit: { context: 128_000 },
},
const providerList = vi.fn(
() =>
new Promise((resolve) => {
setTimeout(() => {
resolve({
data: {
connected: ["zai"],
all: [
{
id: "zai",
name: "Z.AI",
models: {
"glm-5.1": {
name: "GLM 5.1",
limit: { context: 128_000 },
},
},
],
},
});
}, 15_000);
}),
);
vi.mocked(createOpencodeClient).mockReturnValue({
provider: {
list: providerList,
},
} as never);
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
acquire: vi.fn().mockResolvedValue({
server: { port: 1234, url: "http://127.0.0.1:1234" },
release: vi.fn(),
},
],
},
});
}, 15_000);
}),
} as never);
);
const client = new OpenCodeAgentClient(createTestLogger());
const modelsPromise = client.listModels({ cwd: "/tmp/opencode-models", force: false });
vi.mocked(createOpencodeClient).mockReturnValue({
provider: {
list: providerList,
},
} as never);
await vi.advanceTimersByTimeAsync(15_000);
await expect(modelsPromise).resolves.toMatchObject([
{
provider: "opencode",
id: "zai/glm-5.1",
label: "GLM 5.1",
},
]);
});
test("passes explicit refresh force through server acquisition", async () => {
vi.mocked(createOpencodeClient).mockReturnValue({
provider: {
list: vi.fn().mockResolvedValue({
data: {
connected: ["openai"],
all: [{ id: "openai", name: "OpenAI", models: {} }],
},
}),
},
} as never);
const acquire = vi.fn().mockResolvedValue({
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
acquire: vi.fn().mockResolvedValue({
server: { port: 1234, url: "http://127.0.0.1:1234" },
release: vi.fn(),
});
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({ acquire } as never);
}),
} as never);
const client = new OpenCodeAgentClient(createTestLogger());
const client = new OpenCodeAgentClient(createTestLogger());
const modelsPromise = client.listModels({ cwd: "/tmp/opencode-models", force: false });
await client.listModels({ cwd: "/tmp/opencode-models", force: true });
await vi.advanceTimersByTimeAsync(15_000);
expect(acquire).toHaveBeenCalledWith({ force: true });
await expect(modelsPromise).resolves.toMatchObject([
{
provider: "opencode",
id: "zai/glm-5.1",
label: "GLM 5.1",
},
]);
});
test("passes explicit refresh force through server acquisition", async () => {
vi.mocked(createOpencodeClient).mockReturnValue({
provider: {
list: vi.fn().mockResolvedValue({
data: {
connected: ["openai"],
all: [{ id: "openai", name: "OpenAI", models: {} }],
},
}),
},
} as never);
const acquire = vi.fn().mockResolvedValue({
server: { port: 1234, url: "http://127.0.0.1:1234" },
release: vi.fn(),
});
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({ acquire } as never);
const client = new OpenCodeAgentClient(createTestLogger());
await client.listModels({ cwd: "/tmp/opencode-models", force: true });
expect(acquire).toHaveBeenCalledWith({ force: true });
});
File diff suppressed because it is too large Load Diff
@@ -13,381 +13,379 @@ function tmpCwd(): string {
const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
let messages: SessionOutboundMessage[] = [];
let unsubscribe: (() => void) | null = null;
beforeEach(async () => {
ctx = await createDaemonTestContext();
messages = [];
unsubscribe = ctx.client.subscribeRawMessages((message) => {
messages.push(message);
});
});
afterEach(async () => {
unsubscribe?.();
await ctx.cleanup();
}, 60000);
describe("timestamp behavior", () => {
test("opening agent without interaction does not update timestamp", async () => {
const cwd = tmpCwd();
// Create a Codex agent
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Timestamp Test Agent",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// Record the initial updatedAt timestamp
const initialUpdatedAt = agent.updatedAt;
expect(initialUpdatedAt).toBeTruthy();
// Wait a bit to ensure any timestamp update would be visible
await new Promise((resolve) => setTimeout(resolve, 1500));
// Clear captured messages before the "click" action
messages.length = 0;
// Simulate opening the agent in the UI:
// fetch timeline window + fetch current agent snapshot.
await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 200,
});
const refreshedResult = await ctx.client.fetchAgent(agent.id);
// Verify agent is still idle
expect(refreshedResult?.agent.status).toBe("idle");
// CRITICAL: The timestamp should NOT have changed
// Just opening/clicking an agent should not update its updatedAt
expect(refreshedResult?.agent.updatedAt).toBe(initialUpdatedAt);
// Also clear attention (what happens when opening an agent with notification)
await ctx.client.clearAgentAttention(agent.id);
// Get the state again after clearing attention
await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 200,
});
const clearResult = await ctx.client.fetchAgent(agent.id);
// Timestamp should STILL not have changed
expect(clearResult?.agent.updatedAt).toBe(initialUpdatedAt);
// Cleanup
rmSync(cwd, { recursive: true, force: true });
}, 60000);
test("sending message DOES update timestamp", async () => {
const cwd = tmpCwd();
// Create a Codex agent
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Timestamp Update Test Agent",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// Record the initial updatedAt timestamp
const initialUpdatedAt = new Date(agent.updatedAt);
// Wait a bit to ensure timestamp difference is visible
await new Promise((resolve) => setTimeout(resolve, 1500));
// Send a message (this SHOULD update the timestamp)
await ctx.client.sendMessage(agent.id, "Say 'test' and nothing else");
// Wait for agent to complete
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
expect(finalState.status).toBe("idle");
// The timestamp SHOULD have been updated (should be later than initial)
const finalUpdatedAt = new Date(finalState.final?.updatedAt ?? 0);
expect(finalUpdatedAt.getTime()).toBeGreaterThan(initialUpdatedAt.getTime());
// Cleanup
rmSync(cwd, { recursive: true, force: true });
}, 180000);
});
describe("cancelAgent", () => {
test("cancels a running agent mid-execution", async () => {
const cwd = tmpCwd();
await ctx.client.fetchAgents({
subscribe: { subscriptionId: "agent-operations-cancel" },
});
// Create Codex agent
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Cancel Test Agent",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// Clear message queue before sending prompt
messages.length = 0;
// Send a prompt that triggers a long-running operation
await ctx.client.sendMessage(agent.id, "Run: sleep 30");
// Wait for the agent to begin running (fetch_agent RPC; no agent_update subscription race)
await ctx.client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
30000,
);
// Record timestamp before cancel
const cancelStart = Date.now();
// Cancel the agent
await ctx.client.cancelAgent(agent.id);
// Wait for agent to reach idle or error state via server wait RPC
const afterCancel = await ctx.client.waitForFinish(agent.id, 10000);
// Calculate how long the cancel took
const cancelDuration = Date.now() - cancelStart;
// Verify agent stopped within reasonable time (2 seconds)
expect(cancelDuration).toBeLessThan(3000);
// Verify agent is now idle or error
expect(["idle", "error"]).toContain(afterCancel.status);
// Verify no zombie sleep processes left (check for sleep 30)
const { execSync } = await import("child_process");
try {
const result = execSync("pgrep -f 'sleep 30'", {
encoding: "utf8",
timeout: 2000,
});
// If pgrep succeeds, there are zombie processes
if (result.trim()) {
// Kill them and fail the test
execSync("pkill -f 'sleep 30'");
expect.fail("Found zombie sleep processes after cancel");
}
} catch {
// pgrep returns non-zero when no processes found - this is expected
}
// Cleanup
rmSync(cwd, { recursive: true, force: true });
}, 60000);
});
describe("setAgentMode", () => {
test("switches agent mode and persists across messages", async () => {
const cwd = tmpCwd();
await ctx.client.fetchAgents({
subscribe: { subscriptionId: "agent-operations-mode" },
});
// Create a Codex agent with default mode ("auto")
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Mode Switch Test Agent",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// Verify initial mode is "auto" (the default)
expect(agent.currentModeId).toBe("auto");
// Clear message queue before mode switch
messages.length = 0;
const startPosition = messages.length;
// Switch to "read-only" mode
await ctx.client.setAgentMode(agent.id, "read-only");
// Wait for agent_update upsert reflecting the new mode
const stateAfterModeSwitch = await new Promise<AgentSnapshotPayload>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Timeout waiting for mode change in agent_update"));
}, 10000);
const checkForModeChange = (): void => {
const queue = messages;
for (let i = startPosition; i < queue.length; i++) {
const msg = queue[i];
if (
msg.type === "agent_update" &&
msg.payload.kind === "upsert" &&
msg.payload.agent.id === agent.id &&
msg.payload.agent.currentModeId === "read-only"
) {
clearTimeout(timeout);
clearInterval(interval);
resolve(msg.payload.agent);
return;
}
}
};
const interval = setInterval(checkForModeChange, 50);
});
// Verify mode changed to "read-only"
expect(stateAfterModeSwitch.currentModeId).toBe("read-only");
// Now verify the mode persists: send a message and check the mode is still "read-only"
messages.length = 0;
await ctx.client.sendMessage(agent.id, "Say 'hello' and nothing else");
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
// Mode should still be "read-only" after the message
expect(finalState.final?.currentModeId).toBe("read-only");
// Also verify runtimeInfo has the updated modeId
expect(finalState.final?.runtimeInfo?.modeId).toBe("read-only");
// Switch to another mode: "full-access"
messages.length = 0;
const position2 = messages.length;
await ctx.client.setAgentMode(agent.id, "full-access");
// Wait for agent_update upsert
const stateAfterFullAccess = await new Promise<AgentSnapshotPayload>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Timeout waiting for full-access mode change"));
}, 10000);
const checkForModeChange = (): void => {
const queue = messages;
for (let i = position2; i < queue.length; i++) {
const msg = queue[i];
if (
msg.type === "agent_update" &&
msg.payload.kind === "upsert" &&
msg.payload.agent.id === agent.id &&
msg.payload.agent.currentModeId === "full-access"
) {
clearTimeout(timeout);
clearInterval(interval);
resolve(msg.payload.agent);
return;
}
}
};
const interval = setInterval(checkForModeChange, 50);
});
expect(stateAfterFullAccess.currentModeId).toBe("full-access");
// Cleanup
rmSync(cwd, { recursive: true, force: true });
}, 30_000);
});
describe("listAgents", () => {
test("returns current agents and reflects create/delete operations", async () => {
const cwd1 = tmpCwd();
const cwd2 = tmpCwd();
// Initially, there should be no agents (fresh session)
const initialAgents = await ctx.client.fetchAgents();
expect(initialAgents.entries).toHaveLength(0);
// Create first agent
const agent1 = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: cwd1,
title: "List Test Agent 1",
});
expect(agent1.id).toBeTruthy();
expect(agent1.status).toBe("idle");
// fetchAgents should now return 1 agent
const afterFirst = await ctx.client.fetchAgents();
expect(afterFirst.entries).toHaveLength(1);
expect(afterFirst.entries[0]?.agent.id).toBe(agent1.id);
// Title may or may not be set depending on timing
expect(afterFirst.entries[0]?.agent.cwd).toBe(cwd1);
// Create second agent
const agent2 = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: cwd2,
title: "List Test Agent 2",
});
expect(agent2.id).toBeTruthy();
expect(agent2.status).toBe("idle");
// fetchAgents should now return 2 agents
const afterSecond = await ctx.client.fetchAgents();
expect(afterSecond.entries).toHaveLength(2);
// Verify both agents are present with correct IDs and states
const ids = afterSecond.entries.map((entry) => entry.agent.id);
expect(ids).toContain(agent1.id);
expect(ids).toContain(agent2.id);
const agent1State = afterSecond.entries.find((entry) => entry.agent.id === agent1.id)?.agent;
const agent2State = afterSecond.entries.find((entry) => entry.agent.id === agent2.id)?.agent;
// Title may or may not be set depending on timing
expect(agent1State?.cwd).toBe(cwd1);
expect(agent1State?.status).toBe("idle");
// Title may or may not be set depending on timing
expect(agent2State?.cwd).toBe(cwd2);
expect(agent2State?.status).toBe("idle");
// Delete first agent
await ctx.client.deleteAgent(agent1.id);
// fetchAgents should now return only 1 agent
const afterDelete = await ctx.client.fetchAgents();
expect(afterDelete.entries).toHaveLength(1);
expect(afterDelete.entries[0]?.agent.id).toBe(agent2.id);
expect(afterDelete.entries[0]?.agent.cwd).toBe(cwd2);
// Verify agent1 is no longer in the list
const deletedAgent = afterDelete.entries.find((entry) => entry.agent.id === agent1.id);
expect(deletedAgent).toBeUndefined();
// Cleanup
await ctx.client.deleteAgent(agent2.id);
rmSync(cwd1, { recursive: true, force: true });
rmSync(cwd2, { recursive: true, force: true });
}, 60000); // 1 minute timeout
let ctx: DaemonTestContext;
let messages: SessionOutboundMessage[] = [];
let unsubscribe: (() => void) | null = null;
beforeEach(async () => {
ctx = await createDaemonTestContext();
messages = [];
unsubscribe = ctx.client.subscribeRawMessages((message) => {
messages.push(message);
});
});
afterEach(async () => {
unsubscribe?.();
await ctx.cleanup();
}, 60000);
describe("timestamp behavior", () => {
test("opening agent without interaction does not update timestamp", async () => {
const cwd = tmpCwd();
// Create a Codex agent
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Timestamp Test Agent",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// Record the initial updatedAt timestamp
const initialUpdatedAt = agent.updatedAt;
expect(initialUpdatedAt).toBeTruthy();
// Wait a bit to ensure any timestamp update would be visible
await new Promise((resolve) => setTimeout(resolve, 1500));
// Clear captured messages before the "click" action
messages.length = 0;
// Simulate opening the agent in the UI:
// fetch timeline window + fetch current agent snapshot.
await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 200,
});
const refreshedResult = await ctx.client.fetchAgent(agent.id);
// Verify agent is still idle
expect(refreshedResult?.agent.status).toBe("idle");
// CRITICAL: The timestamp should NOT have changed
// Just opening/clicking an agent should not update its updatedAt
expect(refreshedResult?.agent.updatedAt).toBe(initialUpdatedAt);
// Also clear attention (what happens when opening an agent with notification)
await ctx.client.clearAgentAttention(agent.id);
// Get the state again after clearing attention
await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 200,
});
const clearResult = await ctx.client.fetchAgent(agent.id);
// Timestamp should STILL not have changed
expect(clearResult?.agent.updatedAt).toBe(initialUpdatedAt);
// Cleanup
rmSync(cwd, { recursive: true, force: true });
}, 60000);
test("sending message DOES update timestamp", async () => {
const cwd = tmpCwd();
// Create a Codex agent
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Timestamp Update Test Agent",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// Record the initial updatedAt timestamp
const initialUpdatedAt = new Date(agent.updatedAt);
// Wait a bit to ensure timestamp difference is visible
await new Promise((resolve) => setTimeout(resolve, 1500));
// Send a message (this SHOULD update the timestamp)
await ctx.client.sendMessage(agent.id, "Say 'test' and nothing else");
// Wait for agent to complete
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
expect(finalState.status).toBe("idle");
// The timestamp SHOULD have been updated (should be later than initial)
const finalUpdatedAt = new Date(finalState.final?.updatedAt ?? 0);
expect(finalUpdatedAt.getTime()).toBeGreaterThan(initialUpdatedAt.getTime());
// Cleanup
rmSync(cwd, { recursive: true, force: true });
}, 180000);
});
describe("cancelAgent", () => {
test("cancels a running agent mid-execution", async () => {
const cwd = tmpCwd();
await ctx.client.fetchAgents({
subscribe: { subscriptionId: "agent-operations-cancel" },
});
// Create Codex agent
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Cancel Test Agent",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// Clear message queue before sending prompt
messages.length = 0;
// Send a prompt that triggers a long-running operation
await ctx.client.sendMessage(agent.id, "Run: sleep 30");
// Wait for the agent to begin running (fetch_agent RPC; no agent_update subscription race)
await ctx.client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
30000,
);
// Record timestamp before cancel
const cancelStart = Date.now();
// Cancel the agent
await ctx.client.cancelAgent(agent.id);
// Wait for agent to reach idle or error state via server wait RPC
const afterCancel = await ctx.client.waitForFinish(agent.id, 10000);
// Calculate how long the cancel took
const cancelDuration = Date.now() - cancelStart;
// Verify agent stopped within reasonable time (2 seconds)
expect(cancelDuration).toBeLessThan(3000);
// Verify agent is now idle or error
expect(["idle", "error"]).toContain(afterCancel.status);
// Verify no zombie sleep processes left (check for sleep 30)
const { execSync } = await import("child_process");
try {
const result = execSync("pgrep -f 'sleep 30'", {
encoding: "utf8",
timeout: 2000,
});
// If pgrep succeeds, there are zombie processes
if (result.trim()) {
// Kill them and fail the test
execSync("pkill -f 'sleep 30'");
expect.fail("Found zombie sleep processes after cancel");
}
} catch {
// pgrep returns non-zero when no processes found - this is expected
}
// Cleanup
rmSync(cwd, { recursive: true, force: true });
}, 60000);
});
describe("setAgentMode", () => {
test("switches agent mode and persists across messages", async () => {
const cwd = tmpCwd();
await ctx.client.fetchAgents({
subscribe: { subscriptionId: "agent-operations-mode" },
});
// Create a Codex agent with default mode ("auto")
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Mode Switch Test Agent",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// Verify initial mode is "auto" (the default)
expect(agent.currentModeId).toBe("auto");
// Clear message queue before mode switch
messages.length = 0;
const startPosition = messages.length;
// Switch to "read-only" mode
await ctx.client.setAgentMode(agent.id, "read-only");
// Wait for agent_update upsert reflecting the new mode
const stateAfterModeSwitch = await new Promise<AgentSnapshotPayload>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Timeout waiting for mode change in agent_update"));
}, 10000);
const checkForModeChange = (): void => {
const queue = messages;
for (let i = startPosition; i < queue.length; i++) {
const msg = queue[i];
if (
msg.type === "agent_update" &&
msg.payload.kind === "upsert" &&
msg.payload.agent.id === agent.id &&
msg.payload.agent.currentModeId === "read-only"
) {
clearTimeout(timeout);
clearInterval(interval);
resolve(msg.payload.agent);
return;
}
}
};
const interval = setInterval(checkForModeChange, 50);
});
// Verify mode changed to "read-only"
expect(stateAfterModeSwitch.currentModeId).toBe("read-only");
// Now verify the mode persists: send a message and check the mode is still "read-only"
messages.length = 0;
await ctx.client.sendMessage(agent.id, "Say 'hello' and nothing else");
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
// Mode should still be "read-only" after the message
expect(finalState.final?.currentModeId).toBe("read-only");
// Also verify runtimeInfo has the updated modeId
expect(finalState.final?.runtimeInfo?.modeId).toBe("read-only");
// Switch to another mode: "full-access"
messages.length = 0;
const position2 = messages.length;
await ctx.client.setAgentMode(agent.id, "full-access");
// Wait for agent_update upsert
const stateAfterFullAccess = await new Promise<AgentSnapshotPayload>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Timeout waiting for full-access mode change"));
}, 10000);
const checkForModeChange = (): void => {
const queue = messages;
for (let i = position2; i < queue.length; i++) {
const msg = queue[i];
if (
msg.type === "agent_update" &&
msg.payload.kind === "upsert" &&
msg.payload.agent.id === agent.id &&
msg.payload.agent.currentModeId === "full-access"
) {
clearTimeout(timeout);
clearInterval(interval);
resolve(msg.payload.agent);
return;
}
}
};
const interval = setInterval(checkForModeChange, 50);
});
expect(stateAfterFullAccess.currentModeId).toBe("full-access");
// Cleanup
rmSync(cwd, { recursive: true, force: true });
}, 30_000);
});
describe("listAgents", () => {
test("returns current agents and reflects create/delete operations", async () => {
const cwd1 = tmpCwd();
const cwd2 = tmpCwd();
// Initially, there should be no agents (fresh session)
const initialAgents = await ctx.client.fetchAgents();
expect(initialAgents.entries).toHaveLength(0);
// Create first agent
const agent1 = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: cwd1,
title: "List Test Agent 1",
});
expect(agent1.id).toBeTruthy();
expect(agent1.status).toBe("idle");
// fetchAgents should now return 1 agent
const afterFirst = await ctx.client.fetchAgents();
expect(afterFirst.entries).toHaveLength(1);
expect(afterFirst.entries[0]?.agent.id).toBe(agent1.id);
// Title may or may not be set depending on timing
expect(afterFirst.entries[0]?.agent.cwd).toBe(cwd1);
// Create second agent
const agent2 = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: cwd2,
title: "List Test Agent 2",
});
expect(agent2.id).toBeTruthy();
expect(agent2.status).toBe("idle");
// fetchAgents should now return 2 agents
const afterSecond = await ctx.client.fetchAgents();
expect(afterSecond.entries).toHaveLength(2);
// Verify both agents are present with correct IDs and states
const ids = afterSecond.entries.map((entry) => entry.agent.id);
expect(ids).toContain(agent1.id);
expect(ids).toContain(agent2.id);
const agent1State = afterSecond.entries.find((entry) => entry.agent.id === agent1.id)?.agent;
const agent2State = afterSecond.entries.find((entry) => entry.agent.id === agent2.id)?.agent;
// Title may or may not be set depending on timing
expect(agent1State?.cwd).toBe(cwd1);
expect(agent1State?.status).toBe("idle");
// Title may or may not be set depending on timing
expect(agent2State?.cwd).toBe(cwd2);
expect(agent2State?.status).toBe("idle");
// Delete first agent
await ctx.client.deleteAgent(agent1.id);
// fetchAgents should now return only 1 agent
const afterDelete = await ctx.client.fetchAgents();
expect(afterDelete.entries).toHaveLength(1);
expect(afterDelete.entries[0]?.agent.id).toBe(agent2.id);
expect(afterDelete.entries[0]?.agent.cwd).toBe(cwd2);
// Verify agent1 is no longer in the list
const deletedAgent = afterDelete.entries.find((entry) => entry.agent.id === agent1.id);
expect(deletedAgent).toBeUndefined();
// Cleanup
await ctx.client.deleteAgent(agent2.id);
rmSync(cwd1, { recursive: true, force: true });
rmSync(cwd2, { recursive: true, force: true });
}, 60000); // 1 minute timeout
});
@@ -60,83 +60,81 @@ async function waitForCheckoutDiffUpdate(
});
}
describe("daemon E2E checkout diff subscriptions", () => {
let ctx: DaemonTestContext;
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60000);
test("pushes file-level checkout diff updates with deterministic path order", async () => {
const cwd = tmpCwd();
try {
initGitRepo(cwd);
commitFile(cwd, "base.txt", "base\n");
const subscriptionId = "checkout-diff-e2e-subscription";
const initial = await ctx.client.subscribeCheckoutDiff(
cwd,
{ mode: "uncommitted" },
{ subscriptionId },
);
expect(initial.error).toBeNull();
expect(initial.files).toEqual([]);
writeFileSync(path.join(cwd, "zeta.txt"), "zeta\n");
writeFileSync(path.join(cwd, "alpha.txt"), "alpha\n");
const update = await waitForCheckoutDiffUpdate(ctx, subscriptionId, (payload) => {
const paths = new Set(payload.files.map((file) => file.path));
return paths.has("alpha.txt") && paths.has("zeta.txt");
});
expect(update.error).toBeNull();
expect(update.files.map((file) => file.path)).toEqual(["alpha.txt", "zeta.txt"]);
ctx.client.unsubscribeCheckoutDiff(subscriptionId);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}, 60000);
test("pushes updates when subscribed from a subdirectory and files change outside it", async () => {
const cwd = tmpCwd();
try {
initGitRepo(cwd);
commitFile(cwd, "base.txt", "base\n");
const nestedDir = path.join(cwd, "nested", "dir");
mkdirSync(nestedDir, { recursive: true });
const subscriptionId = "checkout-diff-subdir-e2e-subscription";
const initial = await ctx.client.subscribeCheckoutDiff(
nestedDir,
{ mode: "uncommitted" },
{ subscriptionId },
);
expect(initial.error).toBeNull();
expect(initial.files).toEqual([]);
writeFileSync(path.join(cwd, "outside-subdir.txt"), "changed outside\n");
const update = await waitForCheckoutDiffUpdate(ctx, subscriptionId, (payload) =>
payload.files.some((file) => file.path === "outside-subdir.txt"),
);
expect(update.error).toBeNull();
expect(update.files.some((file) => file.path === "outside-subdir.txt")).toBe(true);
ctx.client.unsubscribeCheckoutDiff(subscriptionId);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}, 60000);
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60000);
test("pushes file-level checkout diff updates with deterministic path order", async () => {
const cwd = tmpCwd();
try {
initGitRepo(cwd);
commitFile(cwd, "base.txt", "base\n");
const subscriptionId = "checkout-diff-e2e-subscription";
const initial = await ctx.client.subscribeCheckoutDiff(
cwd,
{ mode: "uncommitted" },
{ subscriptionId },
);
expect(initial.error).toBeNull();
expect(initial.files).toEqual([]);
writeFileSync(path.join(cwd, "zeta.txt"), "zeta\n");
writeFileSync(path.join(cwd, "alpha.txt"), "alpha\n");
const update = await waitForCheckoutDiffUpdate(ctx, subscriptionId, (payload) => {
const paths = new Set(payload.files.map((file) => file.path));
return paths.has("alpha.txt") && paths.has("zeta.txt");
});
expect(update.error).toBeNull();
expect(update.files.map((file) => file.path)).toEqual(["alpha.txt", "zeta.txt"]);
ctx.client.unsubscribeCheckoutDiff(subscriptionId);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}, 60000);
test("pushes updates when subscribed from a subdirectory and files change outside it", async () => {
const cwd = tmpCwd();
try {
initGitRepo(cwd);
commitFile(cwd, "base.txt", "base\n");
const nestedDir = path.join(cwd, "nested", "dir");
mkdirSync(nestedDir, { recursive: true });
const subscriptionId = "checkout-diff-subdir-e2e-subscription";
const initial = await ctx.client.subscribeCheckoutDiff(
nestedDir,
{ mode: "uncommitted" },
{ subscriptionId },
);
expect(initial.error).toBeNull();
expect(initial.files).toEqual([]);
writeFileSync(path.join(cwd, "outside-subdir.txt"), "changed outside\n");
const update = await waitForCheckoutDiffUpdate(ctx, subscriptionId, (payload) =>
payload.files.some((file) => file.path === "outside-subdir.txt"),
);
expect(update.error).toBeNull();
expect(update.files.some((file) => file.path === "outside-subdir.txt")).toBe(true);
ctx.client.unsubscribeCheckoutDiff(subscriptionId);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}, 60000);
@@ -12,127 +12,125 @@ function tmpCwd(): string {
const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60000);
describe("exploreFileSystem", () => {
test("lists directory contents", async () => {
const cwd = tmpCwd();
// Create test files and directories
writeFileSync(path.join(cwd, "test.txt"), "hello world\n");
writeFileSync(path.join(cwd, "data.json"), '{"key": "value"}\n');
mkdirSync(path.join(cwd, "subdir"));
writeFileSync(path.join(cwd, "subdir", "nested.txt"), "nested content\n");
// Create agent in the directory
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "File Explorer Test",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// List directory contents
const result = await ctx.client.exploreFileSystem(cwd, ".", "list");
// Verify listing returned without error
expect(result.error).toBeNull();
expect(result.mode).toBe("list");
expect(result.directory).toBeTruthy();
expect(result.directory!.entries).toBeTruthy();
// Find expected entries
const entries = result.directory!.entries;
const testTxt = entries.find((e) => e.name === "test.txt");
const dataJson = entries.find((e) => e.name === "data.json");
const subdir = entries.find((e) => e.name === "subdir");
expect(testTxt).toBeTruthy();
expect(testTxt!.kind).toBe("file");
expect(testTxt!.size).toBeGreaterThan(0);
expect(dataJson).toBeTruthy();
expect(dataJson!.kind).toBe("file");
expect(subdir).toBeTruthy();
expect(subdir!.kind).toBe("directory");
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
}, 60000); // 1 minute timeout
test("reads file contents", async () => {
const cwd = tmpCwd();
const testContent = "This is test file content.\nLine 2.";
const testFile = path.join(cwd, "readme.txt");
writeFileSync(testFile, testContent);
// Create agent in the directory
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "File Read Test",
});
expect(agent.id).toBeTruthy();
// Read file contents
const result = await ctx.client.exploreFileSystem(cwd, "readme.txt", "file");
// Verify file read
expect(result.error).toBeNull();
expect(result.mode).toBe("file");
expect(result.file).toBeTruthy();
// Server may return basename or full path
expect(result.file!.path).toContain("readme.txt");
expect(result.file!.kind).toBe("text");
expect(result.file!.content).toBe(testContent);
expect(result.file!.size).toBe(testContent.length);
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
}, 60000); // 1 minute timeout
test("returns error for non-existent path", async () => {
const cwd = tmpCwd();
// Create agent
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "File Explorer Error Test",
});
expect(agent.id).toBeTruthy();
// Try to list non-existent path
const result = await ctx.client.exploreFileSystem(cwd, "does-not-exist", "list");
// Should return error
expect(result.error).toBeTruthy();
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
}, 60000); // 1 minute timeout
});
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60000);
describe("exploreFileSystem", () => {
test("lists directory contents", async () => {
const cwd = tmpCwd();
// Create test files and directories
writeFileSync(path.join(cwd, "test.txt"), "hello world\n");
writeFileSync(path.join(cwd, "data.json"), '{"key": "value"}\n');
mkdirSync(path.join(cwd, "subdir"));
writeFileSync(path.join(cwd, "subdir", "nested.txt"), "nested content\n");
// Create agent in the directory
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "File Explorer Test",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// List directory contents
const result = await ctx.client.exploreFileSystem(cwd, ".", "list");
// Verify listing returned without error
expect(result.error).toBeNull();
expect(result.mode).toBe("list");
expect(result.directory).toBeTruthy();
expect(result.directory!.entries).toBeTruthy();
// Find expected entries
const entries = result.directory!.entries;
const testTxt = entries.find((e) => e.name === "test.txt");
const dataJson = entries.find((e) => e.name === "data.json");
const subdir = entries.find((e) => e.name === "subdir");
expect(testTxt).toBeTruthy();
expect(testTxt!.kind).toBe("file");
expect(testTxt!.size).toBeGreaterThan(0);
expect(dataJson).toBeTruthy();
expect(dataJson!.kind).toBe("file");
expect(subdir).toBeTruthy();
expect(subdir!.kind).toBe("directory");
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
}, 60000); // 1 minute timeout
test("reads file contents", async () => {
const cwd = tmpCwd();
const testContent = "This is test file content.\nLine 2.";
const testFile = path.join(cwd, "readme.txt");
writeFileSync(testFile, testContent);
// Create agent in the directory
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "File Read Test",
});
expect(agent.id).toBeTruthy();
// Read file contents
const result = await ctx.client.exploreFileSystem(cwd, "readme.txt", "file");
// Verify file read
expect(result.error).toBeNull();
expect(result.mode).toBe("file");
expect(result.file).toBeTruthy();
// Server may return basename or full path
expect(result.file!.path).toContain("readme.txt");
expect(result.file!.kind).toBe("text");
expect(result.file!.content).toBe(testContent);
expect(result.file!.size).toBe(testContent.length);
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
}, 60000); // 1 minute timeout
test("returns error for non-existent path", async () => {
const cwd = tmpCwd();
// Create agent
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "File Explorer Error Test",
});
expect(agent.id).toBeTruthy();
// Try to list non-existent path
const result = await ctx.client.exploreFileSystem(cwd, "does-not-exist", "list");
// Should return error
expect(result.error).toBeTruthy();
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
}, 60000); // 1 minute timeout
});
File diff suppressed because it is too large Load Diff
@@ -11,138 +11,136 @@ function tmpCwd(): string {
const CODEX_TEST_MODEL = "gpt-5.4-mini";
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
let collector: MessageCollector;
let ctx: DaemonTestContext;
let collector: MessageCollector;
beforeEach(async () => {
ctx = await createDaemonTestContext();
collector = createMessageCollector(ctx.client);
});
afterEach(async () => {
collector.unsubscribe();
await ctx.cleanup();
}, 60000);
describe("sendImages", () => {
// Minimal 1x1 red PNG image encoded in base64
// This is a valid PNG that can be decoded by image processing libraries
const MINIMAL_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
test("sends message with image attachment to Claude agent", async () => {
const cwd = tmpCwd();
// Create Claude agent
const agent = await ctx.client.createAgent({
provider: "claude",
cwd,
title: "Image Test Agent",
});
expect(agent.id).toBeTruthy();
expect(agent.provider).toBe("claude");
// Send message with image attachment
await ctx.client.sendMessage(
agent.id,
"I'm sending you an image. Describe what information you received about the image attachment. Reply with a single short sentence.",
{
images: [
{
data: MINIMAL_PNG_BASE64,
mimeType: "image/png",
},
],
},
);
// Wait for agent to complete
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
expect(finalState.status).toBe("idle");
expect(finalState.final?.lastError).toBeUndefined();
// Verify stream events show the agent processed the message
const queue = collector.messages;
const streamEvents = queue.filter(
(m) => m.type === "agent_stream" && m.payload.agentId === agent.id,
);
// Should have received stream events
expect(streamEvents.length).toBeGreaterThan(0);
// Verify turn completed successfully
const hasTurnCompleted = streamEvents.some(
(m) => m.type === "agent_stream" && m.payload.event.type === "turn_completed",
);
expect(hasTurnCompleted).toBe(true);
// Verify there was an assistant message in the timeline
const hasAssistantMessage = streamEvents.some((m) => {
if (m.type !== "agent_stream" || m.payload.event.type !== "timeline") {
return false;
}
const item = m.payload.event.item;
return item.type === "assistant_message" && item.text.length > 0;
});
expect(hasAssistantMessage).toBe(true);
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
}, 180000); // 3 minute timeout for Claude API call
test("sends message with multiple image attachments", async () => {
const cwd = tmpCwd();
// Create Claude agent
const agent = await ctx.client.createAgent({
provider: "claude",
cwd,
title: "Multi-Image Test Agent",
});
expect(agent.id).toBeTruthy();
// Send message with two image attachments
await ctx.client.sendMessage(
agent.id,
"I'm sending you two images. How many image attachments are mentioned in the context? Reply with just a number.",
{
images: [
{
data: MINIMAL_PNG_BASE64,
mimeType: "image/png",
},
{
data: MINIMAL_PNG_BASE64,
mimeType: "image/jpeg",
},
],
},
);
// Wait for agent to complete
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
expect(finalState.status).toBe("idle");
expect(finalState.final?.lastError).toBeUndefined();
// Verify turn completed
const queue = collector.messages;
const hasTurnCompleted = queue.some(
(m) =>
m.type === "agent_stream" &&
m.payload.agentId === agent.id &&
m.payload.event.type === "turn_completed",
);
expect(hasTurnCompleted).toBe(true);
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
}, 180000); // 3 minute timeout for Claude API call
});
beforeEach(async () => {
ctx = await createDaemonTestContext();
collector = createMessageCollector(ctx.client);
});
afterEach(async () => {
collector.unsubscribe();
await ctx.cleanup();
}, 60000);
describe("sendImages", () => {
// Minimal 1x1 red PNG image encoded in base64
// This is a valid PNG that can be decoded by image processing libraries
const MINIMAL_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
test("sends message with image attachment to Claude agent", async () => {
const cwd = tmpCwd();
// Create Claude agent
const agent = await ctx.client.createAgent({
provider: "claude",
cwd,
title: "Image Test Agent",
});
expect(agent.id).toBeTruthy();
expect(agent.provider).toBe("claude");
// Send message with image attachment
await ctx.client.sendMessage(
agent.id,
"I'm sending you an image. Describe what information you received about the image attachment. Reply with a single short sentence.",
{
images: [
{
data: MINIMAL_PNG_BASE64,
mimeType: "image/png",
},
],
},
);
// Wait for agent to complete
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
expect(finalState.status).toBe("idle");
expect(finalState.final?.lastError).toBeUndefined();
// Verify stream events show the agent processed the message
const queue = collector.messages;
const streamEvents = queue.filter(
(m) => m.type === "agent_stream" && m.payload.agentId === agent.id,
);
// Should have received stream events
expect(streamEvents.length).toBeGreaterThan(0);
// Verify turn completed successfully
const hasTurnCompleted = streamEvents.some(
(m) => m.type === "agent_stream" && m.payload.event.type === "turn_completed",
);
expect(hasTurnCompleted).toBe(true);
// Verify there was an assistant message in the timeline
const hasAssistantMessage = streamEvents.some((m) => {
if (m.type !== "agent_stream" || m.payload.event.type !== "timeline") {
return false;
}
const item = m.payload.event.item;
return item.type === "assistant_message" && item.text.length > 0;
});
expect(hasAssistantMessage).toBe(true);
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
}, 180000); // 3 minute timeout for Claude API call
test("sends message with multiple image attachments", async () => {
const cwd = tmpCwd();
// Create Claude agent
const agent = await ctx.client.createAgent({
provider: "claude",
cwd,
title: "Multi-Image Test Agent",
});
expect(agent.id).toBeTruthy();
// Send message with two image attachments
await ctx.client.sendMessage(
agent.id,
"I'm sending you two images. How many image attachments are mentioned in the context? Reply with just a number.",
{
images: [
{
data: MINIMAL_PNG_BASE64,
mimeType: "image/png",
},
{
data: MINIMAL_PNG_BASE64,
mimeType: "image/jpeg",
},
],
},
);
// Wait for agent to complete
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
expect(finalState.status).toBe("idle");
expect(finalState.final?.lastError).toBeUndefined();
// Verify turn completed
const queue = collector.messages;
const hasTurnCompleted = queue.some(
(m) =>
m.type === "agent_stream" &&
m.payload.agentId === agent.id &&
m.payload.event.type === "turn_completed",
);
expect(hasTurnCompleted).toBe(true);
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
}, 180000); // 3 minute timeout for Claude API call
});
@@ -59,192 +59,59 @@ function isBinaryInstalled(binary: string): boolean {
const hasCodex = isBinaryInstalled("codex");
const hasOpenCode = isBinaryInstalled("opencode");
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
let messages: SessionOutboundMessage[] = [];
let unsubscribe: (() => void) | null = null;
let ctx: DaemonTestContext;
let messages: SessionOutboundMessage[] = [];
let unsubscribe: (() => void) | null = null;
beforeEach(async () => {
ctx = await createDaemonTestContext();
messages = [];
unsubscribe = ctx.client.subscribeRawMessages((message) => {
messages.push(message);
});
await ctx.client.fetchAgents({ subscribe: { subscriptionId: "live-preferences" } });
beforeEach(async () => {
ctx = await createDaemonTestContext();
messages = [];
unsubscribe = ctx.client.subscribeRawMessages((message) => {
messages.push(message);
});
await ctx.client.fetchAgents({ subscribe: { subscriptionId: "live-preferences" } });
});
afterEach(async () => {
unsubscribe?.();
await ctx.cleanup();
}, 60000);
afterEach(async () => {
unsubscribe?.();
await ctx.cleanup();
}, 60000);
describe.each(["claude", "codex", "opencode"] as const)(
"live model switching (%s)",
(provider) => {
const shouldRun =
provider === "claude" ||
(provider === "codex" && hasCodex) ||
(provider === "opencode" && hasOpenCode);
describe.each(["claude", "codex", "opencode"] as const)("live model switching (%s)", (provider) => {
const shouldRun =
provider === "claude" ||
(provider === "codex" && hasCodex) ||
(provider === "opencode" && hasOpenCode);
test.runIf(shouldRun)(
"updates agent model without restarting",
async () => {
const cwd = tmpCwd();
try {
const modelList = await ctx.client.listProviderModels(provider);
if (!modelList.models || modelList.models.length === 0) {
throw new Error(`No models returned for provider ${provider}`);
}
const [modelA, modelB] = pickTwoDistinctModels(modelList.models);
const agent = await ctx.client.createAgent({
provider,
cwd,
title: `Model Switch (${provider})`,
model: modelA,
});
const startIndex = messages.length;
await ctx.client.setAgentModel(agent.id, modelB);
const updated = await waitForAgentUpdate(
messages,
startIndex,
(a) => a.id === agent.id && a.model === modelB,
{ timeoutMs: 20000 },
);
expect(updated.model).toBe(modelB);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
180000,
);
},
);
test("live thinking switching works for Claude (off -> on)", async () => {
const cwd = tmpCwd();
try {
const modelList = await ctx.client.listProviderModels("claude");
if (!modelList.models || modelList.models.length === 0) {
throw new Error("No Claude models returned");
}
const modelId = modelList.models[0]!.id;
const agent = await ctx.client.createAgent({
provider: "claude",
cwd,
title: "Claude Thinking Switch",
model: modelId,
});
const startIndex = messages.length;
await ctx.client.setAgentThinkingOption(agent.id, "on");
const updated = await waitForAgentUpdate(
messages,
startIndex,
(a) => a.id === agent.id && a.thinkingOptionId === "on",
{ timeoutMs: 20000 },
);
expect(updated.thinkingOptionId).toBe("on");
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}, 120000);
test.runIf(hasCodex)(
"live thinking switching works for Codex (default -> non-default)",
test.runIf(shouldRun)(
"updates agent model without restarting",
async () => {
const cwd = tmpCwd();
try {
const modelList = await ctx.client.listProviderModels("codex");
const modelList = await ctx.client.listProviderModels(provider);
if (!modelList.models || modelList.models.length === 0) {
throw new Error("No Codex models returned");
}
const modelWithOptions = modelList.models.find((m) => (m.thinkingOptions?.length ?? 0) > 1);
if (!modelWithOptions) {
throw new Error("No Codex model with thinkingOptions returned");
}
const defaultThinkingId = modelWithOptions.defaultThinkingOptionId ?? "default";
const nonDefault =
modelWithOptions.thinkingOptions?.find((o) => o.id !== defaultThinkingId)?.id ??
modelWithOptions.thinkingOptions?.[0]?.id ??
null;
if (!nonDefault) {
throw new Error("No non-default Codex thinking option found");
throw new Error(`No models returned for provider ${provider}`);
}
const [modelA, modelB] = pickTwoDistinctModels(modelList.models);
const agent = await ctx.client.createAgent({
provider: "codex",
provider,
cwd,
title: "Codex Thinking Switch",
model: modelWithOptions.id,
title: `Model Switch (${provider})`,
model: modelA,
});
const startIndex = messages.length;
await ctx.client.setAgentThinkingOption(agent.id, nonDefault);
await ctx.client.setAgentModel(agent.id, modelB);
const updated = await waitForAgentUpdate(
messages,
startIndex,
(a) => a.id === agent.id && a.thinkingOptionId === nonDefault,
(a) => a.id === agent.id && a.model === modelB,
{ timeoutMs: 20000 },
);
expect(updated.thinkingOptionId).toBe(nonDefault);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
120000,
);
test.runIf(hasOpenCode)(
"live thinking switching works for OpenCode",
async () => {
const cwd = tmpCwd();
try {
const modelList = await ctx.client.listProviderModels("opencode");
if (!modelList.models || modelList.models.length === 0) {
throw new Error("No OpenCode models returned");
}
const modelWithThinkingOptions = modelList.models.find(
(m) => (m.thinkingOptions?.length ?? 0) > 1,
);
if (!modelWithThinkingOptions) {
throw new Error("No OpenCode model with thinkingOptions returned");
}
const agent = await ctx.client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode Preferences Switch",
model: modelWithThinkingOptions.id,
});
const defaultThinkingId = modelWithThinkingOptions.defaultThinkingOptionId ?? "default";
const thinkingId =
modelWithThinkingOptions.thinkingOptions?.find((o) => o.id !== defaultThinkingId)?.id ??
modelWithThinkingOptions.thinkingOptions?.[0]?.id ??
null;
if (!thinkingId) {
throw new Error("No non-default OpenCode thinking option found");
}
const startIndex = messages.length;
await ctx.client.setAgentThinkingOption(agent.id, thinkingId);
const updatedThinking = await waitForAgentUpdate(
messages,
startIndex,
(a) => a.id === agent.id && a.thinkingOptionId === thinkingId,
{ timeoutMs: 20000 },
);
expect(updatedThinking.thinkingOptionId).toBe(thinkingId);
expect(updated.model).toBe(modelB);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
@@ -252,3 +119,131 @@ describe("daemon E2E", () => {
180000,
);
});
test("live thinking switching works for Claude (off -> on)", async () => {
const cwd = tmpCwd();
try {
const modelList = await ctx.client.listProviderModels("claude");
if (!modelList.models || modelList.models.length === 0) {
throw new Error("No Claude models returned");
}
const modelId = modelList.models[0]!.id;
const agent = await ctx.client.createAgent({
provider: "claude",
cwd,
title: "Claude Thinking Switch",
model: modelId,
});
const startIndex = messages.length;
await ctx.client.setAgentThinkingOption(agent.id, "on");
const updated = await waitForAgentUpdate(
messages,
startIndex,
(a) => a.id === agent.id && a.thinkingOptionId === "on",
{ timeoutMs: 20000 },
);
expect(updated.thinkingOptionId).toBe("on");
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}, 120000);
test.runIf(hasCodex)(
"live thinking switching works for Codex (default -> non-default)",
async () => {
const cwd = tmpCwd();
try {
const modelList = await ctx.client.listProviderModels("codex");
if (!modelList.models || modelList.models.length === 0) {
throw new Error("No Codex models returned");
}
const modelWithOptions = modelList.models.find((m) => (m.thinkingOptions?.length ?? 0) > 1);
if (!modelWithOptions) {
throw new Error("No Codex model with thinkingOptions returned");
}
const defaultThinkingId = modelWithOptions.defaultThinkingOptionId ?? "default";
const nonDefault =
modelWithOptions.thinkingOptions?.find((o) => o.id !== defaultThinkingId)?.id ??
modelWithOptions.thinkingOptions?.[0]?.id ??
null;
if (!nonDefault) {
throw new Error("No non-default Codex thinking option found");
}
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Codex Thinking Switch",
model: modelWithOptions.id,
});
const startIndex = messages.length;
await ctx.client.setAgentThinkingOption(agent.id, nonDefault);
const updated = await waitForAgentUpdate(
messages,
startIndex,
(a) => a.id === agent.id && a.thinkingOptionId === nonDefault,
{ timeoutMs: 20000 },
);
expect(updated.thinkingOptionId).toBe(nonDefault);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
120000,
);
test.runIf(hasOpenCode)(
"live thinking switching works for OpenCode",
async () => {
const cwd = tmpCwd();
try {
const modelList = await ctx.client.listProviderModels("opencode");
if (!modelList.models || modelList.models.length === 0) {
throw new Error("No OpenCode models returned");
}
const modelWithThinkingOptions = modelList.models.find(
(m) => (m.thinkingOptions?.length ?? 0) > 1,
);
if (!modelWithThinkingOptions) {
throw new Error("No OpenCode model with thinkingOptions returned");
}
const agent = await ctx.client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode Preferences Switch",
model: modelWithThinkingOptions.id,
});
const defaultThinkingId = modelWithThinkingOptions.defaultThinkingOptionId ?? "default";
const thinkingId =
modelWithThinkingOptions.thinkingOptions?.find((o) => o.id !== defaultThinkingId)?.id ??
modelWithThinkingOptions.thinkingOptions?.[0]?.id ??
null;
if (!thinkingId) {
throw new Error("No non-default OpenCode thinking option found");
}
const startIndex = messages.length;
await ctx.client.setAgentThinkingOption(agent.id, thinkingId);
const updatedThinking = await waitForAgentUpdate(
messages,
startIndex,
(a) => a.id === agent.id && a.thinkingOptionId === thinkingId,
{ timeoutMs: 20000 },
);
expect(updatedThinking.thinkingOptionId).toBe(thinkingId);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
180000,
);
@@ -13,353 +13,351 @@ function tmpCwd(): string {
const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
let messages: SessionOutboundMessage[] = [];
let unsubscribe: (() => void) | null = null;
beforeEach(async () => {
ctx = await createDaemonTestContext();
messages = [];
unsubscribe = ctx.client.subscribeRawMessages((message) => {
messages.push(message);
});
});
afterEach(async () => {
unsubscribe?.();
await ctx.cleanup();
}, 60000);
describe("permission flow: Codex", () => {
test("approves permission and executes command", async () => {
const cwd = tmpCwd();
const filePath = path.join(cwd, "permission.txt");
// Create Codex agent with on-request approval policy
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Codex Permission Test",
modeId: "read-only",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// Clear message queue before sending prompt
messages.length = 0;
// Send a prompt that requires permission
const prompt = [
'Request approval to run the command `printf "ok" > permission.txt`.',
"After approval, run it and reply DONE.",
].join(" ");
await ctx.client.sendMessage(agent.id, prompt);
// Wait for permission request
const permissionState = await ctx.client.waitForFinish(agent.id, 60000);
expect(permissionState.final?.pendingPermissions?.length).toBeGreaterThan(0);
const permission = permissionState.final!.pendingPermissions[0];
expect(permission).not.toBeNull();
expect(permission.id).toBeTruthy();
expect(permission.kind).toBe("tool");
// Approve the permission
await ctx.client.respondToPermission(agent.id, permission.id, {
behavior: "allow",
});
// Wait for agent to complete
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
expect(finalState.status).toBe("idle");
// Verify the file was created
expect(existsSync(filePath)).toBe(true);
// Verify permission_resolved event was received
const queue = messages;
const hasPermissionResolved = queue.some((m) => {
if (m.type === "agent_stream" && m.payload.agentId === agent.id) {
return (
m.payload.event.type === "permission_resolved" &&
m.payload.event.requestId === permission.id &&
m.payload.event.resolution.behavior === "allow"
);
}
return false;
});
expect(hasPermissionResolved).toBe(true);
rmSync(cwd, { recursive: true, force: true });
}, 30_000);
test("denies permission and prevents execution", async () => {
const cwd = tmpCwd();
const filePath = path.join(cwd, "permission.txt");
// Create Codex agent with on-request approval policy
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Codex Permission Deny Test",
modeId: "read-only",
});
expect(agent.id).toBeTruthy();
// Clear message queue before sending prompt
messages.length = 0;
// Send a prompt that requires permission
const prompt = [
'Request approval to run the command `printf "ok" > permission.txt`.',
"If approval is denied, acknowledge and stop.",
].join(" ");
await ctx.client.sendMessage(agent.id, prompt);
// Wait for permission request
const permissionState = await ctx.client.waitForFinish(agent.id, 60000);
expect(permissionState.final?.pendingPermissions?.length).toBeGreaterThan(0);
const permission = permissionState.final!.pendingPermissions[0];
expect(permission).not.toBeNull();
expect(permission.id).toBeTruthy();
// Deny the permission
await ctx.client.respondToPermission(agent.id, permission.id, {
behavior: "deny",
message: "Not allowed.",
});
// Wait for agent to complete
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
expect(finalState.status).toBe("idle");
// Verify the file was NOT created
expect(existsSync(filePath)).toBe(false);
// Verify permission_resolved event was received with deny
const queue = messages;
const hasPermissionDenied = queue.some((m) => {
if (m.type === "agent_stream" && m.payload.agentId === agent.id) {
return (
m.payload.event.type === "permission_resolved" &&
m.payload.event.requestId === permission.id &&
m.payload.event.resolution.behavior === "deny"
);
}
return false;
});
expect(hasPermissionDenied).toBe(true);
rmSync(cwd, { recursive: true, force: true });
}, 30_000);
test("Codex agent can complete a new turn after interrupt", async () => {
const cwd = tmpCwd();
// Create Codex agent with full-access (no permissions needed)
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Codex Interrupt Test",
modeId: "full-access",
});
expect(agent.id).toBeTruthy();
expect(agent.currentModeId).toBe("full-access");
// Send first message to start a long-running operation so we can interrupt it.
messages.length = 0;
await ctx.client.sendMessage(agent.id, "Run: sleep 30");
await ctx.client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
5_000,
);
// Cancel while running
await ctx.client.cancelAgent(agent.id);
// Wait for agent to become idle after cancellation
// Don't use waitForAgentIdle because it requires seeing "running" first,
// but we already saw it above. Just wait for "idle" or "error".
await ctx.client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "idle" || snapshot.status === "error",
30000,
);
// Now send another message - this should work
messages.length = 0;
await ctx.client.sendMessage(agent.id, "Say 'hello from interrupt test' and nothing else.");
// Wait for this to complete
await ctx.client.waitForFinish(agent.id, 60000);
// Verify we got an assistant message in the queue
const queue = messages;
const hasAssistantMessage = queue.some(
(m) =>
m.type === "agent_stream" &&
m.payload.agentId === agent.id &&
m.payload.event.type === "timeline" &&
m.payload.event.item.type === "assistant_message",
);
expect(hasAssistantMessage).toBe(true);
rmSync(cwd, { recursive: true, force: true });
}, 30_000);
test("aborting Codex actually stops execution (sleep + write test)", async () => {
const cwd = tmpCwd();
const filePath = path.join(cwd, "abort-test-file.txt");
// Create Codex agent with full-access (no permissions needed)
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Codex Abort Stop Test",
modeId: "full-access",
});
expect(agent.id).toBeTruthy();
messages.length = 0;
await ctx.client.sendMessage(
agent.id,
"Run this bash command: sleep 15 && echo 'abort-test-completed' > abort-test-file.txt",
);
await ctx.client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
5_000,
);
// Cancel/interrupt the agent
await ctx.client.cancelAgent(agent.id);
await ctx.client.waitForFinish(agent.id, 5_000);
// Assert the file was NOT created (proving Codex actually stopped)
const fileExists = existsSync(filePath);
expect(fileExists).toBe(false);
rmSync(cwd, { recursive: true, force: true });
}, 30_000);
test("switching from auto to full-access mode allows writes without permission", async () => {
const cwd = tmpCwd();
const filePath = path.join(cwd, "mode-switch-test.txt");
// Step 1: Create Codex agent with "auto" mode (requires permission for writes)
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Codex Mode Switch Permission Test",
modeId: "auto",
});
expect(agent.id).toBeTruthy();
expect(agent.currentModeId).toBe("auto");
// Step 2: Ask agent to write a file - this should trigger permission request
// Note: We DON'T tell the agent to "stop" if denied - this keeps the conversation
// alive and tests the real scenario where mode switch must work mid-conversation.
messages.length = 0;
const writePrompt =
'Request approval to run the command `printf "ok" > mode-switch-test.txt`. After approval, run it and stop.';
await ctx.client.sendMessage(agent.id, writePrompt);
// Step 3: Wait for permission request
const permissionState = await ctx.client.waitForFinish(agent.id, 60000);
expect(permissionState.final?.pendingPermissions?.length).toBeGreaterThan(0);
const permission = permissionState.final!.pendingPermissions[0];
expect(permission).not.toBeNull();
expect(permission.id).toBeTruthy();
// Step 4: Deny the permission
await ctx.client.respondToPermission(agent.id, permission.id, {
behavior: "deny",
message: "Permission denied for test.",
});
// Wait for agent to complete after denial
await ctx.client.waitForFinish(agent.id, 120000);
// Verify file was NOT created after denial
expect(existsSync(filePath)).toBe(false);
// Step 5: Switch to "full-access" mode
messages.length = 0;
const modeStartPosition = messages.length;
await ctx.client.setAgentMode(agent.id, "full-access");
// Wait for mode change to be reflected in agent_update
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Timeout waiting for full-access mode change"));
}, 15000);
const checkForModeChange = (): void => {
const queue = messages;
for (let i = modeStartPosition; i < queue.length; i++) {
const msg = queue[i];
if (
msg.type === "agent_update" &&
msg.payload.kind === "upsert" &&
msg.payload.agent.id === agent.id &&
msg.payload.agent.currentModeId === "full-access"
) {
clearTimeout(timeout);
clearInterval(interval);
resolve();
return;
}
}
};
const interval = setInterval(checkForModeChange, 50);
});
// Step 6: Ask agent to write file again - should succeed WITHOUT permission request
// In full-access mode, the agent should just execute without asking.
messages.length = 0;
const writePrompt2 = 'Run the command `printf "ok" > mode-switch-test.txt` and reply DONE.';
await ctx.client.sendMessage(agent.id, writePrompt2);
// Wait for agent to complete
await ctx.client.waitForFinish(agent.id, 120000);
// Step 7: Verify file was created (mode switch worked)
expect(existsSync(filePath)).toBe(true);
const content = readFileSync(filePath, "utf-8");
expect(content).toBe("ok");
// Verify no permission was requested in this second attempt
const queue = messages;
const hasPermissionRequest = queue.some(
(m) => m.type === "agent_permission_request" && m.payload.agentId === agent.id,
);
expect(hasPermissionRequest).toBe(false);
rmSync(cwd, { recursive: true, force: true });
}, 30_000);
let ctx: DaemonTestContext;
let messages: SessionOutboundMessage[] = [];
let unsubscribe: (() => void) | null = null;
beforeEach(async () => {
ctx = await createDaemonTestContext();
messages = [];
unsubscribe = ctx.client.subscribeRawMessages((message) => {
messages.push(message);
});
});
afterEach(async () => {
unsubscribe?.();
await ctx.cleanup();
}, 60000);
describe("permission flow: Codex", () => {
test("approves permission and executes command", async () => {
const cwd = tmpCwd();
const filePath = path.join(cwd, "permission.txt");
// Create Codex agent with on-request approval policy
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Codex Permission Test",
modeId: "read-only",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// Clear message queue before sending prompt
messages.length = 0;
// Send a prompt that requires permission
const prompt = [
'Request approval to run the command `printf "ok" > permission.txt`.',
"After approval, run it and reply DONE.",
].join(" ");
await ctx.client.sendMessage(agent.id, prompt);
// Wait for permission request
const permissionState = await ctx.client.waitForFinish(agent.id, 60000);
expect(permissionState.final?.pendingPermissions?.length).toBeGreaterThan(0);
const permission = permissionState.final!.pendingPermissions[0];
expect(permission).not.toBeNull();
expect(permission.id).toBeTruthy();
expect(permission.kind).toBe("tool");
// Approve the permission
await ctx.client.respondToPermission(agent.id, permission.id, {
behavior: "allow",
});
// Wait for agent to complete
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
expect(finalState.status).toBe("idle");
// Verify the file was created
expect(existsSync(filePath)).toBe(true);
// Verify permission_resolved event was received
const queue = messages;
const hasPermissionResolved = queue.some((m) => {
if (m.type === "agent_stream" && m.payload.agentId === agent.id) {
return (
m.payload.event.type === "permission_resolved" &&
m.payload.event.requestId === permission.id &&
m.payload.event.resolution.behavior === "allow"
);
}
return false;
});
expect(hasPermissionResolved).toBe(true);
rmSync(cwd, { recursive: true, force: true });
}, 30_000);
test("denies permission and prevents execution", async () => {
const cwd = tmpCwd();
const filePath = path.join(cwd, "permission.txt");
// Create Codex agent with on-request approval policy
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Codex Permission Deny Test",
modeId: "read-only",
});
expect(agent.id).toBeTruthy();
// Clear message queue before sending prompt
messages.length = 0;
// Send a prompt that requires permission
const prompt = [
'Request approval to run the command `printf "ok" > permission.txt`.',
"If approval is denied, acknowledge and stop.",
].join(" ");
await ctx.client.sendMessage(agent.id, prompt);
// Wait for permission request
const permissionState = await ctx.client.waitForFinish(agent.id, 60000);
expect(permissionState.final?.pendingPermissions?.length).toBeGreaterThan(0);
const permission = permissionState.final!.pendingPermissions[0];
expect(permission).not.toBeNull();
expect(permission.id).toBeTruthy();
// Deny the permission
await ctx.client.respondToPermission(agent.id, permission.id, {
behavior: "deny",
message: "Not allowed.",
});
// Wait for agent to complete
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
expect(finalState.status).toBe("idle");
// Verify the file was NOT created
expect(existsSync(filePath)).toBe(false);
// Verify permission_resolved event was received with deny
const queue = messages;
const hasPermissionDenied = queue.some((m) => {
if (m.type === "agent_stream" && m.payload.agentId === agent.id) {
return (
m.payload.event.type === "permission_resolved" &&
m.payload.event.requestId === permission.id &&
m.payload.event.resolution.behavior === "deny"
);
}
return false;
});
expect(hasPermissionDenied).toBe(true);
rmSync(cwd, { recursive: true, force: true });
}, 30_000);
test("Codex agent can complete a new turn after interrupt", async () => {
const cwd = tmpCwd();
// Create Codex agent with full-access (no permissions needed)
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Codex Interrupt Test",
modeId: "full-access",
});
expect(agent.id).toBeTruthy();
expect(agent.currentModeId).toBe("full-access");
// Send first message to start a long-running operation so we can interrupt it.
messages.length = 0;
await ctx.client.sendMessage(agent.id, "Run: sleep 30");
await ctx.client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
5_000,
);
// Cancel while running
await ctx.client.cancelAgent(agent.id);
// Wait for agent to become idle after cancellation
// Don't use waitForAgentIdle because it requires seeing "running" first,
// but we already saw it above. Just wait for "idle" or "error".
await ctx.client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "idle" || snapshot.status === "error",
30000,
);
// Now send another message - this should work
messages.length = 0;
await ctx.client.sendMessage(agent.id, "Say 'hello from interrupt test' and nothing else.");
// Wait for this to complete
await ctx.client.waitForFinish(agent.id, 60000);
// Verify we got an assistant message in the queue
const queue = messages;
const hasAssistantMessage = queue.some(
(m) =>
m.type === "agent_stream" &&
m.payload.agentId === agent.id &&
m.payload.event.type === "timeline" &&
m.payload.event.item.type === "assistant_message",
);
expect(hasAssistantMessage).toBe(true);
rmSync(cwd, { recursive: true, force: true });
}, 30_000);
test("aborting Codex actually stops execution (sleep + write test)", async () => {
const cwd = tmpCwd();
const filePath = path.join(cwd, "abort-test-file.txt");
// Create Codex agent with full-access (no permissions needed)
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Codex Abort Stop Test",
modeId: "full-access",
});
expect(agent.id).toBeTruthy();
messages.length = 0;
await ctx.client.sendMessage(
agent.id,
"Run this bash command: sleep 15 && echo 'abort-test-completed' > abort-test-file.txt",
);
await ctx.client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
5_000,
);
// Cancel/interrupt the agent
await ctx.client.cancelAgent(agent.id);
await ctx.client.waitForFinish(agent.id, 5_000);
// Assert the file was NOT created (proving Codex actually stopped)
const fileExists = existsSync(filePath);
expect(fileExists).toBe(false);
rmSync(cwd, { recursive: true, force: true });
}, 30_000);
test("switching from auto to full-access mode allows writes without permission", async () => {
const cwd = tmpCwd();
const filePath = path.join(cwd, "mode-switch-test.txt");
// Step 1: Create Codex agent with "auto" mode (requires permission for writes)
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Codex Mode Switch Permission Test",
modeId: "auto",
});
expect(agent.id).toBeTruthy();
expect(agent.currentModeId).toBe("auto");
// Step 2: Ask agent to write a file - this should trigger permission request
// Note: We DON'T tell the agent to "stop" if denied - this keeps the conversation
// alive and tests the real scenario where mode switch must work mid-conversation.
messages.length = 0;
const writePrompt =
'Request approval to run the command `printf "ok" > mode-switch-test.txt`. After approval, run it and stop.';
await ctx.client.sendMessage(agent.id, writePrompt);
// Step 3: Wait for permission request
const permissionState = await ctx.client.waitForFinish(agent.id, 60000);
expect(permissionState.final?.pendingPermissions?.length).toBeGreaterThan(0);
const permission = permissionState.final!.pendingPermissions[0];
expect(permission).not.toBeNull();
expect(permission.id).toBeTruthy();
// Step 4: Deny the permission
await ctx.client.respondToPermission(agent.id, permission.id, {
behavior: "deny",
message: "Permission denied for test.",
});
// Wait for agent to complete after denial
await ctx.client.waitForFinish(agent.id, 120000);
// Verify file was NOT created after denial
expect(existsSync(filePath)).toBe(false);
// Step 5: Switch to "full-access" mode
messages.length = 0;
const modeStartPosition = messages.length;
await ctx.client.setAgentMode(agent.id, "full-access");
// Wait for mode change to be reflected in agent_update
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Timeout waiting for full-access mode change"));
}, 15000);
const checkForModeChange = (): void => {
const queue = messages;
for (let i = modeStartPosition; i < queue.length; i++) {
const msg = queue[i];
if (
msg.type === "agent_update" &&
msg.payload.kind === "upsert" &&
msg.payload.agent.id === agent.id &&
msg.payload.agent.currentModeId === "full-access"
) {
clearTimeout(timeout);
clearInterval(interval);
resolve();
return;
}
}
};
const interval = setInterval(checkForModeChange, 50);
});
// Step 6: Ask agent to write file again - should succeed WITHOUT permission request
// In full-access mode, the agent should just execute without asking.
messages.length = 0;
const writePrompt2 = 'Run the command `printf "ok" > mode-switch-test.txt` and reply DONE.';
await ctx.client.sendMessage(agent.id, writePrompt2);
// Wait for agent to complete
await ctx.client.waitForFinish(agent.id, 120000);
// Step 7: Verify file was created (mode switch worked)
expect(existsSync(filePath)).toBe(true);
const content = readFileSync(filePath, "utf-8");
expect(content).toBe("ok");
// Verify no permission was requested in this second attempt
const queue = messages;
const hasPermissionRequest = queue.some(
(m) => m.type === "agent_permission_request" && m.payload.agentId === agent.id,
);
expect(hasPermissionRequest).toBe(false);
rmSync(cwd, { recursive: true, force: true });
}, 30_000);
});
@@ -92,435 +92,426 @@ async function withConnectedPiDaemon(
}
}
describe(
"daemon E2E (real pi)",
() => {
let canRun = false;
let canRun = false;
beforeAll(async () => {
canRun = await isProviderAvailable("pi");
});
beforeAll(async () => {
canRun = await isProviderAvailable("pi");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test(
"bash tool call records completed shell detail and output",
async () => {
const cwd = tmpCwd();
test(
"bash tool call records completed shell detail and output",
async () => {
const cwd = tmpCwd();
try {
await withConnectedPiDaemon(async ({ client }) => {
const agent = await client.createAgent({
cwd,
title: "pi-bash-tool-call",
provider: "pi",
model: PI_REAL_TEST_MODEL,
});
await client.sendMessage(
agent.id,
"Use the bash tool and run this exact bash command: echo HELLO_PI_TEST",
);
const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
expect(finish.status).toBe("idle");
const items = await fetchCanonicalTimeline(client, agent.id);
const toolCall = findCompletedToolCall(
items,
(item) =>
item.detail.type === "shell" && item.detail.command.includes("echo HELLO_PI_TEST"),
);
expect(toolCall).toBeDefined();
expect(toolCall?.status).toBe("completed");
expect(toolCall?.detail.type).toBe("shell");
if (toolCall?.detail.type === "shell") {
expect(toolCall.detail.command).toContain("echo HELLO_PI_TEST");
expect(toolCall.detail.output).toContain("HELLO_PI_TEST");
}
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
PI_TEST_TIMEOUT_MS,
);
test(
"file read tool call captures read detail and content",
async () => {
const cwd = tmpCwd();
const filename = "pi-read.txt";
const expectedContent = "PI_READ_CONTENT_12345";
try {
writeFileSync(path.join(cwd, filename), expectedContent, "utf8");
await withConnectedPiDaemon(async ({ client }) => {
const agent = await client.createAgent({
cwd,
title: "pi-file-read",
provider: "pi",
model: PI_REAL_TEST_MODEL,
});
await client.sendMessage(
agent.id,
`Use the read tool to read the file ${filename} and tell me its contents exactly.`,
);
const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
expect(finish.status).toBe("idle");
const items = await fetchCanonicalTimeline(client, agent.id);
const toolCall = findCompletedToolCall(
items,
(item) =>
item.detail.type === "read" &&
item.detail.filePath.includes(filename) &&
item.detail.content?.includes(expectedContent) === true,
);
expect(toolCall).toBeDefined();
expect(toolCall?.detail.type).toBe("read");
if (toolCall?.detail.type === "read") {
expect(toolCall.detail.filePath).toContain(filename);
expect(toolCall.detail.content).toContain(expectedContent);
}
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
PI_TEST_TIMEOUT_MS,
);
test(
"file write tool call captures write detail and writes to disk",
async () => {
const cwd = tmpCwd();
const filename = "pi-test-write.txt";
const expectedContent = "PI_WRITE_CONTENT_67890";
try {
await withConnectedPiDaemon(async ({ client }) => {
const agent = await client.createAgent({
cwd,
title: "pi-file-write",
provider: "pi",
model: PI_REAL_TEST_MODEL,
});
await client.sendMessage(
agent.id,
`Use the write tool to write a file called ${filename} in the current directory with the exact content ${expectedContent}`,
);
const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
expect(finish.status).toBe("idle");
const items = await fetchCanonicalTimeline(client, agent.id);
const toolCall = findCompletedToolCall(
items,
(item) => item.detail.type === "write" && item.detail.filePath.includes(filename),
);
expect(toolCall).toBeDefined();
expect(toolCall?.detail.type).toBe("write");
expect(existsSync(path.join(cwd, filename))).toBe(true);
expect(readFileSync(path.join(cwd, filename), "utf8")).toBe(expectedContent);
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
PI_TEST_TIMEOUT_MS,
);
test(
"file edit tool call captures edit detail and updates the file on disk",
async () => {
const cwd = tmpCwd();
const filename = "pi-edit.txt";
const filePath = path.join(cwd, filename);
try {
writeFileSync(filePath, "BEFORE_EDIT", "utf8");
await withConnectedPiDaemon(async ({ client }) => {
const agent = await client.createAgent({
cwd,
title: "pi-file-edit",
provider: "pi",
model: PI_REAL_TEST_MODEL,
});
await client.sendMessage(
agent.id,
`Use the edit tool on the file ${filename} and replace BEFORE_EDIT with AFTER_EDIT. Do not just describe the change.`,
);
const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
expect(finish.status).toBe("idle");
const items = await fetchCanonicalTimeline(client, agent.id);
const toolCall = findCompletedToolCall(
items,
(item) => item.detail.type === "edit" && item.detail.filePath.includes(filename),
);
expect(toolCall).toBeDefined();
expect(toolCall?.detail.type).toBe("edit");
expect(readFileSync(filePath, "utf8")).toContain("AFTER_EDIT");
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
PI_TEST_TIMEOUT_MS,
);
test(
"thinking-enabled runs emit reasoning timeline chunks",
async () => {
const cwd = tmpCwd();
try {
await withConnectedPiDaemon(async ({ client }) => {
const agent = await client.createAgent({
cwd,
title: "pi-reasoning",
provider: "pi",
model: PI_REAL_TEST_MODEL,
thinkingOptionId: "high",
});
await client.sendMessage(
agent.id,
"Think step by step about what 7 * 13 equals, and give the final answer at the end.",
);
const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
expect(finish.status).toBe("idle");
const items = await fetchCanonicalTimeline(client, agent.id);
const reasoningItems = items.filter(
(item): item is Extract<AgentTimelineItem, { type: "reasoning" }> =>
item.type === "reasoning" && item.text.trim().length > 0,
);
expect(reasoningItems.length).toBeGreaterThan(0);
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
PI_TEST_TIMEOUT_MS,
);
test(
"session persistence survives delete and resume",
async () => {
const cwd = tmpCwd();
const rememberedToken = "PERSISTENCE_TOKEN_42";
try {
await withConnectedPiDaemon(async ({ client }) => {
const agent = await client.createAgent({
cwd,
title: "pi-persistence",
provider: "pi",
model: PI_REAL_TEST_MODEL,
});
await client.sendMessage(agent.id, `Remember this code: ${rememberedToken}`);
const initialFinish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
expect(initialFinish.status).toBe("idle");
expect(initialFinish.final?.persistence).toBeTruthy();
const handle = initialFinish.final?.persistence as AgentPersistenceHandle;
await client.deleteAgent(agent.id);
const resumed = await client.resumeAgent(handle);
await client.sendMessage(resumed.id, "What was the code I asked you to remember?");
const resumedFinish = await client.waitForFinish(resumed.id, PI_TEST_TIMEOUT_MS);
expect(resumedFinish.status).toBe("idle");
const items = await fetchCanonicalTimeline(client, resumed.id);
const assistantText = extractAssistantText(items).toUpperCase();
expect(assistantText.includes(rememberedToken) || assistantText.includes("42")).toBe(
true,
);
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
PI_TEST_TIMEOUT_MS,
);
test(
"streamHistory replays user/assistant/tool_call timeline after resume",
async () => {
const cwd = tmpCwd("pi-history-prime-");
const marker = "HISTORY_PRIME_MARKER_4242";
const piClient = createPiClient();
const session = await piClient.createSession({
provider: "pi",
try {
await withConnectedPiDaemon(async ({ client }) => {
const agent = await client.createAgent({
cwd,
title: "pi-bash-tool-call",
provider: "pi",
model: PI_REAL_TEST_MODEL,
});
let handle: AgentPersistenceHandle | null = null;
await client.sendMessage(
agent.id,
"Use the bash tool and run this exact bash command: echo HELLO_PI_TEST",
);
try {
const result = await session.run(
`Use the bash tool to run this exact bash command: echo ${marker}`,
);
const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
expect(finish.status).toBe("idle");
const hadShellToolCall = result.timeline.some(
(item) =>
item.type === "tool_call" &&
item.status === "completed" &&
item.detail.type === "shell" &&
item.detail.command.includes(`echo ${marker}`),
);
expect(hadShellToolCall).toBe(true);
const items = await fetchCanonicalTimeline(client, agent.id);
const toolCall = findCompletedToolCall(
items,
(item) =>
item.detail.type === "shell" && item.detail.command.includes("echo HELLO_PI_TEST"),
);
handle = session.describePersistence();
expect(handle).toBeTruthy();
} finally {
await session.close();
expect(toolCall).toBeDefined();
expect(toolCall?.status).toBe("completed");
expect(toolCall?.detail.type).toBe("shell");
if (toolCall?.detail.type === "shell") {
expect(toolCall.detail.command).toContain("echo HELLO_PI_TEST");
expect(toolCall.detail.output).toContain("HELLO_PI_TEST");
}
const resumed = await piClient.resumeSession(handle as AgentPersistenceHandle);
try {
const events: AgentStreamEvent[] = [];
for await (const event of resumed.streamHistory()) {
events.push(event);
}
const items = events
.filter(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline",
)
.map((event) => event.item);
const userItems = items.filter(
(item): item is Extract<AgentTimelineItem, { type: "user_message" }> =>
item.type === "user_message",
);
expect(userItems.length).toBeGreaterThan(0);
expect(userItems.some((item) => item.text.includes(marker))).toBe(true);
expect(userItems.every((item) => typeof item.messageId === "string")).toBe(true);
const assistantItems = items.filter(
(item): item is Extract<AgentTimelineItem, { type: "assistant_message" }> =>
item.type === "assistant_message",
);
expect(assistantItems.length).toBeGreaterThan(0);
const toolCallItem = findCompletedToolCall(
items,
(item) =>
item.detail.type === "shell" && item.detail.command.includes(`echo ${marker}`),
);
expect(toolCallItem).toBeDefined();
if (toolCallItem?.detail.type === "shell") {
expect(toolCallItem.detail.output).toContain(marker);
}
} finally {
await resumed.close();
rmSync(cwd, { recursive: true, force: true });
}
},
PI_TEST_TIMEOUT_MS,
);
test(
"PiDirectAgentClient.listModels returns non-empty Pi model definitions",
async () => {
const client = createPiClient();
const cwd = tmpCwd("pi-list-models-");
try {
const models = await client.listModels({ cwd, force: false });
expect(models.length).toBeGreaterThan(0);
for (const model of models) {
expect(model.provider).toBe("pi");
expect(typeof model.id).toBe("string");
expect(model.id.length).toBeGreaterThan(0);
expect(typeof model.label).toBe("string");
expect(model.label.length).toBeGreaterThan(0);
}
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
PI_TEST_TIMEOUT_MS,
);
test(
"session getRuntimeInfo reflects configured high thinking level",
async () => {
const cwd = tmpCwd("pi-runtime-info-");
const client = createPiClient();
try {
const session = await client.createSession({
provider: "pi",
cwd,
thinkingOptionId: "high",
});
try {
const runtimeInfo = await session.getRuntimeInfo();
expect(runtimeInfo.thinkingOptionId).toBe("high");
} finally {
await session.close();
}
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
PI_TEST_TIMEOUT_MS,
);
test(
"session setThinkingOption('low') updates runtime thinking level",
async () => {
const cwd = tmpCwd("pi-feature-");
const client = createPiClient();
try {
const session = await client.createSession({
provider: "pi",
cwd,
});
try {
await session.setThinkingOption?.("low");
const runtimeInfo = await session.getRuntimeInfo();
expect(runtimeInfo.thinkingOptionId).toBe("low");
} finally {
await session.close();
}
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
PI_TEST_TIMEOUT_MS,
);
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
PI_SUITE_TIMEOUT_MS,
PI_TEST_TIMEOUT_MS,
);
test(
"file read tool call captures read detail and content",
async () => {
const cwd = tmpCwd();
const filename = "pi-read.txt";
const expectedContent = "PI_READ_CONTENT_12345";
try {
writeFileSync(path.join(cwd, filename), expectedContent, "utf8");
await withConnectedPiDaemon(async ({ client }) => {
const agent = await client.createAgent({
cwd,
title: "pi-file-read",
provider: "pi",
model: PI_REAL_TEST_MODEL,
});
await client.sendMessage(
agent.id,
`Use the read tool to read the file ${filename} and tell me its contents exactly.`,
);
const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
expect(finish.status).toBe("idle");
const items = await fetchCanonicalTimeline(client, agent.id);
const toolCall = findCompletedToolCall(
items,
(item) =>
item.detail.type === "read" &&
item.detail.filePath.includes(filename) &&
item.detail.content?.includes(expectedContent) === true,
);
expect(toolCall).toBeDefined();
expect(toolCall?.detail.type).toBe("read");
if (toolCall?.detail.type === "read") {
expect(toolCall.detail.filePath).toContain(filename);
expect(toolCall.detail.content).toContain(expectedContent);
}
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
PI_TEST_TIMEOUT_MS,
);
test(
"file write tool call captures write detail and writes to disk",
async () => {
const cwd = tmpCwd();
const filename = "pi-test-write.txt";
const expectedContent = "PI_WRITE_CONTENT_67890";
try {
await withConnectedPiDaemon(async ({ client }) => {
const agent = await client.createAgent({
cwd,
title: "pi-file-write",
provider: "pi",
model: PI_REAL_TEST_MODEL,
});
await client.sendMessage(
agent.id,
`Use the write tool to write a file called ${filename} in the current directory with the exact content ${expectedContent}`,
);
const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
expect(finish.status).toBe("idle");
const items = await fetchCanonicalTimeline(client, agent.id);
const toolCall = findCompletedToolCall(
items,
(item) => item.detail.type === "write" && item.detail.filePath.includes(filename),
);
expect(toolCall).toBeDefined();
expect(toolCall?.detail.type).toBe("write");
expect(existsSync(path.join(cwd, filename))).toBe(true);
expect(readFileSync(path.join(cwd, filename), "utf8")).toBe(expectedContent);
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
PI_TEST_TIMEOUT_MS,
);
test(
"file edit tool call captures edit detail and updates the file on disk",
async () => {
const cwd = tmpCwd();
const filename = "pi-edit.txt";
const filePath = path.join(cwd, filename);
try {
writeFileSync(filePath, "BEFORE_EDIT", "utf8");
await withConnectedPiDaemon(async ({ client }) => {
const agent = await client.createAgent({
cwd,
title: "pi-file-edit",
provider: "pi",
model: PI_REAL_TEST_MODEL,
});
await client.sendMessage(
agent.id,
`Use the edit tool on the file ${filename} and replace BEFORE_EDIT with AFTER_EDIT. Do not just describe the change.`,
);
const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
expect(finish.status).toBe("idle");
const items = await fetchCanonicalTimeline(client, agent.id);
const toolCall = findCompletedToolCall(
items,
(item) => item.detail.type === "edit" && item.detail.filePath.includes(filename),
);
expect(toolCall).toBeDefined();
expect(toolCall?.detail.type).toBe("edit");
expect(readFileSync(filePath, "utf8")).toContain("AFTER_EDIT");
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
PI_TEST_TIMEOUT_MS,
);
test(
"thinking-enabled runs emit reasoning timeline chunks",
async () => {
const cwd = tmpCwd();
try {
await withConnectedPiDaemon(async ({ client }) => {
const agent = await client.createAgent({
cwd,
title: "pi-reasoning",
provider: "pi",
model: PI_REAL_TEST_MODEL,
thinkingOptionId: "high",
});
await client.sendMessage(
agent.id,
"Think step by step about what 7 * 13 equals, and give the final answer at the end.",
);
const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
expect(finish.status).toBe("idle");
const items = await fetchCanonicalTimeline(client, agent.id);
const reasoningItems = items.filter(
(item): item is Extract<AgentTimelineItem, { type: "reasoning" }> =>
item.type === "reasoning" && item.text.trim().length > 0,
);
expect(reasoningItems.length).toBeGreaterThan(0);
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
PI_TEST_TIMEOUT_MS,
);
test(
"session persistence survives delete and resume",
async () => {
const cwd = tmpCwd();
const rememberedToken = "PERSISTENCE_TOKEN_42";
try {
await withConnectedPiDaemon(async ({ client }) => {
const agent = await client.createAgent({
cwd,
title: "pi-persistence",
provider: "pi",
model: PI_REAL_TEST_MODEL,
});
await client.sendMessage(agent.id, `Remember this code: ${rememberedToken}`);
const initialFinish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
expect(initialFinish.status).toBe("idle");
expect(initialFinish.final?.persistence).toBeTruthy();
const handle = initialFinish.final?.persistence as AgentPersistenceHandle;
await client.deleteAgent(agent.id);
const resumed = await client.resumeAgent(handle);
await client.sendMessage(resumed.id, "What was the code I asked you to remember?");
const resumedFinish = await client.waitForFinish(resumed.id, PI_TEST_TIMEOUT_MS);
expect(resumedFinish.status).toBe("idle");
const items = await fetchCanonicalTimeline(client, resumed.id);
const assistantText = extractAssistantText(items).toUpperCase();
expect(assistantText.includes(rememberedToken) || assistantText.includes("42")).toBe(true);
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
PI_TEST_TIMEOUT_MS,
);
test(
"streamHistory replays user/assistant/tool_call timeline after resume",
async () => {
const cwd = tmpCwd("pi-history-prime-");
const marker = "HISTORY_PRIME_MARKER_4242";
const piClient = createPiClient();
const session = await piClient.createSession({
provider: "pi",
cwd,
model: PI_REAL_TEST_MODEL,
});
let handle: AgentPersistenceHandle | null = null;
try {
const result = await session.run(
`Use the bash tool to run this exact bash command: echo ${marker}`,
);
const hadShellToolCall = result.timeline.some(
(item) =>
item.type === "tool_call" &&
item.status === "completed" &&
item.detail.type === "shell" &&
item.detail.command.includes(`echo ${marker}`),
);
expect(hadShellToolCall).toBe(true);
handle = session.describePersistence();
expect(handle).toBeTruthy();
} finally {
await session.close();
}
const resumed = await piClient.resumeSession(handle as AgentPersistenceHandle);
try {
const events: AgentStreamEvent[] = [];
for await (const event of resumed.streamHistory()) {
events.push(event);
}
const items = events
.filter(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline",
)
.map((event) => event.item);
const userItems = items.filter(
(item): item is Extract<AgentTimelineItem, { type: "user_message" }> =>
item.type === "user_message",
);
expect(userItems.length).toBeGreaterThan(0);
expect(userItems.some((item) => item.text.includes(marker))).toBe(true);
expect(userItems.every((item) => typeof item.messageId === "string")).toBe(true);
const assistantItems = items.filter(
(item): item is Extract<AgentTimelineItem, { type: "assistant_message" }> =>
item.type === "assistant_message",
);
expect(assistantItems.length).toBeGreaterThan(0);
const toolCallItem = findCompletedToolCall(
items,
(item) => item.detail.type === "shell" && item.detail.command.includes(`echo ${marker}`),
);
expect(toolCallItem).toBeDefined();
if (toolCallItem?.detail.type === "shell") {
expect(toolCallItem.detail.output).toContain(marker);
}
} finally {
await resumed.close();
rmSync(cwd, { recursive: true, force: true });
}
},
PI_TEST_TIMEOUT_MS,
);
test(
"PiDirectAgentClient.listModels returns non-empty Pi model definitions",
async () => {
const client = createPiClient();
const cwd = tmpCwd("pi-list-models-");
try {
const models = await client.listModels({ cwd, force: false });
expect(models.length).toBeGreaterThan(0);
for (const model of models) {
expect(model.provider).toBe("pi");
expect(typeof model.id).toBe("string");
expect(model.id.length).toBeGreaterThan(0);
expect(typeof model.label).toBe("string");
expect(model.label.length).toBeGreaterThan(0);
}
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
PI_TEST_TIMEOUT_MS,
);
test(
"session getRuntimeInfo reflects configured high thinking level",
async () => {
const cwd = tmpCwd("pi-runtime-info-");
const client = createPiClient();
try {
const session = await client.createSession({
provider: "pi",
cwd,
thinkingOptionId: "high",
});
try {
const runtimeInfo = await session.getRuntimeInfo();
expect(runtimeInfo.thinkingOptionId).toBe("high");
} finally {
await session.close();
}
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
PI_TEST_TIMEOUT_MS,
);
test(
"session setThinkingOption('low') updates runtime thinking level",
async () => {
const cwd = tmpCwd("pi-feature-");
const client = createPiClient();
try {
const session = await client.createSession({
provider: "pi",
cwd,
});
try {
await session.setThinkingOption?.("low");
const runtimeInfo = await session.getRuntimeInfo();
expect(runtimeInfo.thinkingOptionId).toBe("low");
} finally {
await session.close();
}
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
PI_TEST_TIMEOUT_MS,
);
File diff suppressed because it is too large Load Diff
@@ -47,198 +47,196 @@ function isLiveAssistantTimeline(
);
}
describe("daemon E2E - timeline reconnect contract", () => {
let ctx: DaemonTestContext;
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60_000);
afterEach(async () => {
await ctx.cleanup();
}, 60_000);
test("reconnect catches up committed rows without replaying a provisional seed", async () => {
const cwd = tmpCwd();
const primaryCollector = createMessageCollector(ctx.client);
test("reconnect catches up committed rows without replaying a provisional seed", async () => {
const cwd = tmpCwd();
const primaryCollector = createMessageCollector(ctx.client);
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Reconnect Contract Test",
modeId: "full-access",
});
for (let seq = 1; seq <= 120; seq += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "assistant_message",
text: `committed row ${seq}`,
});
}
const baseline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const epoch = baseline.epoch;
expect(epoch).not.toBe("");
expect(baseline.endCursor?.epoch).toBe(epoch);
primaryCollector.clear();
await ctx.daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, {
type: "assistant_message",
text: "partial before disconnect",
});
await waitFor(() =>
primaryCollector.messages.some((message) =>
isLiveAssistantTimeline(message, agent.id, epoch, "partial before disconnect"),
),
);
await ctx.client.close();
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "assistant_message",
text: "finalized while disconnected",
});
const reconnectClient = new DaemonClient({
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
});
await reconnectClient.connect();
const reconnectCollector = createMessageCollector(reconnectClient);
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Reconnect Contract Test",
modeId: "full-access",
await reconnectClient.fetchAgents({
subscribe: { subscriptionId: "timeline-reconnect-a" },
});
for (let seq = 1; seq <= 120; seq += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "assistant_message",
text: `committed row ${seq}`,
});
}
const baseline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
expect(
reconnectCollector.messages.some((message) =>
isLiveAssistantTimeline(message, agent.id, epoch),
),
).toBe(false);
const catchUp = await reconnectClient.fetchAgentTimeline(agent.id, {
direction: "after",
cursor: { epoch, seq: 120 },
limit: 0,
projection: "canonical",
});
const epoch = baseline.epoch;
expect(epoch).not.toBe("");
expect(baseline.endCursor?.epoch).toBe(epoch);
primaryCollector.clear();
await ctx.daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, {
type: "assistant_message",
text: "partial before disconnect",
});
await waitFor(() =>
primaryCollector.messages.some((message) =>
isLiveAssistantTimeline(message, agent.id, epoch, "partial before disconnect"),
),
);
await ctx.client.close();
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
expect(catchUp.epoch).toBe(epoch);
expect(catchUp.reset).toBe(false);
expect(catchUp.staleCursor).toBe(false);
expect(catchUp.gap).toBe(false);
expect(catchUp.entries).toHaveLength(1);
expect(catchUp.startCursor).toEqual({ epoch, seq: 121 });
expect(catchUp.endCursor).toEqual({ epoch, seq: 121 });
expect(catchUp.entries[0]?.seqStart).toBe(121);
expect(catchUp.entries[0]?.seqEnd).toBe(121);
expect(catchUp.entries[0]?.item).toEqual({
type: "assistant_message",
text: "finalized while disconnected",
});
const reconnectClient = new DaemonClient({
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
});
await reconnectClient.connect();
const reconnectCollector = createMessageCollector(reconnectClient);
try {
await reconnectClient.fetchAgents({
subscribe: { subscriptionId: "timeline-reconnect-a" },
});
expect(
reconnectCollector.messages.some((message) =>
isLiveAssistantTimeline(message, agent.id, epoch),
),
).toBe(false);
const catchUp = await reconnectClient.fetchAgentTimeline(agent.id, {
direction: "after",
cursor: { epoch, seq: 120 },
limit: 0,
projection: "canonical",
});
expect(catchUp.epoch).toBe(epoch);
expect(catchUp.reset).toBe(false);
expect(catchUp.staleCursor).toBe(false);
expect(catchUp.gap).toBe(false);
expect(catchUp.entries).toHaveLength(1);
expect(catchUp.startCursor).toEqual({ epoch, seq: 121 });
expect(catchUp.endCursor).toEqual({ epoch, seq: 121 });
expect(catchUp.entries[0]?.seqStart).toBe(121);
expect(catchUp.entries[0]?.seqEnd).toBe(121);
expect(catchUp.entries[0]?.item).toEqual({
type: "assistant_message",
text: "finalized while disconnected",
});
} finally {
reconnectCollector.unsubscribe();
await reconnectClient.close();
}
} finally {
primaryCollector.unsubscribe();
rmSync(cwd, { recursive: true, force: true });
reconnectCollector.unsubscribe();
await reconnectClient.close();
}
}, 30_000);
} finally {
primaryCollector.unsubscribe();
rmSync(cwd, { recursive: true, force: true });
}
}, 30_000);
test("reconnect with no new committed rows resumes from future live provisional updates only", async () => {
const cwd = tmpCwd();
const primaryCollector = createMessageCollector(ctx.client);
test("reconnect with no new committed rows resumes from future live provisional updates only", async () => {
const cwd = tmpCwd();
const primaryCollector = createMessageCollector(ctx.client);
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Reconnect No Seed Test",
modeId: "full-access",
});
for (let seq = 1; seq <= 120; seq += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "assistant_message",
text: `committed row ${seq}`,
});
}
const baseline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const epoch = baseline.epoch;
expect(epoch).not.toBe("");
expect(baseline.endCursor?.epoch).toBe(epoch);
primaryCollector.clear();
await ctx.daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, {
type: "assistant_message",
text: "partial before disconnect",
});
await waitFor(() =>
primaryCollector.messages.some((message) =>
isLiveAssistantTimeline(message, agent.id, epoch, "partial before disconnect"),
),
);
await ctx.client.close();
const reconnectClient = new DaemonClient({
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
});
await reconnectClient.connect();
const reconnectCollector = createMessageCollector(reconnectClient);
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Reconnect No Seed Test",
modeId: "full-access",
await reconnectClient.fetchAgents({
subscribe: { subscriptionId: "timeline-reconnect-b" },
});
for (let seq = 1; seq <= 120; seq += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "assistant_message",
text: `committed row ${seq}`,
});
}
const baseline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
expect(
reconnectCollector.messages.some((message) =>
isLiveAssistantTimeline(message, agent.id, epoch),
),
).toBe(false);
const catchUp = await reconnectClient.fetchAgentTimeline(agent.id, {
direction: "after",
cursor: { epoch, seq: 120 },
limit: 0,
projection: "canonical",
});
const epoch = baseline.epoch;
expect(epoch).not.toBe("");
expect(baseline.endCursor?.epoch).toBe(epoch);
primaryCollector.clear();
expect(catchUp.epoch).toBe(epoch);
expect(catchUp.reset).toBe(false);
expect(catchUp.staleCursor).toBe(false);
expect(catchUp.gap).toBe(false);
expect(catchUp.entries).toHaveLength(0);
expect(catchUp.startCursor).toBeNull();
expect(catchUp.endCursor).toBeNull();
reconnectCollector.clear();
await ctx.daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, {
type: "assistant_message",
text: "partial before disconnect",
text: "fresh live after reconnect",
});
await waitFor(() =>
primaryCollector.messages.some((message) =>
isLiveAssistantTimeline(message, agent.id, epoch, "partial before disconnect"),
reconnectCollector.messages.some((message) =>
isLiveAssistantTimeline(message, agent.id, epoch, "fresh live after reconnect"),
),
);
await ctx.client.close();
const reconnectClient = new DaemonClient({
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
});
await reconnectClient.connect();
const reconnectCollector = createMessageCollector(reconnectClient);
try {
await reconnectClient.fetchAgents({
subscribe: { subscriptionId: "timeline-reconnect-b" },
});
expect(
reconnectCollector.messages.some((message) =>
isLiveAssistantTimeline(message, agent.id, epoch),
),
).toBe(false);
const catchUp = await reconnectClient.fetchAgentTimeline(agent.id, {
direction: "after",
cursor: { epoch, seq: 120 },
limit: 0,
projection: "canonical",
});
expect(catchUp.epoch).toBe(epoch);
expect(catchUp.reset).toBe(false);
expect(catchUp.staleCursor).toBe(false);
expect(catchUp.gap).toBe(false);
expect(catchUp.entries).toHaveLength(0);
expect(catchUp.startCursor).toBeNull();
expect(catchUp.endCursor).toBeNull();
reconnectCollector.clear();
await ctx.daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, {
type: "assistant_message",
text: "fresh live after reconnect",
});
await waitFor(() =>
reconnectCollector.messages.some((message) =>
isLiveAssistantTimeline(message, agent.id, epoch, "fresh live after reconnect"),
),
);
} finally {
reconnectCollector.unsubscribe();
await reconnectClient.close();
}
} finally {
primaryCollector.unsubscribe();
rmSync(cwd, { recursive: true, force: true });
reconnectCollector.unsubscribe();
await reconnectClient.close();
}
}, 30_000);
});
} finally {
primaryCollector.unsubscribe();
rmSync(cwd, { recursive: true, force: true });
}
}, 30_000);
@@ -87,301 +87,299 @@ function pathFromToolCallDetail(detail: ToolCallItem["detail"]): string | null {
return null;
}
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60000);
afterEach(async () => {
await ctx.cleanup();
}, 60000);
describe("tool call structure", () => {
// Helper to extract and dedupe tool calls by callId, keeping the last (most complete) version
function extractToolCalls(queue: SessionOutboundMessage[], agentId: string): ToolCallItem[] {
const byCallId = new Map<string, ToolCallItem>();
const noCallId: ToolCallItem[] = [];
describe("tool call structure", () => {
// Helper to extract and dedupe tool calls by callId, keeping the last (most complete) version
function extractToolCalls(queue: SessionOutboundMessage[], agentId: string): ToolCallItem[] {
const byCallId = new Map<string, ToolCallItem>();
const noCallId: ToolCallItem[] = [];
for (const m of queue) {
if (
m.type === "agent_stream" &&
m.payload.agentId === agentId &&
m.payload.event.type === "timeline" &&
m.payload.event.item.type === "tool_call"
) {
const tc = m.payload.event.item;
if (tc.callId) {
byCallId.set(tc.callId, tc);
} else {
noCallId.push(tc);
}
for (const m of queue) {
if (
m.type === "agent_stream" &&
m.payload.agentId === agentId &&
m.payload.event.type === "timeline" &&
m.payload.event.item.type === "tool_call"
) {
const tc = m.payload.event.item;
if (tc.callId) {
byCallId.set(tc.callId, tc);
} else {
noCallId.push(tc);
}
}
return [...byCallId.values(), ...noCallId];
}
// Helper to log tool call structure in a consistent format
function logToolCall(prefix: string, tc: ToolCallItem): void {
void prefix;
void tc;
return [...byCallId.values(), ...noCallId];
}
// Helper to log tool call structure in a consistent format
function logToolCall(prefix: string, tc: ToolCallItem): void {
void prefix;
void tc;
}
test("Claude agent: Read tool", async () => {
const cwd = tmpCwd();
const collector = createMessageCollector(ctx.client);
const agent = await ctx.client.createAgent({
provider: "claude",
cwd,
title: "Claude Read Test",
modeId: "bypassPermissions",
});
collector.clear();
await ctx.client.sendMessage(
agent.id,
"Read the file /etc/hosts and tell me how many lines it has. Be brief.",
);
await ctx.client.waitForFinish(agent.id, 120000);
const toolCalls = extractToolCalls(collector.messages, agent.id);
expect(toolCalls.length).toBeGreaterThan(0);
for (const tc of toolCalls) {
logToolCall("CLAUDE_READ", tc);
}
test("Claude agent: Read tool", async () => {
const cwd = tmpCwd();
const collector = createMessageCollector(ctx.client);
const readCall = toolCalls.find((tc) => tc.name === "Read");
expect(readCall).toBeDefined();
expect(readCall?.name).toBe("Read");
expect(readCall?.detail).toBeDefined();
const readPath = readCall ? pathFromToolCallDetail(readCall.detail) : null;
expect(readPath).toBeTruthy();
if (readPath) {
expect(readPath).toContain("/etc/hosts");
}
const agent = await ctx.client.createAgent({
provider: "claude",
cwd,
title: "Claude Read Test",
modeId: "bypassPermissions",
});
await ctx.client.deleteAgent(agent.id);
collector.unsubscribe();
rmSync(cwd, { recursive: true, force: true });
}, 180000);
collector.clear();
test("Claude agent: Bash tool", async () => {
const cwd = tmpCwd();
const collector = createMessageCollector(ctx.client);
await ctx.client.sendMessage(
agent.id,
"Read the file /etc/hosts and tell me how many lines it has. Be brief.",
);
const agent = await ctx.client.createAgent({
provider: "claude",
cwd,
title: "Claude Bash Test",
modeId: "bypassPermissions",
});
await ctx.client.waitForFinish(agent.id, 120000);
collector.clear();
const toolCalls = extractToolCalls(collector.messages, agent.id);
expect(toolCalls.length).toBeGreaterThan(0);
await ctx.client.sendMessage(
agent.id,
"Run `echo hello` and tell me what it outputs. Be brief.",
);
for (const tc of toolCalls) {
logToolCall("CLAUDE_READ", tc);
}
await ctx.client.waitForFinish(agent.id, 120000);
const readCall = toolCalls.find((tc) => tc.name === "Read");
expect(readCall).toBeDefined();
expect(readCall?.name).toBe("Read");
expect(readCall?.detail).toBeDefined();
const readPath = readCall ? pathFromToolCallDetail(readCall.detail) : null;
expect(readPath).toBeTruthy();
if (readPath) {
expect(readPath).toContain("/etc/hosts");
}
const toolCalls = extractToolCalls(collector.messages, agent.id);
expect(toolCalls.length).toBeGreaterThan(0);
await ctx.client.deleteAgent(agent.id);
collector.unsubscribe();
rmSync(cwd, { recursive: true, force: true });
}, 180000);
for (const tc of toolCalls) {
logToolCall("CLAUDE_BASH", tc);
}
test("Claude agent: Bash tool", async () => {
const cwd = tmpCwd();
const collector = createMessageCollector(ctx.client);
const bashCall = toolCalls.find((tc) => tc.name === "Bash");
expect(bashCall).toBeDefined();
expect(bashCall?.name).toBe("Bash");
expect(bashCall?.detail).toBeDefined();
const command = bashCall ? commandFromToolCallDetail(bashCall.detail) : null;
expect(command).toBeTruthy();
if (command) {
expect(command).toContain("echo");
}
const agent = await ctx.client.createAgent({
provider: "claude",
cwd,
title: "Claude Bash Test",
modeId: "bypassPermissions",
});
await ctx.client.deleteAgent(agent.id);
collector.unsubscribe();
rmSync(cwd, { recursive: true, force: true });
}, 180000);
collector.clear();
test("Claude agent: Edit tool", async () => {
const cwd = tmpCwd();
const collector = createMessageCollector(ctx.client);
const testFile = path.join(cwd, "test.txt");
writeFileSync(testFile, "hello world\n");
await ctx.client.sendMessage(
agent.id,
"Run `echo hello` and tell me what it outputs. Be brief.",
);
const agent = await ctx.client.createAgent({
provider: "claude",
cwd,
title: "Claude Edit Test",
modeId: "bypassPermissions",
});
await ctx.client.waitForFinish(agent.id, 120000);
collector.clear();
const toolCalls = extractToolCalls(collector.messages, agent.id);
expect(toolCalls.length).toBeGreaterThan(0);
await ctx.client.sendMessage(
agent.id,
`Edit the file ${testFile} and change "hello" to "goodbye". Be brief.`,
);
for (const tc of toolCalls) {
logToolCall("CLAUDE_BASH", tc);
}
await ctx.client.waitForFinish(agent.id, 120000);
const bashCall = toolCalls.find((tc) => tc.name === "Bash");
expect(bashCall).toBeDefined();
expect(bashCall?.name).toBe("Bash");
expect(bashCall?.detail).toBeDefined();
const command = bashCall ? commandFromToolCallDetail(bashCall.detail) : null;
expect(command).toBeTruthy();
if (command) {
expect(command).toContain("echo");
}
const toolCalls = extractToolCalls(collector.messages, agent.id);
expect(toolCalls.length).toBeGreaterThan(0);
await ctx.client.deleteAgent(agent.id);
collector.unsubscribe();
rmSync(cwd, { recursive: true, force: true });
}, 180000);
for (const tc of toolCalls) {
logToolCall("CLAUDE_EDIT", tc);
}
test("Claude agent: Edit tool", async () => {
const cwd = tmpCwd();
const collector = createMessageCollector(ctx.client);
const testFile = path.join(cwd, "test.txt");
writeFileSync(testFile, "hello world\n");
const editCall = toolCalls.find((tc) => tc.name === "Edit");
expect(editCall).toBeDefined();
expect(editCall?.detail).toBeDefined();
const editPath = editCall ? pathFromToolCallDetail(editCall.detail) : null;
if (editPath) {
expect(editPath).toBe(testFile);
} else if (editCall?.detail.type === "unknown") {
expect(editCall.detail.input ?? editCall.detail.output).toBeTruthy();
} else {
expect(editCall?.detail.type).toBe("edit");
}
const agent = await ctx.client.createAgent({
provider: "claude",
cwd,
title: "Claude Edit Test",
modeId: "bypassPermissions",
});
await ctx.client.deleteAgent(agent.id);
collector.unsubscribe();
rmSync(cwd, { recursive: true, force: true });
}, 180000);
collector.clear();
test("Codex agent: shell command", async () => {
const cwd = tmpCwd();
const collector = createMessageCollector(ctx.client);
await ctx.client.sendMessage(
agent.id,
`Edit the file ${testFile} and change "hello" to "goodbye". Be brief.`,
);
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Codex Shell Test",
modeId: "full-access",
});
await ctx.client.waitForFinish(agent.id, 120000);
collector.clear();
const toolCalls = extractToolCalls(collector.messages, agent.id);
expect(toolCalls.length).toBeGreaterThan(0);
await ctx.client.sendMessage(
agent.id,
"Run `echo hello` and tell me what it outputs. Be brief.",
);
for (const tc of toolCalls) {
logToolCall("CLAUDE_EDIT", tc);
}
await ctx.client.waitForFinish(agent.id, 120000);
const editCall = toolCalls.find((tc) => tc.name === "Edit");
expect(editCall).toBeDefined();
expect(editCall?.detail).toBeDefined();
const editPath = editCall ? pathFromToolCallDetail(editCall.detail) : null;
if (editPath) {
expect(editPath).toBe(testFile);
} else if (editCall?.detail.type === "unknown") {
expect(editCall.detail.input ?? editCall.detail.output).toBeTruthy();
} else {
expect(editCall?.detail.type).toBe("edit");
}
const toolCalls = extractToolCalls(collector.messages, agent.id);
expect(toolCalls.length).toBeGreaterThan(0);
await ctx.client.deleteAgent(agent.id);
collector.unsubscribe();
rmSync(cwd, { recursive: true, force: true });
}, 180000);
for (const tc of toolCalls) {
logToolCall("CODEX_SHELL", tc);
}
test("Codex agent: shell command", async () => {
const cwd = tmpCwd();
const collector = createMessageCollector(ctx.client);
const shellCalls = toolCalls.filter((tc) => tc.name === "shell");
expect(shellCalls.length).toBeGreaterThan(0);
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Codex Shell Test",
modeId: "full-access",
});
const echoCall = shellCalls.find((tc) => {
const command = commandFromToolCallDetail(tc.detail);
return typeof command === "string" && command.includes("echo");
});
expect(echoCall).toBeDefined();
collector.clear();
await ctx.client.deleteAgent(agent.id);
collector.unsubscribe();
rmSync(cwd, { recursive: true, force: true });
}, 180000);
await ctx.client.sendMessage(
agent.id,
"Run `echo hello` and tell me what it outputs. Be brief.",
);
test("Codex agent: file read", async () => {
const cwd = tmpCwd();
const collector = createMessageCollector(ctx.client);
await ctx.client.waitForFinish(agent.id, 120000);
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Codex Read Test",
modeId: "full-access",
});
const toolCalls = extractToolCalls(collector.messages, agent.id);
expect(toolCalls.length).toBeGreaterThan(0);
collector.clear();
for (const tc of toolCalls) {
logToolCall("CODEX_SHELL", tc);
}
await ctx.client.sendMessage(
agent.id,
"Read the file /etc/hosts and tell me how many lines it has. Be brief.",
);
const shellCalls = toolCalls.filter((tc) => tc.name === "shell");
expect(shellCalls.length).toBeGreaterThan(0);
await ctx.client.waitForFinish(agent.id, 120000);
const echoCall = shellCalls.find((tc) => {
const command = commandFromToolCallDetail(tc.detail);
return typeof command === "string" && command.includes("echo");
});
expect(echoCall).toBeDefined();
const toolCalls = extractToolCalls(collector.messages, agent.id);
expect(toolCalls.length).toBeGreaterThan(0);
await ctx.client.deleteAgent(agent.id);
collector.unsubscribe();
rmSync(cwd, { recursive: true, force: true });
}, 180000);
for (const tc of toolCalls) {
logToolCall("CODEX_READ", tc);
}
test("Codex agent: file read", async () => {
const cwd = tmpCwd();
const collector = createMessageCollector(ctx.client);
// Codex may use shell cat or file read
const readCall = toolCalls.find((tc) => tc.type === "tool_call" && tc.name === "read_file");
if (readCall) {
expect(readCall.name).toBe("read_file");
}
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Codex Read Test",
modeId: "full-access",
});
await ctx.client.deleteAgent(agent.id);
collector.unsubscribe();
rmSync(cwd, { recursive: true, force: true });
}, 180000);
collector.clear();
test("Codex agent: file edit", async () => {
const cwd = tmpCwd();
const collector = createMessageCollector(ctx.client);
const testFile = path.join(cwd, "test.txt");
writeFileSync(testFile, "hello world\n");
await ctx.client.sendMessage(
agent.id,
"Read the file /etc/hosts and tell me how many lines it has. Be brief.",
);
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Codex Edit Test",
modeId: "full-access",
});
await ctx.client.waitForFinish(agent.id, 120000);
collector.clear();
const toolCalls = extractToolCalls(collector.messages, agent.id);
expect(toolCalls.length).toBeGreaterThan(0);
await ctx.client.sendMessage(
agent.id,
`Edit the file ${testFile} and change "hello" to "goodbye". Be brief.`,
);
for (const tc of toolCalls) {
logToolCall("CODEX_READ", tc);
}
await ctx.client.waitForFinish(agent.id, 120000);
// Codex may use shell cat or file read
const readCall = toolCalls.find((tc) => tc.type === "tool_call" && tc.name === "read_file");
if (readCall) {
expect(readCall.name).toBe("read_file");
}
const toolCalls = extractToolCalls(collector.messages, agent.id);
expect(toolCalls.length).toBeGreaterThan(0);
await ctx.client.deleteAgent(agent.id);
collector.unsubscribe();
rmSync(cwd, { recursive: true, force: true });
}, 180000);
for (const tc of toolCalls) {
logToolCall("CODEX_EDIT", tc);
}
test("Codex agent: file edit", async () => {
const cwd = tmpCwd();
const collector = createMessageCollector(ctx.client);
const testFile = path.join(cwd, "test.txt");
writeFileSync(testFile, "hello world\n");
// Codex uses apply_patch for edits
const editCall = toolCalls.find((tc) => tc.type === "tool_call" && tc.name === "apply_patch");
if (editCall) {
expect(editCall.name).toBe("apply_patch");
}
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Codex Edit Test",
modeId: "full-access",
});
collector.clear();
await ctx.client.sendMessage(
agent.id,
`Edit the file ${testFile} and change "hello" to "goodbye". Be brief.`,
);
await ctx.client.waitForFinish(agent.id, 120000);
const toolCalls = extractToolCalls(collector.messages, agent.id);
expect(toolCalls.length).toBeGreaterThan(0);
for (const tc of toolCalls) {
logToolCall("CODEX_EDIT", tc);
}
// Codex uses apply_patch for edits
const editCall = toolCalls.find((tc) => tc.type === "tool_call" && tc.name === "apply_patch");
if (editCall) {
expect(editCall.name).toBe("apply_patch");
}
await ctx.client.deleteAgent(agent.id);
collector.unsubscribe();
rmSync(cwd, { recursive: true, force: true });
}, 180000);
});
await ctx.client.deleteAgent(agent.id);
collector.unsubscribe();
rmSync(cwd, { recursive: true, force: true });
}, 180000);
});
@@ -15,215 +15,213 @@ function tmpCwd(): string {
* Tests for waitForFinish edge cases.
* Uses haiku for speed. Allow higher timeouts in CI / congested environments.
*/
describe("waitForFinish edge cases", () => {
let ctx: DaemonTestContext;
let collector: MessageCollector;
let ctx: DaemonTestContext;
let collector: MessageCollector;
beforeEach(async () => {
ctx = await createDaemonTestContext();
collector = createMessageCollector(ctx.client);
beforeEach(async () => {
ctx = await createDaemonTestContext();
collector = createMessageCollector(ctx.client);
});
afterEach(async () => {
collector.unsubscribe();
await ctx.cleanup();
}, 30000);
test("waitForFinish immediately after sendMessage", async () => {
const cwd = tmpCwd();
const agent = await ctx.client.createAgent({
provider: "claude",
model: "haiku",
cwd,
title: "Immediate Wait Test",
modeId: "bypassPermissions",
});
afterEach(async () => {
collector.unsubscribe();
await ctx.cleanup();
}, 30000);
// This was the original bug: waitForFinish returned old idle states
collector.clear();
await ctx.client.sendMessage(agent.id, "Say 'hello'");
const state = await ctx.client.waitForFinish(agent.id, 30000);
test("waitForFinish immediately after sendMessage", async () => {
const cwd = tmpCwd();
expect(state.status).toBe("idle");
const agent = await ctx.client.createAgent({
provider: "claude",
model: "haiku",
cwd,
title: "Immediate Wait Test",
modeId: "bypassPermissions",
});
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
}, 45000);
// This was the original bug: waitForFinish returned old idle states
collector.clear();
await ctx.client.sendMessage(agent.id, "Say 'hello'");
test("rapid fire messages then single wait", async () => {
const cwd = tmpCwd();
const agent = await ctx.client.createAgent({
provider: "claude",
model: "haiku",
cwd,
title: "Rapid Fire Test",
modeId: "bypassPermissions",
});
// Send 3 messages without waiting - tests that waitForFinish
// finds the idle AFTER the last running state
collector.clear();
await ctx.client.sendMessage(agent.id, "Say 'one'");
await ctx.client.sendMessage(agent.id, "Say 'two'");
await ctx.client.sendMessage(agent.id, "Say 'three'");
const state = await ctx.client.waitForFinish(agent.id, 30000);
expect(state.status).toBe("idle");
// Verify all 3 messages were recorded
const userMessages = collector.messages.filter(
(m) =>
m.type === "agent_stream" &&
m.payload.agentId === agent.id &&
m.payload.event.type === "timeline" &&
m.payload.event.item.type === "user_message",
);
expect(userMessages.length).toBe(3);
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
}, 45000);
test("two agents: waitForFinish filters by agent", async () => {
const cwd1 = tmpCwd();
const cwd2 = tmpCwd();
const agent1 = await ctx.client.createAgent({
provider: "claude",
model: "haiku",
cwd: cwd1,
title: "Agent 1",
modeId: "bypassPermissions",
});
const agent2 = await ctx.client.createAgent({
provider: "claude",
model: "haiku",
cwd: cwd2,
title: "Agent 2",
modeId: "bypassPermissions",
});
// Start both agents
collector.clear();
await ctx.client.sendMessage(agent1.id, "Say 'agent one'");
await ctx.client.sendMessage(agent2.id, "Say 'agent two'");
// Wait for each - should not be confused by the other's state
const state2 = await ctx.client.waitForFinish(agent2.id, 30000);
expect(state2.status).toBe("idle");
expect(state2.final?.id).toBe(agent2.id);
const state1 = await ctx.client.waitForFinish(agent1.id, 30000);
expect(state1.status).toBe("idle");
expect(state1.final?.id).toBe(agent1.id);
await ctx.client.deleteAgent(agent1.id);
await ctx.client.deleteAgent(agent2.id);
rmSync(cwd1, { recursive: true, force: true });
rmSync(cwd2, { recursive: true, force: true });
}, 60000);
test("waitForFinish resolves first idle edge even if a new run starts immediately after", async () => {
const cwd = tmpCwd();
const secondary = new DaemonClient({ url: `ws://127.0.0.1:${ctx.daemon.port}/ws` });
await secondary.connect();
await secondary.fetchAgents({ subscribe: { subscriptionId: "wait-for-idle-secondary" } });
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Idle Edge Coherence",
modeId: "bypassPermissions",
});
let sawRunning = false;
let spawnedSecondRun = false;
let secondRunDrain: Promise<void> | null = null;
const unsubscribe = ctx.daemon.daemon.agentManager.subscribe(
(event) => {
if (event.type !== "agent_state" || event.agent.id !== agent.id) {
return;
}
if (event.agent.lifecycle === "running") {
sawRunning = true;
return;
}
if (event.agent.lifecycle !== "idle" || !sawRunning || spawnedSecondRun) {
return;
}
spawnedSecondRun = true;
secondRunDrain = (async () => {
await secondary.sendMessage(agent.id, "Reply with exactly: done.");
await secondary.waitForFinish(agent.id, 5_000);
})();
},
{ agentId: agent.id, replayState: false },
);
try {
await ctx.client.sendMessage(agent.id, "Reply with exactly: first");
const state = await ctx.client.waitForFinish(agent.id, 30000);
expect(spawnedSecondRun).toBe(true);
expect(state.status).toBe("idle");
expect(state.final?.status === "idle" || state.final?.status === "running").toBe(true);
expect(state.error).toBeNull();
} finally {
unsubscribe();
await secondRunDrain;
await secondary.close();
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
}, 45000);
}
}, 60000);
test("rapid fire messages then single wait", async () => {
const cwd = tmpCwd();
test("waitForFinish stays blocked when sendMessage transactionally replaces a running turn", async () => {
const cwd = tmpCwd();
const secondary = new DaemonClient({ url: `ws://127.0.0.1:${ctx.daemon.port}/ws` });
const agent = await ctx.client.createAgent({
provider: "claude",
model: "haiku",
cwd,
title: "Rapid Fire Test",
modeId: "bypassPermissions",
});
// Send 3 messages without waiting - tests that waitForFinish
// finds the idle AFTER the last running state
collector.clear();
await ctx.client.sendMessage(agent.id, "Say 'one'");
await ctx.client.sendMessage(agent.id, "Say 'two'");
await ctx.client.sendMessage(agent.id, "Say 'three'");
const state = await ctx.client.waitForFinish(agent.id, 30000);
expect(state.status).toBe("idle");
// Verify all 3 messages were recorded
const userMessages = collector.messages.filter(
(m) =>
m.type === "agent_stream" &&
m.payload.agentId === agent.id &&
m.payload.event.type === "timeline" &&
m.payload.event.item.type === "user_message",
);
expect(userMessages.length).toBe(3);
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
}, 45000);
test("two agents: waitForFinish filters by agent", async () => {
const cwd1 = tmpCwd();
const cwd2 = tmpCwd();
const agent1 = await ctx.client.createAgent({
provider: "claude",
model: "haiku",
cwd: cwd1,
title: "Agent 1",
modeId: "bypassPermissions",
});
const agent2 = await ctx.client.createAgent({
provider: "claude",
model: "haiku",
cwd: cwd2,
title: "Agent 2",
modeId: "bypassPermissions",
});
// Start both agents
collector.clear();
await ctx.client.sendMessage(agent1.id, "Say 'agent one'");
await ctx.client.sendMessage(agent2.id, "Say 'agent two'");
// Wait for each - should not be confused by the other's state
const state2 = await ctx.client.waitForFinish(agent2.id, 30000);
expect(state2.status).toBe("idle");
expect(state2.final?.id).toBe(agent2.id);
const state1 = await ctx.client.waitForFinish(agent1.id, 30000);
expect(state1.status).toBe("idle");
expect(state1.final?.id).toBe(agent1.id);
await ctx.client.deleteAgent(agent1.id);
await ctx.client.deleteAgent(agent2.id);
rmSync(cwd1, { recursive: true, force: true });
rmSync(cwd2, { recursive: true, force: true });
}, 60000);
test("waitForFinish resolves first idle edge even if a new run starts immediately after", async () => {
const cwd = tmpCwd();
const secondary = new DaemonClient({ url: `ws://127.0.0.1:${ctx.daemon.port}/ws` });
const agent = await ctx.client.createAgent({
...getFullAccessConfig("codex"),
cwd,
title: "Transactional Replacement Wait",
});
try {
await secondary.connect();
await secondary.fetchAgents({ subscribe: { subscriptionId: "wait-for-idle-secondary" } });
await secondary.fetchAgents({ subscribe: { subscriptionId: "secondary" } });
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Idle Edge Coherence",
modeId: "bypassPermissions",
});
let sawRunning = false;
let spawnedSecondRun = false;
let secondRunDrain: Promise<void> | null = null;
const unsubscribe = ctx.daemon.daemon.agentManager.subscribe(
(event) => {
if (event.type !== "agent_state" || event.agent.id !== agent.id) {
return;
}
if (event.agent.lifecycle === "running") {
sawRunning = true;
return;
}
if (event.agent.lifecycle !== "idle" || !sawRunning || spawnedSecondRun) {
return;
}
spawnedSecondRun = true;
secondRunDrain = (async () => {
await secondary.sendMessage(agent.id, "Reply with exactly: done.");
await secondary.waitForFinish(agent.id, 5_000);
})();
},
{ agentId: agent.id, replayState: false },
await ctx.client.sendMessage(agent.id, "Run: sleep 5");
await ctx.client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
5_000,
);
try {
await ctx.client.sendMessage(agent.id, "Reply with exactly: first");
const state = await ctx.client.waitForFinish(agent.id, 30000);
const waitPromise = ctx.client.waitForFinish(agent.id, 5_000);
expect(spawnedSecondRun).toBe(true);
expect(state.status).toBe("idle");
expect(state.final?.status === "idle" || state.final?.status === "running").toBe(true);
expect(state.error).toBeNull();
} finally {
unsubscribe();
await secondRunDrain;
await secondary.close();
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
}
}, 60000);
await secondary.sendMessage(agent.id, "Run: sleep 5");
await secondary.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
5_000,
);
test("waitForFinish stays blocked when sendMessage transactionally replaces a running turn", async () => {
const cwd = tmpCwd();
const secondary = new DaemonClient({ url: `ws://127.0.0.1:${ctx.daemon.port}/ws` });
const prematureResolution = await Promise.race([
waitPromise.then(() => "resolved"),
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 100)),
]);
expect(prematureResolution).toBe("pending");
const agent = await ctx.client.createAgent({
...getFullAccessConfig("codex"),
cwd,
title: "Transactional Replacement Wait",
});
try {
await secondary.connect();
await secondary.fetchAgents({ subscribe: { subscriptionId: "secondary" } });
await ctx.client.sendMessage(agent.id, "Run: sleep 5");
await ctx.client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
5_000,
);
const waitPromise = ctx.client.waitForFinish(agent.id, 5_000);
await secondary.sendMessage(agent.id, "Run: sleep 5");
await secondary.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
5_000,
);
const prematureResolution = await Promise.race([
waitPromise.then(() => "resolved"),
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 100)),
]);
expect(prematureResolution).toBe("pending");
const finalState = await waitPromise;
expect(finalState.status).toBe("idle");
} finally {
await secondary.close();
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
}
}, 30000);
});
const finalState = await waitPromise;
expect(finalState.status).toBe("idle");
} finally {
await secondary.close();
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
}
}, 30000);
@@ -11,129 +11,127 @@ import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspa
import { createPaseoWorktree, type CreatePaseoWorktreeDeps } from "./paseo-worktree-service.js";
import { createWorktreeCoreDeps } from "./worktree-core.js";
describe("createPaseoWorktree", () => {
const cleanupPaths: string[] = [];
const cleanupPaths: string[] = [];
afterEach(() => {
for (const target of cleanupPaths.splice(0)) {
rmSync(target, { recursive: true, force: true });
}
afterEach(() => {
for (const target of cleanupPaths.splice(0)) {
rmSync(target, { recursive: true, force: true });
}
});
test("creates a worktree and registers it in the source workspace project without git snapshot lookup", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const events: string[] = [];
const deps = createDeps({ events });
const sourceProject = createPersistedProjectRecordForTest({
projectId: "remote:github.com/acme/repo",
rootPath: repoDir,
displayName: "acme/repo",
});
const sourceWorkspace = createPersistedWorkspaceRecordForTest({
workspaceId: repoDir,
projectId: sourceProject.projectId,
cwd: repoDir,
kind: "local_checkout",
displayName: "main",
});
deps.projects.set(sourceProject.projectId, sourceProject);
deps.workspaces.set(sourceWorkspace.workspaceId, sourceWorkspace);
deps.workspaceGitService.getSnapshot = vi.fn(deps.workspaceGitService.getSnapshot);
const result = await createPaseoWorktree(
{
cwd: repoDir,
worktreeSlug: "feature-one",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
deps,
);
expect(result.created).toBe(true);
expect(result.workspace.cwd).toBe(result.worktree.worktreePath);
expect(result.workspace.kind).toBe("worktree");
expect(result.workspace.projectId).toBe("remote:github.com/acme/repo");
expect(result.workspace.displayName).toBe("feature-one");
expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(events).toEqual([
"project:remote:github.com/acme/repo",
`workspace:${result.workspace.workspaceId}`,
]);
});
test("reuses an existing worktree and still upserts the workspace", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const firstDeps = createDeps();
const first = await createPaseoWorktree(
{
cwd: repoDir,
worktreeSlug: "reuse-me",
runSetup: false,
paseoHome,
},
firstDeps,
);
const events: string[] = [];
const deps = createDeps({
events,
projects: firstDeps.projects,
workspaces: firstDeps.workspaces,
});
test("creates a worktree and registers it in the source workspace project without git snapshot lookup", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const events: string[] = [];
const deps = createDeps({ events });
const sourceProject = createPersistedProjectRecordForTest({
projectId: "remote:github.com/acme/repo",
rootPath: repoDir,
displayName: "acme/repo",
});
const sourceWorkspace = createPersistedWorkspaceRecordForTest({
workspaceId: repoDir,
projectId: sourceProject.projectId,
const second = await createPaseoWorktree(
{
cwd: repoDir,
kind: "local_checkout",
displayName: "main",
});
deps.projects.set(sourceProject.projectId, sourceProject);
deps.workspaces.set(sourceWorkspace.workspaceId, sourceWorkspace);
deps.workspaceGitService.getSnapshot = vi.fn(deps.workspaceGitService.getSnapshot);
worktreeSlug: "reuse-me",
runSetup: false,
paseoHome,
},
deps,
);
const result = await createPaseoWorktree(
expect(second.created).toBe(false);
expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath);
expect(events).toContain(`workspace:${second.workspace.workspaceId}`);
});
test("does not mutate registries or broadcast when core worktree creation fails", async () => {
const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-worktree-service-"));
cleanupPaths.push(tempDir);
const deps = createDeps();
await expect(
createPaseoWorktree(
{
cwd: repoDir,
worktreeSlug: "feature-one",
cwd: tempDir,
worktreeSlug: "not-git",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
deps,
);
),
).rejects.toThrow("Create worktree requires a git repository");
expect(result.created).toBe(true);
expect(result.workspace.cwd).toBe(result.worktree.worktreePath);
expect(result.workspace.kind).toBe("worktree");
expect(result.workspace.projectId).toBe("remote:github.com/acme/repo");
expect(result.workspace.displayName).toBe("feature-one");
expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(events).toEqual([
"project:remote:github.com/acme/repo",
`workspace:${result.workspace.workspaceId}`,
]);
expect(deps.projects.size).toBe(0);
expect(deps.workspaces.size).toBe(0);
});
test("keeps direct core worktree creation calls behind the service boundary", () => {
// Keep this literal in the test file so the grep invariant sees createWorktreeCore( here.
const serverSrc = path.dirname(fileURLToPath(import.meta.url));
const matches = listTypeScriptFiles(serverSrc).flatMap((filePath) => {
if (path.basename(filePath) === "worktree-core.ts") {
return [];
}
const contents = readFileSync(filePath, "utf8");
const pattern = new RegExp(["createWorktreeCore", "\\("].join(""), "g");
return Array.from(contents.matchAll(pattern), () => path.relative(serverSrc, filePath));
});
test("reuses an existing worktree and still upserts the workspace", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const firstDeps = createDeps();
const first = await createPaseoWorktree(
{
cwd: repoDir,
worktreeSlug: "reuse-me",
runSetup: false,
paseoHome,
},
firstDeps,
);
const events: string[] = [];
const deps = createDeps({
events,
projects: firstDeps.projects,
workspaces: firstDeps.workspaces,
});
const second = await createPaseoWorktree(
{
cwd: repoDir,
worktreeSlug: "reuse-me",
runSetup: false,
paseoHome,
},
deps,
);
expect(second.created).toBe(false);
expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath);
expect(events).toContain(`workspace:${second.workspace.workspaceId}`);
});
test("does not mutate registries or broadcast when core worktree creation fails", async () => {
const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-worktree-service-"));
cleanupPaths.push(tempDir);
const deps = createDeps();
await expect(
createPaseoWorktree(
{
cwd: tempDir,
worktreeSlug: "not-git",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
deps,
),
).rejects.toThrow("Create worktree requires a git repository");
expect(deps.projects.size).toBe(0);
expect(deps.workspaces.size).toBe(0);
});
test("keeps direct core worktree creation calls behind the service boundary", () => {
// Keep this literal in the test file so the grep invariant sees createWorktreeCore( here.
const serverSrc = path.dirname(fileURLToPath(import.meta.url));
const matches = listTypeScriptFiles(serverSrc).flatMap((filePath) => {
if (path.basename(filePath) === "worktree-core.ts") {
return [];
}
const contents = readFileSync(filePath, "utf8");
const pattern = new RegExp(["createWorktreeCore", "\\("].join(""), "g");
return Array.from(contents.matchAll(pattern), () => path.relative(serverSrc, filePath));
});
expect(matches).toEqual(["paseo-worktree-service.test.ts", "paseo-worktree-service.ts"]);
});
expect(matches).toEqual(["paseo-worktree-service.test.ts", "paseo-worktree-service.ts"]);
});
interface TestDeps extends CreatePaseoWorktreeDeps {
+375 -383
View File
@@ -27,466 +27,458 @@ function closeServer(server: http.Server): Promise<void> {
// ScriptRouteStore
// ---------------------------------------------------------------------------
describe("ScriptRouteStore", () => {
it("registerRoute and findRoute with exact match", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "route-a.example.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "editor",
});
const route = store.findRoute("route-a.example.localhost");
expect(route).toEqual({ hostname: "route-a.example.localhost", port: 3000 });
it("registerRoute and findRoute with exact match", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "route-a.example.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "editor",
});
it("findRoute strips port from host header", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "route-a.example.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "editor",
});
const route = store.findRoute("route-a.example.localhost");
expect(route).toEqual({ hostname: "route-a.example.localhost", port: 3000 });
});
const route = store.findRoute("route-a.example.localhost:6767");
expect(route).toEqual({ hostname: "route-a.example.localhost", port: 3000 });
it("findRoute strips port from host header", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "route-a.example.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "editor",
});
it("findRoute subdomain match", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "editor.example.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "editor",
});
const route = store.findRoute("route-a.example.localhost:6767");
expect(route).toEqual({ hostname: "route-a.example.localhost", port: 3000 });
});
const route = store.findRoute("tenant.editor.example.localhost");
expect(route).toEqual({ hostname: "editor.example.localhost", port: 3000 });
it("findRoute subdomain match", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "editor.example.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "editor",
});
it("listRoutes returns enriched entries", () => {
const store = new ScriptRouteStore();
store.registerRoute({
const route = store.findRoute("tenant.editor.example.localhost");
expect(route).toEqual({ hostname: "editor.example.localhost", port: 3000 });
});
it("listRoutes returns enriched entries", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "web",
});
store.registerRoute({
hostname: "b.localhost",
port: 4000,
workspaceId: "/repo/.paseo/worktrees/feature-b",
projectSlug: "repo",
scriptName: "docs",
});
const routes = store.listRoutes();
expect(routes).toHaveLength(2);
expect(routes).toContainEqual({
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "web",
});
expect(routes).toContainEqual({
hostname: "b.localhost",
port: 4000,
workspaceId: "/repo/.paseo/worktrees/feature-b",
projectSlug: "repo",
scriptName: "docs",
});
});
it("listRoutesForWorkspace returns only routes for that workspace", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "web",
});
store.registerRoute({
hostname: "b.localhost",
port: 4000,
workspaceId: "/repo/.paseo/worktrees/feature-b",
projectSlug: "repo",
scriptName: "docs",
});
store.registerRoute({
hostname: "c.localhost",
port: 5000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "api",
});
expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([
{
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "web",
});
store.registerRoute({
hostname: "b.localhost",
port: 4000,
workspaceId: "/repo/.paseo/worktrees/feature-b",
projectSlug: "repo",
scriptName: "docs",
});
const routes = store.listRoutes();
expect(routes).toHaveLength(2);
expect(routes).toContainEqual({
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "web",
});
expect(routes).toContainEqual({
hostname: "b.localhost",
port: 4000,
workspaceId: "/repo/.paseo/worktrees/feature-b",
projectSlug: "repo",
scriptName: "docs",
});
});
it("listRoutesForWorkspace returns only routes for that workspace", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "web",
});
store.registerRoute({
hostname: "b.localhost",
port: 4000,
workspaceId: "/repo/.paseo/worktrees/feature-b",
projectSlug: "repo",
scriptName: "docs",
});
store.registerRoute({
},
{
hostname: "c.localhost",
port: 5000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "api",
});
},
]);
});
expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([
{
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "web",
},
{
hostname: "c.localhost",
port: 5000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "api",
},
]);
it("removeRoute works", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "route-a.example.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "editor",
});
store.removeRoute("route-a.example.localhost");
expect(store.findRoute("route-a.example.localhost")).toBeNull();
});
it("removeRoute cleans up workspace index", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "route-a.example.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "editor",
});
it("removeRoute works", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "route-a.example.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "editor",
});
store.removeRoute("route-a.example.localhost");
store.removeRoute("route-a.example.localhost");
expect(store.findRoute("route-a.example.localhost")).toBeNull();
expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([]);
});
it("removeRoutesForPort works", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "web",
});
store.registerRoute({
hostname: "b.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "api",
});
store.registerRoute({
hostname: "c.localhost",
port: 4000,
workspaceId: "/repo/.paseo/worktrees/feature-b",
projectSlug: "repo",
scriptName: "docs",
});
it("removeRoute cleans up workspace index", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "route-a.example.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "editor",
});
store.removeRoutesForPort(3000);
store.removeRoute("route-a.example.localhost");
expect(store.findRoute("a.localhost")).toBeNull();
expect(store.findRoute("b.localhost")).toBeNull();
expect(store.findRoute("c.localhost")).toEqual({
hostname: "c.localhost",
port: 4000,
});
});
expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([]);
it("removeRoutesForPort cleans up workspace index", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "web",
});
store.registerRoute({
hostname: "b.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "api",
});
it("removeRoutesForPort works", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "web",
});
store.registerRoute({
hostname: "b.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "api",
});
store.registerRoute({
hostname: "c.localhost",
port: 4000,
workspaceId: "/repo/.paseo/worktrees/feature-b",
projectSlug: "repo",
scriptName: "docs",
});
store.removeRoutesForPort(3000);
store.removeRoutesForPort(3000);
expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([]);
});
expect(store.findRoute("a.localhost")).toBeNull();
expect(store.findRoute("b.localhost")).toBeNull();
expect(store.findRoute("c.localhost")).toEqual({
hostname: "c.localhost",
port: 4000,
});
it("findRoute returns null for unknown hosts", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "route-a.example.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "editor",
});
it("removeRoutesForPort cleans up workspace index", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "web",
});
store.registerRoute({
hostname: "b.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "api",
});
store.removeRoutesForPort(3000);
expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([]);
});
it("findRoute returns null for unknown hosts", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "route-a.example.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "editor",
});
expect(store.findRoute("unknown.example.com")).toBeNull();
});
expect(store.findRoute("unknown.example.com")).toBeNull();
});
// ---------------------------------------------------------------------------
// HTTP proxy
// ---------------------------------------------------------------------------
describe("HTTP proxy", () => {
const servers: http.Server[] = [];
const servers: http.Server[] = [];
afterEach(async () => {
await Promise.all(servers.map(closeServer));
servers.length = 0;
afterEach(async () => {
await Promise.all(servers.map(closeServer));
servers.length = 0;
});
/** Start a real HTTP server that echoes back a known body and records received headers. */
async function startUpstream(): Promise<{
port: number;
server: http.Server;
receivedHeaders: () => http.IncomingHttpHeaders;
}> {
const port = await findFreePort();
let lastHeaders: http.IncomingHttpHeaders = {};
const server = http.createServer((req, res) => {
lastHeaders = req.headers;
res.writeHead(200, { "content-type": "text/plain" });
res.end("upstream-ok");
});
/** Start a real HTTP server that echoes back a known body and records received headers. */
async function startUpstream(): Promise<{
port: number;
server: http.Server;
receivedHeaders: () => http.IncomingHttpHeaders;
}> {
const port = await findFreePort();
let lastHeaders: http.IncomingHttpHeaders = {};
await new Promise<void>((resolve) => server.listen(port, "127.0.0.1", resolve));
servers.push(server);
const server = http.createServer((req, res) => {
lastHeaders = req.headers;
res.writeHead(200, { "content-type": "text/plain" });
res.end("upstream-ok");
});
return {
port,
server,
receivedHeaders: () => lastHeaders,
};
}
await new Promise<void>((resolve) => server.listen(port, "127.0.0.1", resolve));
servers.push(server);
/** Start an Express app with the service proxy middleware and an optional fallback. */
async function startProxy(
routeStore: ScriptRouteStore,
opts?: { fallback?: boolean },
): Promise<{ port: number; server: http.Server }> {
const port = await findFreePort();
const app = express();
app.use(createScriptProxyMiddleware({ routeStore, logger }));
return {
port,
server,
receivedHeaders: () => lastHeaders,
};
}
/** Start an Express app with the service proxy middleware and an optional fallback. */
async function startProxy(
routeStore: ScriptRouteStore,
opts?: { fallback?: boolean },
): Promise<{ port: number; server: http.Server }> {
const port = await findFreePort();
const app = express();
app.use(createScriptProxyMiddleware({ routeStore, logger }));
if (opts?.fallback) {
app.use((_req, res) => {
res.status(404).send("no route");
});
}
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(port, "127.0.0.1", resolve));
servers.push(server);
return { port, server };
}
/** Simple HTTP GET helper that returns status code and body. */
function httpGet(
port: number,
host: string,
path = "/",
): Promise<{ status: number; body: string }> {
return new Promise((resolve, reject) => {
const req = http.get({ hostname: "127.0.0.1", port, path, headers: { host } }, (res) => {
let body = "";
res.on("data", (chunk: Buffer) => (body += chunk.toString()));
res.on("end", () => resolve({ status: res.statusCode ?? 0, body }));
});
req.on("error", reject);
if (opts?.fallback) {
app.use((_req, res) => {
res.status(404).send("no route");
});
}
it("proxies requests to the correct upstream based on Host header", async () => {
const upstream = await startUpstream();
const routeStore = new ScriptRouteStore();
routeStore.registerRoute({
hostname: "test-service.localhost",
port: upstream.port,
workspaceId: "workspace-test",
projectSlug: "test",
scriptName: "service",
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(port, "127.0.0.1", resolve));
servers.push(server);
return { port, server };
}
/** Simple HTTP GET helper that returns status code and body. */
function httpGet(
port: number,
host: string,
path = "/",
): Promise<{ status: number; body: string }> {
return new Promise((resolve, reject) => {
const req = http.get({ hostname: "127.0.0.1", port, path, headers: { host } }, (res) => {
let body = "";
res.on("data", (chunk: Buffer) => (body += chunk.toString()));
res.on("end", () => resolve({ status: res.statusCode ?? 0, body }));
});
req.on("error", reject);
});
}
const proxy = await startProxy(routeStore);
const res = await httpGet(proxy.port, `test-service.localhost:${proxy.port}`);
expect(res.status).toBe(200);
expect(res.body).toBe("upstream-ok");
const headers = upstream.receivedHeaders();
expect(headers["x-forwarded-for"]).toBeDefined();
expect(headers["x-forwarded-host"]).toBe("test-service.localhost");
it("proxies requests to the correct upstream based on Host header", async () => {
const upstream = await startUpstream();
const routeStore = new ScriptRouteStore();
routeStore.registerRoute({
hostname: "test-service.localhost",
port: upstream.port,
workspaceId: "workspace-test",
projectSlug: "test",
scriptName: "service",
});
it("falls through when no route matches", async () => {
const routeStore = new ScriptRouteStore();
const proxy = await startProxy(routeStore, { fallback: true });
const proxy = await startProxy(routeStore);
const res = await httpGet(proxy.port, `test-service.localhost:${proxy.port}`);
const res = await httpGet(proxy.port, `unknown.localhost:${proxy.port}`);
expect(res.status).toBe(200);
expect(res.body).toBe("upstream-ok");
expect(res.status).toBe(404);
expect(res.body).toBe("no route");
const headers = upstream.receivedHeaders();
expect(headers["x-forwarded-for"]).toBeDefined();
expect(headers["x-forwarded-host"]).toBe("test-service.localhost");
});
it("falls through when no route matches", async () => {
const routeStore = new ScriptRouteStore();
const proxy = await startProxy(routeStore, { fallback: true });
const res = await httpGet(proxy.port, `unknown.localhost:${proxy.port}`);
expect(res.status).toBe(404);
expect(res.body).toBe("no route");
});
it("returns 502 when upstream is down", async () => {
// Get a port that nothing is listening on
const deadPort = await findFreePort();
const routeStore = new ScriptRouteStore();
routeStore.registerRoute({
hostname: "dead-service.localhost",
port: deadPort,
workspaceId: "workspace-dead",
projectSlug: "dead",
scriptName: "service",
});
it("returns 502 when upstream is down", async () => {
// Get a port that nothing is listening on
const deadPort = await findFreePort();
const proxy = await startProxy(routeStore);
const res = await httpGet(proxy.port, `dead-service.localhost:${proxy.port}`);
const routeStore = new ScriptRouteStore();
routeStore.registerRoute({
hostname: "dead-service.localhost",
port: deadPort,
workspaceId: "workspace-dead",
projectSlug: "dead",
scriptName: "service",
});
const proxy = await startProxy(routeStore);
const res = await httpGet(proxy.port, `dead-service.localhost:${proxy.port}`);
expect(res.status).toBe(502);
expect(res.body).toBe("502 Bad Gateway");
});
expect(res.status).toBe(502);
expect(res.body).toBe("502 Bad Gateway");
});
// ---------------------------------------------------------------------------
// WebSocket proxy
// ---------------------------------------------------------------------------
describe("WebSocket proxy", () => {
const httpServers: http.Server[] = [];
const wsServers: WebSocketServer[] = [];
const wsClients: WebSocket[] = [];
const httpServers: http.Server[] = [];
const wsServers: WebSocketServer[] = [];
const wsClients: WebSocket[] = [];
afterEach(async () => {
for (const ws of wsClients) {
if (ws.readyState === WebSocket.OPEN) ws.close();
}
wsClients.length = 0;
afterEach(async () => {
for (const ws of wsClients) {
if (ws.readyState === WebSocket.OPEN) ws.close();
}
wsClients.length = 0;
for (const wss of wsServers) {
wss.close();
}
wsServers.length = 0;
for (const wss of wsServers) {
wss.close();
}
wsServers.length = 0;
await Promise.all(httpServers.map(closeServer));
httpServers.length = 0;
await Promise.all(httpServers.map(closeServer));
httpServers.length = 0;
});
it("proxies WebSocket connections to the correct upstream", async () => {
// 1. Start a real WebSocket echo server
const upstreamPort = await findFreePort();
const upstreamServer = http.createServer();
const wss = new WebSocketServer({ server: upstreamServer });
wsServers.push(wss);
wss.on("connection", (ws) => {
ws.on("message", (data) => {
ws.send(`echo: ${data.toString()}`);
});
});
it("proxies WebSocket connections to the correct upstream", async () => {
// 1. Start a real WebSocket echo server
const upstreamPort = await findFreePort();
const upstreamServer = http.createServer();
const wss = new WebSocketServer({ server: upstreamServer });
wsServers.push(wss);
await new Promise<void>((resolve) => upstreamServer.listen(upstreamPort, "127.0.0.1", resolve));
httpServers.push(upstreamServer);
wss.on("connection", (ws) => {
ws.on("message", (data) => {
ws.send(`echo: ${data.toString()}`);
});
});
await new Promise<void>((resolve) => upstreamServer.listen(upstreamPort, "127.0.0.1", resolve));
httpServers.push(upstreamServer);
// 2. Create the proxy server with the upgrade handler
const routeStore = new ScriptRouteStore();
routeStore.registerRoute({
hostname: "ws-service.localhost",
port: upstreamPort,
workspaceId: "workspace-ws",
projectSlug: "ws",
scriptName: "service",
});
const proxyPort = await findFreePort();
const proxyServer = http.createServer((_req, res) => {
res.writeHead(404);
res.end();
});
const upgradeHandler = createScriptProxyUpgradeHandler({
routeStore,
logger,
});
proxyServer.on("upgrade", upgradeHandler);
await new Promise<void>((resolve) => proxyServer.listen(proxyPort, "127.0.0.1", resolve));
httpServers.push(proxyServer);
// 3. Connect a WebSocket client through the proxy
const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}`, {
headers: { host: `ws-service.localhost:${proxyPort}` },
});
wsClients.push(ws);
await new Promise<void>((resolve, reject) => {
ws.on("open", resolve);
ws.on("error", reject);
});
// 4. Send a message and verify echo
const reply = await new Promise<string>((resolve, reject) => {
ws.on("message", (data) => resolve(data.toString()));
ws.on("error", reject);
ws.send("hello proxy");
});
expect(reply).toBe("echo: hello proxy");
// 2. Create the proxy server with the upgrade handler
const routeStore = new ScriptRouteStore();
routeStore.registerRoute({
hostname: "ws-service.localhost",
port: upstreamPort,
workspaceId: "workspace-ws",
projectSlug: "ws",
scriptName: "service",
});
const proxyPort = await findFreePort();
const proxyServer = http.createServer((_req, res) => {
res.writeHead(404);
res.end();
});
const upgradeHandler = createScriptProxyUpgradeHandler({
routeStore,
logger,
});
proxyServer.on("upgrade", upgradeHandler);
await new Promise<void>((resolve) => proxyServer.listen(proxyPort, "127.0.0.1", resolve));
httpServers.push(proxyServer);
// 3. Connect a WebSocket client through the proxy
const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}`, {
headers: { host: `ws-service.localhost:${proxyPort}` },
});
wsClients.push(ws);
await new Promise<void>((resolve, reject) => {
ws.on("open", resolve);
ws.on("error", reject);
});
// 4. Send a message and verify echo
const reply = await new Promise<string>((resolve, reject) => {
ws.on("message", (data) => resolve(data.toString()));
ws.on("error", reject);
ws.send("hello proxy");
});
expect(reply).toBe("echo: hello proxy");
});
// ---------------------------------------------------------------------------
// findFreePort
// ---------------------------------------------------------------------------
describe("findFreePort", () => {
it("returns a number", async () => {
const port = await findFreePort();
expect(typeof port).toBe("number");
expect(port).toBeGreaterThan(0);
expect(port).toBeLessThan(65536);
});
it("returns a number", async () => {
const port = await findFreePort();
expect(typeof port).toBe("number");
expect(port).toBeGreaterThan(0);
expect(port).toBeLessThan(65536);
});
it("returns a port that is actually available", async () => {
const port = await findFreePort();
it("returns a port that is actually available", async () => {
const port = await findFreePort();
// Verify we can bind a server to it
const server = net.createServer();
await new Promise<void>((resolve, reject) => {
server.listen(port, "127.0.0.1", () => resolve());
server.on("error", reject);
});
// Verify we can bind a server to it
const server = net.createServer();
await new Promise<void>((resolve, reject) => {
server.listen(port, "127.0.0.1", () => resolve());
server.on("error", reject);
});
const addr = server.address();
expect(addr).not.toBeNull();
expect(typeof addr === "object" && addr !== null ? addr.port : -1).toBe(port);
const addr = server.address();
expect(addr).not.toBeNull();
expect(typeof addr === "object" && addr !== null ? addr.port : -1).toBe(port);
await new Promise<void>((resolve) => server.close(() => resolve()));
});
await new Promise<void>((resolve) => server.close(() => resolve()));
});
File diff suppressed because it is too large Load Diff
@@ -96,207 +96,205 @@ function toAudioPcmFormat(format: string): string {
return `audio/pcm;${trimmed}`;
}
describe("speech models (download E2E)", () => {
downloadTest(
"downloads models and runs streaming STT + streaming TTS",
async () => {
const logger = pino({ level: "silent" });
const set = getModelSet();
downloadTest(
"downloads models and runs streaming STT + streaming TTS",
async () => {
const logger = pino({ level: "silent" });
const set = getModelSet();
const paseoHomeRoot = mkdtempSync(path.join(tmpdir(), "paseo-speech-download-"));
const modelsDir = path.join(paseoHomeRoot, ".paseo", "models", "local-speech");
const paseoHomeRoot = mkdtempSync(path.join(tmpdir(), "paseo-speech-download-"));
const modelsDir = path.join(paseoHomeRoot, ".paseo", "models", "local-speech");
const modelIds: SherpaOnnxModelId[] =
set === "parakeet-pocket"
? ["parakeet-tdt-0.6b-v3-int8", "pocket-tts-onnx-int8"]
: ["zipformer-bilingual-zh-en-2023-02-20", "kitten-nano-en-v0_1-fp16"];
const modelIds: SherpaOnnxModelId[] =
set === "parakeet-pocket"
? ["parakeet-tdt-0.6b-v3-int8", "pocket-tts-onnx-int8"]
: ["zipformer-bilingual-zh-en-2023-02-20", "kitten-nano-en-v0_1-fp16"];
await ensureSherpaOnnxModels({
modelsDir,
modelIds,
logger,
});
await ensureSherpaOnnxModels({
modelsDir,
modelIds,
logger,
});
const ctx = await createDaemonTestContext({
paseoHomeRoot,
dictationFinalTimeoutMs: 8000,
speech: {
providers: {
dictationStt: { provider: "local", explicit: true },
voiceStt: { provider: "local", explicit: true },
voiceTts: { provider: "local", explicit: true },
},
local: {
modelsDir,
models: {
dictationStt:
set === "parakeet-pocket"
? "parakeet-tdt-0.6b-v3-int8"
: "zipformer-bilingual-zh-en-2023-02-20",
voiceStt:
set === "parakeet-pocket"
? "parakeet-tdt-0.6b-v3-int8"
: "zipformer-bilingual-zh-en-2023-02-20",
voiceTts:
set === "parakeet-pocket" ? "pocket-tts-onnx-int8" : "kitten-nano-en-v0_1-fp16",
},
const ctx = await createDaemonTestContext({
paseoHomeRoot,
dictationFinalTimeoutMs: 8000,
speech: {
providers: {
dictationStt: { provider: "local", explicit: true },
voiceStt: { provider: "local", explicit: true },
voiceTts: { provider: "local", explicit: true },
},
local: {
modelsDir,
models: {
dictationStt:
set === "parakeet-pocket"
? "parakeet-tdt-0.6b-v3-int8"
: "zipformer-bilingual-zh-en-2023-02-20",
voiceStt:
set === "parakeet-pocket"
? "parakeet-tdt-0.6b-v3-int8"
: "zipformer-bilingual-zh-en-2023-02-20",
voiceTts:
set === "parakeet-pocket" ? "pocket-tts-onnx-int8" : "kitten-nano-en-v0_1-fp16",
},
},
},
});
try {
const wav = await readFixtureWav();
const { sampleRate, pcm16 } = parsePcm16MonoWav(wav);
expect(sampleRate).toBe(16000);
const format = "audio/pcm;rate=16000;bits=16";
// Streaming STT: dictation path (verifies websocket streaming + partials + final)
const dictationId = `dict-download-${Date.now()}`;
let partialCount = 0;
const unsubscribe = ctx.client.on("dictation_stream_partial", (message) => {
if (message.type !== "dictation_stream_partial") return;
if (message.payload.dictationId !== dictationId) return;
partialCount += 1;
});
try {
const wav = await readFixtureWav();
const { sampleRate, pcm16 } = parsePcm16MonoWav(wav);
expect(sampleRate).toBe(16000);
const format = "audio/pcm;rate=16000;bits=16";
// Streaming STT: dictation path (verifies websocket streaming + partials + final)
const dictationId = `dict-download-${Date.now()}`;
let partialCount = 0;
const unsubscribe = ctx.client.on("dictation_stream_partial", (message) => {
if (message.type !== "dictation_stream_partial") return;
if (message.payload.dictationId !== dictationId) return;
partialCount += 1;
});
await ctx.client.startDictationStream(dictationId, format);
const chunkBytes = 3200; // 100ms @ 16kHz mono PCM16
let seq = 0;
for (let offset = 0; offset < pcm16.length; offset += chunkBytes) {
const chunk = pcm16.subarray(offset, Math.min(pcm16.length, offset + chunkBytes));
ctx.client.sendDictationStreamChunk(dictationId, seq, chunk.toString("base64"), format);
seq += 1;
}
const finalSeq = seq - 1;
const dictationFinal = await ctx.client.finishDictationStream(dictationId, finalSeq);
unsubscribe();
expect(dictationFinal.text.toLowerCase()).toContain("voice note");
const baseline = await readBaseline();
expect(wordSimilarity(dictationFinal.text, baseline)).toBeGreaterThan(0.45);
expect(partialCount).toBeGreaterThan(0);
// Voice-mode STT: chunked upload until isLast=true
const transcriptionPromise = waitForSignal<string>(30000, (resolve, reject) => {
const offResult = ctx.client.on("transcription_result", (message) => {
if (message.type !== "transcription_result") return;
resolve(message.payload.text);
});
const offError = ctx.client.on("activity_log", (message) => {
if (message.type !== "activity_log") return;
const payload = message.payload as { type?: unknown; content?: unknown };
if (payload.type !== "error") return;
const content = typeof payload.content === "string" ? payload.content : null;
if (!content) return;
reject(new Error(content));
});
return () => {
offResult();
offError();
};
});
const voiceCwd = mkdtempSync(path.join(tmpdir(), "speech-download-voice-agent-"));
const voiceAgent = await ctx.client.createAgent({
config: {
provider: "codex",
cwd: voiceCwd,
modeId: "full-access",
model: "gpt-5.4-mini",
thinkingOptionId: "low",
},
});
await ctx.client.setVoiceMode(true, voiceAgent.id);
for (let offset = 0; offset < pcm16.length; offset += chunkBytes) {
const chunk = pcm16.subarray(offset, Math.min(pcm16.length, offset + chunkBytes));
const isLast = offset + chunkBytes >= pcm16.length;
await ctx.client.sendVoiceAudioChunk(chunk.toString("base64"), format, isLast);
}
const voiceText = (await transcriptionPromise).trim().toLowerCase();
if (voiceText.length > 0) {
expect(voiceText).toContain("voice note");
}
await ctx.client.setVoiceMode(false);
rmSync(voiceCwd, { recursive: true, force: true });
// Streaming TTS: generate locally from downloaded model and validate chunking.
const ttsText = "This is a voice note.";
if (set === "parakeet-pocket") {
const modelDir = getSherpaOnnxModelDir(modelsDir, "pocket-tts-onnx-int8");
const tts = await PocketTtsOnnxTTS.create(
{ modelDir, precision: "int8", targetChunkMs: 50 },
logger,
);
const { stream, format: ttsFormat } = await tts.synthesizeSpeech(ttsText);
const { chunks, combined } = await readAllChunks(stream);
expect(ttsFormat).toMatch(/rate=\d+/);
expect(chunks.length).toBeGreaterThan(3);
expect(combined.byteLength).toBeGreaterThan(2000);
// Round trip: TTS -> STT (offline parakeet)
const sttModelDir = getSherpaOnnxModelDir(modelsDir, "parakeet-tdt-0.6b-v3-int8");
const engine = new SherpaOfflineRecognizerEngine(
{
model: {
kind: "nemo_transducer",
encoder: `${sttModelDir}/encoder.int8.onnx`,
decoder: `${sttModelDir}/decoder.int8.onnx`,
joiner: `${sttModelDir}/joiner.int8.onnx`,
tokens: `${sttModelDir}/tokens.txt`,
},
numThreads: 2,
debug: 0,
},
logger,
);
const stt = new SherpaOnnxParakeetSTT({ engine }, logger);
const rt = await stt.transcribeAudio(combined, toAudioPcmFormat(ttsFormat));
engine.free();
expect(wordSimilarity(rt.text, ttsText)).toBeGreaterThan(0.25);
} else {
const ttsModelDir = path.join(modelsDir, "kitten-nano-en-v0_1-fp16");
const tts = new SherpaOnnxTTS(
{ preset: "kitten-nano-en-v0_1-fp16", modelDir: ttsModelDir },
logger,
);
const { stream, format: ttsFormat } = await tts.synthesizeSpeech(ttsText);
const { chunks, combined } = await readAllChunks(stream);
tts.free();
expect(ttsFormat).toMatch(/rate=\d+/);
expect(chunks.length).toBeGreaterThan(3);
expect(combined.byteLength).toBeGreaterThan(2000);
// Round trip: TTS -> STT (online zipformer, offline segment)
const sttModelDir = path.join(
modelsDir,
"sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20",
);
const engine = new SherpaOnlineRecognizerEngine(
{
model: {
kind: "transducer",
encoder: `${sttModelDir}/encoder-epoch-99-avg-1.onnx`,
decoder: `${sttModelDir}/decoder-epoch-99-avg-1.onnx`,
joiner: `${sttModelDir}/joiner-epoch-99-avg-1.onnx`,
tokens: `${sttModelDir}/tokens.txt`,
modelType: "zipformer",
},
numThreads: 1,
debug: 0,
},
logger,
);
const stt = new SherpaOnnxSTT({ engine }, logger);
const rt = await stt.transcribeAudio(combined, toAudioPcmFormat(ttsFormat));
engine.free();
expect(wordSimilarity(rt.text, ttsText)).toBeGreaterThan(0.25);
}
} finally {
await ctx.cleanup();
await ctx.client.startDictationStream(dictationId, format);
const chunkBytes = 3200; // 100ms @ 16kHz mono PCM16
let seq = 0;
for (let offset = 0; offset < pcm16.length; offset += chunkBytes) {
const chunk = pcm16.subarray(offset, Math.min(pcm16.length, offset + chunkBytes));
ctx.client.sendDictationStreamChunk(dictationId, seq, chunk.toString("base64"), format);
seq += 1;
}
},
15 * 60_000,
);
});
const finalSeq = seq - 1;
const dictationFinal = await ctx.client.finishDictationStream(dictationId, finalSeq);
unsubscribe();
expect(dictationFinal.text.toLowerCase()).toContain("voice note");
const baseline = await readBaseline();
expect(wordSimilarity(dictationFinal.text, baseline)).toBeGreaterThan(0.45);
expect(partialCount).toBeGreaterThan(0);
// Voice-mode STT: chunked upload until isLast=true
const transcriptionPromise = waitForSignal<string>(30000, (resolve, reject) => {
const offResult = ctx.client.on("transcription_result", (message) => {
if (message.type !== "transcription_result") return;
resolve(message.payload.text);
});
const offError = ctx.client.on("activity_log", (message) => {
if (message.type !== "activity_log") return;
const payload = message.payload as { type?: unknown; content?: unknown };
if (payload.type !== "error") return;
const content = typeof payload.content === "string" ? payload.content : null;
if (!content) return;
reject(new Error(content));
});
return () => {
offResult();
offError();
};
});
const voiceCwd = mkdtempSync(path.join(tmpdir(), "speech-download-voice-agent-"));
const voiceAgent = await ctx.client.createAgent({
config: {
provider: "codex",
cwd: voiceCwd,
modeId: "full-access",
model: "gpt-5.4-mini",
thinkingOptionId: "low",
},
});
await ctx.client.setVoiceMode(true, voiceAgent.id);
for (let offset = 0; offset < pcm16.length; offset += chunkBytes) {
const chunk = pcm16.subarray(offset, Math.min(pcm16.length, offset + chunkBytes));
const isLast = offset + chunkBytes >= pcm16.length;
await ctx.client.sendVoiceAudioChunk(chunk.toString("base64"), format, isLast);
}
const voiceText = (await transcriptionPromise).trim().toLowerCase();
if (voiceText.length > 0) {
expect(voiceText).toContain("voice note");
}
await ctx.client.setVoiceMode(false);
rmSync(voiceCwd, { recursive: true, force: true });
// Streaming TTS: generate locally from downloaded model and validate chunking.
const ttsText = "This is a voice note.";
if (set === "parakeet-pocket") {
const modelDir = getSherpaOnnxModelDir(modelsDir, "pocket-tts-onnx-int8");
const tts = await PocketTtsOnnxTTS.create(
{ modelDir, precision: "int8", targetChunkMs: 50 },
logger,
);
const { stream, format: ttsFormat } = await tts.synthesizeSpeech(ttsText);
const { chunks, combined } = await readAllChunks(stream);
expect(ttsFormat).toMatch(/rate=\d+/);
expect(chunks.length).toBeGreaterThan(3);
expect(combined.byteLength).toBeGreaterThan(2000);
// Round trip: TTS -> STT (offline parakeet)
const sttModelDir = getSherpaOnnxModelDir(modelsDir, "parakeet-tdt-0.6b-v3-int8");
const engine = new SherpaOfflineRecognizerEngine(
{
model: {
kind: "nemo_transducer",
encoder: `${sttModelDir}/encoder.int8.onnx`,
decoder: `${sttModelDir}/decoder.int8.onnx`,
joiner: `${sttModelDir}/joiner.int8.onnx`,
tokens: `${sttModelDir}/tokens.txt`,
},
numThreads: 2,
debug: 0,
},
logger,
);
const stt = new SherpaOnnxParakeetSTT({ engine }, logger);
const rt = await stt.transcribeAudio(combined, toAudioPcmFormat(ttsFormat));
engine.free();
expect(wordSimilarity(rt.text, ttsText)).toBeGreaterThan(0.25);
} else {
const ttsModelDir = path.join(modelsDir, "kitten-nano-en-v0_1-fp16");
const tts = new SherpaOnnxTTS(
{ preset: "kitten-nano-en-v0_1-fp16", modelDir: ttsModelDir },
logger,
);
const { stream, format: ttsFormat } = await tts.synthesizeSpeech(ttsText);
const { chunks, combined } = await readAllChunks(stream);
tts.free();
expect(ttsFormat).toMatch(/rate=\d+/);
expect(chunks.length).toBeGreaterThan(3);
expect(combined.byteLength).toBeGreaterThan(2000);
// Round trip: TTS -> STT (online zipformer, offline segment)
const sttModelDir = path.join(
modelsDir,
"sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20",
);
const engine = new SherpaOnlineRecognizerEngine(
{
model: {
kind: "transducer",
encoder: `${sttModelDir}/encoder-epoch-99-avg-1.onnx`,
decoder: `${sttModelDir}/decoder-epoch-99-avg-1.onnx`,
joiner: `${sttModelDir}/joiner-epoch-99-avg-1.onnx`,
tokens: `${sttModelDir}/tokens.txt`,
modelType: "zipformer",
},
numThreads: 1,
debug: 0,
},
logger,
);
const stt = new SherpaOnnxSTT({ engine }, logger);
const rt = await stt.transcribeAudio(combined, toAudioPcmFormat(ttsFormat));
engine.free();
expect(wordSimilarity(rt.text, ttsText)).toBeGreaterThan(0.25);
}
} finally {
await ctx.cleanup();
}
},
15 * 60_000,
);
@@ -7,6 +7,41 @@ import pino from "pino";
import { createDaemonTestContext, type DaemonTestContext } from "./test-utils/index.js";
import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js";
import { OpenAITTS } from "./speech/providers/openai/tts.js";
import type { SessionOutboundMessage } from "../shared/messages.js";
type SessionMessage<T extends SessionOutboundMessage["type"]> = Extract<
SessionOutboundMessage,
{ type: T }
>;
function makeTranscriptionHandler(resolve: (value: string) => void) {
return (msg: SessionMessage<"transcription_result">) => {
if (msg.type !== "transcription_result") return;
const text = String(msg.payload.text ?? "").trim();
if (!text) return;
resolve(text);
};
}
function makeErrorHandler(reject: (error: Error) => void) {
return (msg: SessionMessage<"activity_log">) => {
if (msg.type !== "activity_log") return;
if (msg.payload.type !== "error") return;
reject(new Error(String(msg.payload.content)));
};
}
function makeSpeakToolHandler(resolve: (value: string) => void) {
return (msg: SessionMessage<"agent_stream">) => {
if (msg.type !== "agent_stream") return;
if (msg.payload.event.type !== "timeline") return;
const item = msg.payload.event.item;
if (item.type !== "tool_call") return;
const name = String(item.name ?? "");
if (!name.toLowerCase().includes("speak")) return;
resolve(name);
};
}
const openaiApiKey = process.env.OPENAI_API_KEY ?? null;
const shouldRun =
@@ -84,17 +119,11 @@ function waitForSignal<T>(
expect(voiceMode.accepted).toBe(true);
const transcriptionPromise = waitForSignal<string>(120000, (resolve, reject) => {
const offTranscript = ctx.client.on("transcription_result", (msg) => {
if (msg.type !== "transcription_result") return;
const text = String(msg.payload.text ?? "").trim();
if (!text) return;
resolve(text);
});
const offError = ctx.client.on("activity_log", (msg) => {
if (msg.type !== "activity_log") return;
if (msg.payload.type !== "error") return;
reject(new Error(String(msg.payload.content)));
});
const offTranscript = ctx.client.on(
"transcription_result",
makeTranscriptionHandler(resolve),
);
const offError = ctx.client.on("activity_log", makeErrorHandler(reject));
return () => {
offTranscript();
offError();
@@ -102,20 +131,8 @@ function waitForSignal<T>(
});
const speakToolPromise = waitForSignal<string>(120000, (resolve, reject) => {
const offStream = ctx.client.on("agent_stream", (msg) => {
if (msg.type !== "agent_stream") return;
if (msg.payload.event.type !== "timeline") return;
const item = msg.payload.event.item;
if (item.type !== "tool_call") return;
const name = String(item.name ?? "");
if (!name.toLowerCase().includes("speak")) return;
resolve(name);
});
const offError = ctx.client.on("activity_log", (msg) => {
if (msg.type !== "activity_log") return;
if (msg.payload.type !== "error") return;
reject(new Error(String(msg.payload.content)));
});
const offStream = ctx.client.on("agent_stream", makeSpeakToolHandler(resolve));
const offError = ctx.client.on("activity_log", makeErrorHandler(reject));
return () => {
offStream();
offError();
@@ -10,6 +10,82 @@ import { OpenAITTS } from "./speech/providers/openai/tts.js";
import { OpenAISTT } from "./speech/providers/openai/stt.js";
import { STTManager } from "./agent/stt-manager.js";
import { withTimeout } from "../utils/promise-timeout.js";
import type { SessionOutboundMessage } from "../shared/messages.js";
type SessionMessage<T extends SessionOutboundMessage["type"]> = Extract<
SessionOutboundMessage,
{ type: T }
>;
interface AudioOutputState {
targetGroupId: string | null;
chunks: Array<{ index: number; bytes: Buffer }>;
format: string;
}
function makeTranscriptionHandler(
resolve: (value: { text: string; isLowConfidence: boolean }) => void,
) {
return (message: SessionMessage<"transcription_result">) => {
if (message.type !== "transcription_result") {
return;
}
resolve({
text: String(message.payload.text ?? ""),
isLowConfidence: Boolean(message.payload.isLowConfidence),
});
};
}
function byIndex(a: { index: number }, b: { index: number }): number {
return a.index - b.index;
}
function chunkBytes(entry: { bytes: Buffer }): Buffer {
return entry.bytes;
}
function makeAudioOutputHandler(
state: AudioOutputState,
resolve: (value: { format: string; chunks: Buffer[] }) => void,
) {
return (message: SessionMessage<"audio_output">) => {
if (message.type !== "audio_output") {
return;
}
const payload = message.payload;
if (!state.targetGroupId) {
state.targetGroupId = payload.groupId;
state.format = payload.format;
}
if (payload.groupId !== state.targetGroupId) {
return;
}
state.chunks.push({
index: payload.chunkIndex,
bytes: Buffer.from(payload.audio, "base64"),
});
if (payload.isLastChunk) {
state.chunks.sort(byIndex);
resolve({
format: state.format,
chunks: state.chunks.map(chunkBytes),
});
}
};
}
function makeActivityErrorHandler(reject: (error: Error) => void) {
return (message: SessionMessage<"activity_log">) => {
if (message.type !== "activity_log") {
return;
}
if (message.payload.type !== "error") {
return;
}
reject(new Error(String(message.payload.content)));
};
}
const openaiApiKey = process.env.OPENAI_API_KEY ?? null;
const shouldRun = process.env.PASEO_VOICE_ROUNDTRIP_E2E === "1" && Boolean(openaiApiKey);
@@ -80,243 +156,202 @@ async function streamToBuffer(stream: Readable): Promise<Buffer> {
return Buffer.concat(chunks);
}
describe("voice roundtrip e2e", () => {
let ctx: DaemonTestContext;
let ctx: DaemonTestContext;
beforeAll(async () => {
ctx = await createDaemonTestContext({
agentClients: {},
openai: { apiKey: openaiApiKey! },
speech: {
providers: {
dictationStt: { provider: "openai", explicit: true },
voiceStt: { provider: "openai", explicit: true },
voiceTts: { provider: "openai", explicit: true },
beforeAll(async () => {
ctx = await createDaemonTestContext({
agentClients: {},
openai: { apiKey: openaiApiKey! },
speech: {
providers: {
dictationStt: { provider: "openai", explicit: true },
voiceStt: { provider: "openai", explicit: true },
voiceTts: { provider: "openai", explicit: true },
},
},
});
}, 60000);
afterAll(async () => {
await ctx.cleanup();
}, 60000);
for (const targetProvider of [
"claude",
"codex",
"opencode",
] as const satisfies VoiceRoundtripProvider[]) {
speechTest(
`full roundtrip (${targetProvider}): voice input audio -> voice agent -> output audio -> transcribed output`,
async () => {
const logger = pino({ level: "silent" });
const ttsProvider = new OpenAITTS(
{
apiKey: openaiApiKey!,
responseFormat: "pcm",
voice: "alloy",
},
},
});
}, 60000);
logger,
);
const sttProvider = new OpenAISTT(
{
apiKey: openaiApiKey!,
model: "gpt-4o-mini-transcribe",
},
logger,
);
const sttOutput = new STTManager("voice-roundtrip-e2e", logger, sttProvider);
afterAll(async () => {
await ctx.cleanup();
}, 60000);
for (const targetProvider of [
"claude",
"codex",
"opencode",
] as const satisfies VoiceRoundtripProvider[]) {
speechTest(
`full roundtrip (${targetProvider}): voice input audio -> voice agent -> output audio -> transcribed output`,
async () => {
const logger = pino({ level: "silent" });
const ttsProvider = new OpenAITTS(
{
apiKey: openaiApiKey!,
responseFormat: "pcm",
voice: "alloy",
const voiceCwd = mkdtempSync(path.join(tmpdir(), `voice-roundtrip-agent-${targetProvider}-`));
const voiceAgent = await withTimeout(
ctx.client.createAgent({
config: {
...getVoiceRoundtripConfig(targetProvider),
cwd: voiceCwd,
},
logger,
);
const sttProvider = new OpenAISTT(
{
apiKey: openaiApiKey!,
model: "gpt-4o-mini-transcribe",
},
logger,
);
const sttOutput = new STTManager("voice-roundtrip-e2e", logger, sttProvider);
}),
30000,
"Timed out during createVoiceTargetAgent after 30000ms",
);
const voiceAgentId = voiceAgent.id;
const voiceMode = await withTimeout(
ctx.client.setVoiceMode(true, voiceAgentId),
15000,
"Timed out during setVoiceMode after 15000ms",
);
expect(voiceMode.accepted).toBe(true);
expect(voiceMode.enabled).toBe(true);
const timelineTools: string[] = [];
const timelineToolAgentIds = new Set<string>();
const activityErrors: string[] = [];
const voiceCwd = mkdtempSync(
path.join(tmpdir(), `voice-roundtrip-agent-${targetProvider}-`),
);
const voiceAgent = await withTimeout(
ctx.client.createAgent({
config: {
...getVoiceRoundtripConfig(targetProvider),
cwd: voiceCwd,
},
}),
30000,
"Timed out during createVoiceTargetAgent after 30000ms",
);
const voiceAgentId = voiceAgent.id;
const voiceMode = await withTimeout(
ctx.client.setVoiceMode(true, voiceAgentId),
15000,
"Timed out during setVoiceMode after 15000ms",
);
expect(voiceMode.accepted).toBe(true);
expect(voiceMode.enabled).toBe(true);
const timelineTools: string[] = [];
const timelineToolAgentIds = new Set<string>();
const activityErrors: string[] = [];
const offStream = ctx.client.on("agent_stream", (message) => {
if (message.type !== "agent_stream") {
return;
}
if (message.payload.event.type !== "timeline") {
return;
}
const item = message.payload.event.item;
if (item.type !== "tool_call") {
return;
}
timelineToolAgentIds.add(message.payload.agentId);
timelineTools.push(String(item.name ?? ""));
});
const offErrors = ctx.client.on("activity_log", (message) => {
if (message.type !== "activity_log") {
return;
}
if (message.payload.type !== "error") {
return;
}
activityErrors.push(String(message.payload.content ?? ""));
});
const inputSpeech = await withTimeout(
ttsProvider.synthesizeSpeech("Use the speak tool and say exactly round trip successful."),
30000,
"Timed out during synthesizeInputAudio after 30000ms",
);
const inputPcm = await withTimeout(
streamToBuffer(inputSpeech.stream),
15000,
"Timed out during collectInputAudio after 15000ms",
);
const outputAudio = await (async () => {
try {
const transcriptPromise = waitForSignal<{ text: string; isLowConfidence: boolean }>(
30000,
(resolve) => {
const offTranscript = ctx.client.on("transcription_result", (message) => {
if (message.type !== "transcription_result") {
return;
}
resolve({
text: String(message.payload.text ?? ""),
isLowConfidence: Boolean(message.payload.isLowConfidence),
});
});
return () => {
offTranscript();
};
},
);
const outputAudioPromise = waitForSignal<{
format: string;
chunks: Buffer[];
}>(90000, (resolve, reject) => {
let targetGroupId: string | null = null;
const chunks: Array<{ index: number; bytes: Buffer }> = [];
let format = "pcm";
const offAudio = ctx.client.on("audio_output", (message) => {
if (message.type !== "audio_output") {
return;
}
const payload = message.payload;
if (!targetGroupId) {
targetGroupId = payload.groupId;
format = payload.format;
}
if (payload.groupId !== targetGroupId) {
return;
}
chunks.push({
index: payload.chunkIndex,
bytes: Buffer.from(payload.audio, "base64"),
});
if (payload.isLastChunk) {
chunks.sort((a, b) => a.index - b.index);
resolve({
format,
chunks: chunks.map((entry) => entry.bytes),
});
}
});
const offError = ctx.client.on("activity_log", (message) => {
if (message.type !== "activity_log") {
return;
}
if (message.payload.type !== "error") {
return;
}
reject(new Error(String(message.payload.content)));
});
return () => {
offAudio();
offError();
};
});
const format = "audio/pcm;rate=24000;bits=16";
const chunkBytes = 4800; // 100ms @ 24kHz mono PCM16
for (let offset = 0; offset < inputPcm.length; offset += chunkBytes) {
const chunk = inputPcm.subarray(
offset,
Math.min(inputPcm.length, offset + chunkBytes),
);
const isLast = offset + chunkBytes >= inputPcm.length;
await withTimeout(
ctx.client.sendVoiceAudioChunk(chunk.toString("base64"), format, isLast),
5000,
"Timed out during sendVoiceAudioChunk after 5000ms",
);
}
const transcript = await withTimeout(
transcriptPromise,
35000,
"Timed out during waitForTranscription after 35000ms",
);
if (transcript.text.trim().length === 0) {
throw new Error(`empty transcription (lowConfidence=${transcript.isLowConfidence})`);
}
return await withTimeout(
outputAudioPromise,
95000,
"Timed out during waitForAudioOutput after 95000ms",
);
} catch (error) {
throw new Error(
`${error instanceof Error ? error.message : String(error)} | requestedVoiceAgentId=${voiceAgentId} | timelineTools=${JSON.stringify(timelineTools)} | timelineToolAgentIds=${JSON.stringify(Array.from(timelineToolAgentIds))} | activityErrors=${JSON.stringify(activityErrors)}`,
{ cause: error },
);
} finally {
offStream();
offErrors();
await ctx.client.setVoiceMode(false).catch(() => undefined);
rmSync(voiceCwd, { recursive: true, force: true });
}
})();
const outputRaw = Buffer.concat(outputAudio.chunks);
let outputFormat: string;
if (outputAudio.format === "pcm") {
outputFormat = "audio/pcm;rate=24000;bits=16";
} else if (outputAudio.format.includes("wav")) {
outputFormat = "audio/wav";
} else {
outputFormat = `audio/${outputAudio.format}`;
const offStream = ctx.client.on("agent_stream", (message) => {
if (message.type !== "agent_stream") {
return;
}
const transcription = await withTimeout(
sttOutput.transcribe(outputRaw, outputFormat, {
label: "voice-roundtrip-output",
}),
60000,
"Timed out during transcribeOutputAudio after 60000ms",
);
const normalized = transcription.text.trim().toLowerCase();
if (message.payload.event.type !== "timeline") {
return;
}
const item = message.payload.event.item;
if (item.type !== "tool_call") {
return;
}
timelineToolAgentIds.add(message.payload.agentId);
timelineTools.push(String(item.name ?? ""));
});
const offErrors = ctx.client.on("activity_log", (message) => {
if (message.type !== "activity_log") {
return;
}
if (message.payload.type !== "error") {
return;
}
activityErrors.push(String(message.payload.content ?? ""));
});
expect(normalized.length).toBeGreaterThan(0);
expect(normalized).toMatch(/round|trip|successful/);
},
180000,
);
}
});
const inputSpeech = await withTimeout(
ttsProvider.synthesizeSpeech("Use the speak tool and say exactly round trip successful."),
30000,
"Timed out during synthesizeInputAudio after 30000ms",
);
const inputPcm = await withTimeout(
streamToBuffer(inputSpeech.stream),
15000,
"Timed out during collectInputAudio after 15000ms",
);
let outputAudio: { format: string; chunks: Buffer[] };
try {
const transcriptPromise = waitForSignal<{ text: string; isLowConfidence: boolean }>(
30000,
(resolve) => {
const offTranscript = ctx.client.on(
"transcription_result",
makeTranscriptionHandler(resolve),
);
return () => {
offTranscript();
};
},
);
const outputAudioPromise = waitForSignal<{
format: string;
chunks: Buffer[];
}>(90000, (resolve, reject) => {
const audioState: AudioOutputState = {
targetGroupId: null,
chunks: [],
format: "pcm",
};
const offAudio = ctx.client.on(
"audio_output",
makeAudioOutputHandler(audioState, resolve),
);
const offError = ctx.client.on("activity_log", makeActivityErrorHandler(reject));
return () => {
offAudio();
offError();
};
});
const format = "audio/pcm;rate=24000;bits=16";
const chunkBytes = 4800; // 100ms @ 24kHz mono PCM16
for (let offset = 0; offset < inputPcm.length; offset += chunkBytes) {
const chunk = inputPcm.subarray(offset, Math.min(inputPcm.length, offset + chunkBytes));
const isLast = offset + chunkBytes >= inputPcm.length;
await withTimeout(
ctx.client.sendVoiceAudioChunk(chunk.toString("base64"), format, isLast),
5000,
"Timed out during sendVoiceAudioChunk after 5000ms",
);
}
const transcript = await withTimeout(
transcriptPromise,
35000,
"Timed out during waitForTranscription after 35000ms",
);
if (transcript.text.trim().length === 0) {
throw new Error(`empty transcription (lowConfidence=${transcript.isLowConfidence})`);
}
outputAudio = await withTimeout(
outputAudioPromise,
95000,
"Timed out during waitForAudioOutput after 95000ms",
);
} catch (error) {
throw new Error(
`${error instanceof Error ? error.message : String(error)} | requestedVoiceAgentId=${voiceAgentId} | timelineTools=${JSON.stringify(timelineTools)} | timelineToolAgentIds=${JSON.stringify(Array.from(timelineToolAgentIds))} | activityErrors=${JSON.stringify(activityErrors)}`,
{ cause: error },
);
} finally {
offStream();
offErrors();
await ctx.client.setVoiceMode(false).catch(() => undefined);
rmSync(voiceCwd, { recursive: true, force: true });
}
const outputRaw = Buffer.concat(outputAudio.chunks);
let outputFormat: string;
if (outputAudio.format === "pcm") {
outputFormat = "audio/pcm;rate=24000;bits=16";
} else if (outputAudio.format.includes("wav")) {
outputFormat = "audio/wav";
} else {
outputFormat = `audio/${outputAudio.format}`;
}
const transcription = await withTimeout(
sttOutput.transcribe(outputRaw, outputFormat, {
label: "voice-roundtrip-output",
}),
60000,
"Timed out during transcribeOutputAudio after 60000ms",
);
const normalized = transcription.text.trim().toLowerCase();
expect(normalized.length).toBeGreaterThan(0);
expect(normalized).toMatch(/round|trip|successful/);
},
180000,
);
}
@@ -407,12 +407,10 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
const getCheckoutStatus = vi
.fn<(cwd: string) => Promise<CheckoutStatusGit>>()
.mockImplementationOnce(async (cwd: string) => createCheckoutStatus(cwd))
.mockImplementationOnce(async () =>
refreshStatus.promise.then((status) => ({
...status,
currentBranch: "feature",
})),
);
.mockImplementationOnce(async () => {
const status = await refreshStatus.promise;
return { ...status, currentBranch: "feature" };
});
const getCheckoutShortstat = vi.fn(async () => ({ additions: 4, deletions: 2 }));
const service = createService({
getCheckoutStatus,
File diff suppressed because it is too large Load Diff
@@ -33,348 +33,336 @@ async function withShell<T>(shell: string, run: () => Promise<T>): Promise<T> {
}
}
describe("TerminalManager", () => {
let manager: TerminalManager;
const temporaryDirs: string[] = [];
let manager: TerminalManager;
const temporaryDirs: string[] = [];
afterEach(() => {
if (manager) {
manager.killAll();
afterEach(() => {
if (manager) {
manager.killAll();
}
while (temporaryDirs.length > 0) {
const dir = temporaryDirs.pop();
if (dir) {
rmSync(dir, { recursive: true, force: true });
}
while (temporaryDirs.length > 0) {
const dir = temporaryDirs.pop();
if (dir) {
rmSync(dir, { recursive: true, force: true });
}
}
});
it("returns empty list for new cwd", async () => {
manager = createTerminalManager();
const terminals = await manager.getTerminals("/tmp");
expect(terminals).toHaveLength(0);
});
it("returns existing terminals on subsequent calls", async () => {
manager = createTerminalManager();
const created = await manager.createTerminal({ cwd: "/tmp" });
const first = await manager.getTerminals("/tmp");
const second = await manager.getTerminals("/tmp");
expect(first.length).toBe(1);
expect(first[0].id).toBe(created.id);
expect(second.length).toBe(1);
});
it("throws for relative paths", async () => {
manager = createTerminalManager();
await expect(manager.getTerminals("tmp")).rejects.toThrow("cwd must be absolute path");
});
it("accepts Windows absolute paths", async () => {
manager = createTerminalManager();
await expect(manager.getTerminals("C:\\Users\\foo\\project")).resolves.not.toThrow();
await expect(manager.getTerminals("D:\\MyProject")).resolves.not.toThrow();
});
it("creates separate terminals for different cwds", async () => {
manager = createTerminalManager();
const tmpTerminals = [await manager.createTerminal({ cwd: "/tmp" })];
const homeTerminals = [await manager.createTerminal({ cwd: "/home" })];
expect(tmpTerminals.length).toBe(1);
expect(homeTerminals.length).toBe(1);
expect(tmpTerminals[0].id).not.toBe(homeTerminals[0].id);
});
it("creates additional terminal with auto-incrementing name", async () => {
manager = createTerminalManager();
await manager.createTerminal({ cwd: "/tmp" });
const second = await manager.createTerminal({ cwd: "/tmp" });
expect(second.name).toBe("Terminal 2");
const terminals = await manager.getTerminals("/tmp");
expect(terminals.length).toBe(2);
});
it("uses custom name when provided", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: "/tmp", name: "Dev Server" });
expect(session.name).toBe("Dev Server");
});
it("creates first terminal if none exist", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: "/tmp" });
expect(session.name).toBe("Terminal 1");
const terminals = await manager.getTerminals("/tmp");
expect(terminals.length).toBe(1);
expect(terminals[0].id).toBe(session.id);
});
it("throws for relative paths", async () => {
manager = createTerminalManager();
await expect(manager.createTerminal({ cwd: "tmp" })).rejects.toThrow("cwd must be absolute path");
});
it("does not reject Windows absolute paths as relative", async () => {
manager = createTerminalManager();
// Should pass path validation (not throw "cwd must be absolute path").
// The terminal may or may not spawn successfully on non-Windows hosts,
// so we only assert the validation error is absent.
try {
await manager.createTerminal({ cwd: "C:\\Users\\foo\\project" });
} catch (error) {
expect((error as Error).message).not.toBe("cwd must be absolute path");
}
});
it("inherits registered env for the worktree root cwd", async () => {
await withShell("/bin/sh", async () => {
manager = createTerminalManager();
const cwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-root-"));
temporaryDirs.push(cwd);
const markerPath = join(cwd, "root-port.txt");
manager.registerCwdEnv({
cwd,
env: { PASEO_WORKTREE_PORT: "45678" },
});
const session = await manager.createTerminal({ cwd });
for (let attempt = 0; attempt < 10 && !existsSync(markerPath); attempt++) {
session.send({
type: "input",
data: `printf '%s' "$PASEO_WORKTREE_PORT" > ${JSON.stringify(markerPath)}\r`,
});
await new Promise((resolve) => setTimeout(resolve, 100));
}
});
describe("getTerminals", () => {
it("returns empty list for new cwd", async () => {
manager = createTerminalManager();
const terminals = await manager.getTerminals("/tmp");
expect(terminals).toHaveLength(0);
});
it("returns existing terminals on subsequent calls", async () => {
manager = createTerminalManager();
const created = await manager.createTerminal({ cwd: "/tmp" });
const first = await manager.getTerminals("/tmp");
const second = await manager.getTerminals("/tmp");
expect(first.length).toBe(1);
expect(first[0].id).toBe(created.id);
expect(second.length).toBe(1);
});
it("throws for relative paths", async () => {
manager = createTerminalManager();
await expect(manager.getTerminals("tmp")).rejects.toThrow("cwd must be absolute path");
});
it("accepts Windows absolute paths", async () => {
manager = createTerminalManager();
await expect(manager.getTerminals("C:\\Users\\foo\\project")).resolves.not.toThrow();
await expect(manager.getTerminals("D:\\MyProject")).resolves.not.toThrow();
});
it("creates separate terminals for different cwds", async () => {
manager = createTerminalManager();
const tmpTerminals = [await manager.createTerminal({ cwd: "/tmp" })];
const homeTerminals = [await manager.createTerminal({ cwd: "/home" })];
expect(tmpTerminals.length).toBe(1);
expect(homeTerminals.length).toBe(1);
expect(tmpTerminals[0].id).not.toBe(homeTerminals[0].id);
});
});
describe("createTerminal", () => {
it("creates additional terminal with auto-incrementing name", async () => {
manager = createTerminalManager();
await manager.createTerminal({ cwd: "/tmp" });
const second = await manager.createTerminal({ cwd: "/tmp" });
expect(second.name).toBe("Terminal 2");
const terminals = await manager.getTerminals("/tmp");
expect(terminals.length).toBe(2);
});
it("uses custom name when provided", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: "/tmp", name: "Dev Server" });
expect(session.name).toBe("Dev Server");
});
it("creates first terminal if none exist", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: "/tmp" });
expect(session.name).toBe("Terminal 1");
const terminals = await manager.getTerminals("/tmp");
expect(terminals.length).toBe(1);
expect(terminals[0].id).toBe(session.id);
});
it("throws for relative paths", async () => {
manager = createTerminalManager();
await expect(manager.createTerminal({ cwd: "tmp" })).rejects.toThrow(
"cwd must be absolute path",
);
});
it("does not reject Windows absolute paths as relative", async () => {
manager = createTerminalManager();
// Should pass path validation (not throw "cwd must be absolute path").
// The terminal may or may not spawn successfully on non-Windows hosts,
// so we only assert the validation error is absent.
try {
await manager.createTerminal({ cwd: "C:\\Users\\foo\\project" });
} catch (error) {
expect((error as Error).message).not.toBe("cwd must be absolute path");
}
});
it("inherits registered env for the worktree root cwd", async () => {
await withShell("/bin/sh", async () => {
manager = createTerminalManager();
const cwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-root-"));
temporaryDirs.push(cwd);
const markerPath = join(cwd, "root-port.txt");
manager.registerCwdEnv({
cwd,
env: { PASEO_WORKTREE_PORT: "45678" },
});
const session = await manager.createTerminal({ cwd });
for (let attempt = 0; attempt < 10 && !existsSync(markerPath); attempt++) {
session.send({
type: "input",
data: `printf '%s' "$PASEO_WORKTREE_PORT" > ${JSON.stringify(markerPath)}\r`,
});
await new Promise((resolve) => setTimeout(resolve, 100));
}
await waitForCondition(() => existsSync(markerPath), 10000);
expect(readFileSync(markerPath, "utf8")).toBe("45678");
});
});
it("inherits registered env for subdirectories within the worktree", async () => {
await withShell("/bin/sh", async () => {
manager = createTerminalManager();
const rootCwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-subdir-"));
const subdirCwd = join(rootCwd, "packages", "app");
mkdirSync(subdirCwd, { recursive: true });
temporaryDirs.push(rootCwd);
const markerPath = join(subdirCwd, "subdir-port.txt");
manager.registerCwdEnv({
cwd: rootCwd,
env: { PASEO_WORKTREE_PORT: "45679" },
});
const session = await manager.createTerminal({ cwd: subdirCwd });
for (let attempt = 0; attempt < 10 && !existsSync(markerPath); attempt++) {
session.send({
type: "input",
data: `printf '%s' "$PASEO_WORKTREE_PORT" > ${JSON.stringify(markerPath)}\r`,
});
await new Promise((resolve) => setTimeout(resolve, 100));
}
await waitForCondition(() => existsSync(markerPath), 10000);
expect(readFileSync(markerPath, "utf8")).toBe("45679");
});
});
});
describe("getTerminal", () => {
it("returns terminal by id", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: "/tmp" });
const found = manager.getTerminal(session.id);
expect(found).toBe(session);
});
it("returns undefined for unknown id", () => {
manager = createTerminalManager();
const found = manager.getTerminal("unknown-id");
expect(found).toBeUndefined();
});
});
describe("killTerminal", () => {
it("removes terminal from manager", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: "/tmp" });
const id = session.id;
manager.killTerminal(id);
expect(manager.getTerminal(id)).toBeUndefined();
});
it("removes cwd entry when last terminal is killed", async () => {
manager = createTerminalManager();
const created = await manager.createTerminal({ cwd: "/tmp" });
manager.killTerminal(created.id);
const remaining = await manager.getTerminals("/tmp");
expect(remaining).toHaveLength(0);
expect(manager.listDirectories()).not.toContain("/tmp");
});
it("keeps cwd entry when other terminals remain", async () => {
manager = createTerminalManager();
await manager.createTerminal({ cwd: "/tmp" });
const second = await manager.createTerminal({ cwd: "/tmp" });
const terminals = await manager.getTerminals("/tmp");
manager.killTerminal(terminals[0].id);
expect(manager.listDirectories()).toContain("/tmp");
const remaining = await manager.getTerminals("/tmp");
expect(remaining.length).toBe(1);
expect(remaining[0].id).toBe(second.id);
});
it("is no-op for unknown id", () => {
manager = createTerminalManager();
expect(() => manager.killTerminal("unknown-id")).not.toThrow();
});
it("auto-removes terminal when shell exits", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: "/tmp" });
const exitedId = session.id;
session.kill();
await waitForCondition(() => manager.getTerminal(exitedId) === undefined, 10000);
expect(manager.getTerminal(exitedId)).toBeUndefined();
const remaining = await manager.getTerminals("/tmp");
expect(remaining).toHaveLength(0);
});
});
describe("listDirectories", () => {
it("returns empty array initially", () => {
manager = createTerminalManager();
expect(manager.listDirectories()).toEqual([]);
});
it("returns all cwds with active terminals", async () => {
manager = createTerminalManager();
await manager.createTerminal({ cwd: "/tmp" });
await manager.createTerminal({ cwd: "/home" });
const dirs = manager.listDirectories();
expect(dirs).toContain("/tmp");
expect(dirs).toContain("/home");
expect(dirs.length).toBe(2);
});
});
describe("killAll", () => {
it("kills all terminals and clears state", async () => {
manager = createTerminalManager();
const tmpSession = await manager.createTerminal({ cwd: "/tmp" });
const homeSession = await manager.createTerminal({ cwd: "/home" });
const tmpId = tmpSession.id;
const homeId = homeSession.id;
manager.killAll();
expect(manager.listDirectories()).toEqual([]);
expect(manager.getTerminal(tmpId)).toBeUndefined();
expect(manager.getTerminal(homeId)).toBeUndefined();
});
});
describe("subscribeTerminalsChanged", () => {
it("emits cwd snapshots when terminals are created", async () => {
manager = createTerminalManager();
const snapshots: Array<{ cwd: string; terminals: Array<{ name: string; title?: string }> }> =
[];
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push({
cwd: input.cwd,
terminals: input.terminals.map((terminal) => ({
name: terminal.name,
...(terminal.title ? { title: terminal.title } : {}),
})),
});
});
await manager.createTerminal({ cwd: "/tmp" });
await manager.createTerminal({ cwd: "/tmp", name: "Dev Server" });
expect(snapshots).toContainEqual({
cwd: "/tmp",
terminals: [{ name: "Terminal 1" }],
});
expect(snapshots).toContainEqual({
cwd: "/tmp",
terminals: [{ name: "Terminal 1" }, { name: "Dev Server" }],
});
unsubscribe();
});
it("emits updated terminal titles after debounced title changes", async () => {
await withShell("/bin/sh", async () => {
manager = createTerminalManager();
const snapshots: Array<Array<{ id: string; title?: string }>> = [];
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push(
input.terminals.map((terminal) => ({
id: terminal.id,
...(terminal.title ? { title: terminal.title } : {}),
})),
);
});
const session = await manager.createTerminal({ cwd: "/tmp" });
session.send({ type: "input", data: "printf '\\033]0;Logs\\007'\r" });
await waitForCondition(
() =>
snapshots.some((snapshot) =>
snapshot.some((terminal) => terminal.id === session.id && terminal.title === "Logs"),
),
10000,
);
unsubscribe();
});
}, 10000);
it("emits empty snapshot when last terminal is removed", async () => {
manager = createTerminalManager();
const snapshots: Array<{ cwd: string; terminalCount: number }> = [];
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push({
cwd: input.cwd,
terminalCount: input.terminals.length,
});
});
const session = await manager.createTerminal({ cwd: "/tmp" });
manager.killTerminal(session.id);
expect(snapshots).toContainEqual({
cwd: "/tmp",
terminalCount: 0,
});
unsubscribe();
});
await waitForCondition(() => existsSync(markerPath), 10000);
expect(readFileSync(markerPath, "utf8")).toBe("45678");
});
});
it("inherits registered env for subdirectories within the worktree", async () => {
await withShell("/bin/sh", async () => {
manager = createTerminalManager();
const rootCwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-subdir-"));
const subdirCwd = join(rootCwd, "packages", "app");
mkdirSync(subdirCwd, { recursive: true });
temporaryDirs.push(rootCwd);
const markerPath = join(subdirCwd, "subdir-port.txt");
manager.registerCwdEnv({
cwd: rootCwd,
env: { PASEO_WORKTREE_PORT: "45679" },
});
const session = await manager.createTerminal({ cwd: subdirCwd });
for (let attempt = 0; attempt < 10 && !existsSync(markerPath); attempt++) {
session.send({
type: "input",
data: `printf '%s' "$PASEO_WORKTREE_PORT" > ${JSON.stringify(markerPath)}\r`,
});
await new Promise((resolve) => setTimeout(resolve, 100));
}
await waitForCondition(() => existsSync(markerPath), 10000);
expect(readFileSync(markerPath, "utf8")).toBe("45679");
});
});
it("returns terminal by id", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: "/tmp" });
const found = manager.getTerminal(session.id);
expect(found).toBe(session);
});
it("returns undefined for unknown id", () => {
manager = createTerminalManager();
const found = manager.getTerminal("unknown-id");
expect(found).toBeUndefined();
});
it("removes terminal from manager", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: "/tmp" });
const id = session.id;
manager.killTerminal(id);
expect(manager.getTerminal(id)).toBeUndefined();
});
it("removes cwd entry when last terminal is killed", async () => {
manager = createTerminalManager();
const created = await manager.createTerminal({ cwd: "/tmp" });
manager.killTerminal(created.id);
const remaining = await manager.getTerminals("/tmp");
expect(remaining).toHaveLength(0);
expect(manager.listDirectories()).not.toContain("/tmp");
});
it("keeps cwd entry when other terminals remain", async () => {
manager = createTerminalManager();
await manager.createTerminal({ cwd: "/tmp" });
const second = await manager.createTerminal({ cwd: "/tmp" });
const terminals = await manager.getTerminals("/tmp");
manager.killTerminal(terminals[0].id);
expect(manager.listDirectories()).toContain("/tmp");
const remaining = await manager.getTerminals("/tmp");
expect(remaining.length).toBe(1);
expect(remaining[0].id).toBe(second.id);
});
it("is no-op for unknown id", () => {
manager = createTerminalManager();
expect(() => manager.killTerminal("unknown-id")).not.toThrow();
});
it("auto-removes terminal when shell exits", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: "/tmp" });
const exitedId = session.id;
session.kill();
await waitForCondition(() => manager.getTerminal(exitedId) === undefined, 10000);
expect(manager.getTerminal(exitedId)).toBeUndefined();
const remaining = await manager.getTerminals("/tmp");
expect(remaining).toHaveLength(0);
});
it("returns empty array initially", () => {
manager = createTerminalManager();
expect(manager.listDirectories()).toEqual([]);
});
it("returns all cwds with active terminals", async () => {
manager = createTerminalManager();
await manager.createTerminal({ cwd: "/tmp" });
await manager.createTerminal({ cwd: "/home" });
const dirs = manager.listDirectories();
expect(dirs).toContain("/tmp");
expect(dirs).toContain("/home");
expect(dirs.length).toBe(2);
});
it("kills all terminals and clears state", async () => {
manager = createTerminalManager();
const tmpSession = await manager.createTerminal({ cwd: "/tmp" });
const homeSession = await manager.createTerminal({ cwd: "/home" });
const tmpId = tmpSession.id;
const homeId = homeSession.id;
manager.killAll();
expect(manager.listDirectories()).toEqual([]);
expect(manager.getTerminal(tmpId)).toBeUndefined();
expect(manager.getTerminal(homeId)).toBeUndefined();
});
it("emits cwd snapshots when terminals are created", async () => {
manager = createTerminalManager();
const snapshots: Array<{ cwd: string; terminals: Array<{ name: string; title?: string }> }> = [];
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push({
cwd: input.cwd,
terminals: input.terminals.map((terminal) => ({
name: terminal.name,
...(terminal.title ? { title: terminal.title } : {}),
})),
});
});
await manager.createTerminal({ cwd: "/tmp" });
await manager.createTerminal({ cwd: "/tmp", name: "Dev Server" });
expect(snapshots).toContainEqual({
cwd: "/tmp",
terminals: [{ name: "Terminal 1" }],
});
expect(snapshots).toContainEqual({
cwd: "/tmp",
terminals: [{ name: "Terminal 1" }, { name: "Dev Server" }],
});
unsubscribe();
});
interface TerminalTitleEntry {
id: string;
title?: string;
}
function toTitleEntry(terminal: { id: string; title?: string }): TerminalTitleEntry {
return {
id: terminal.id,
...(terminal.title ? { title: terminal.title } : {}),
};
}
function hasLogsTitle(sessionId: string) {
const matches = (terminal: TerminalTitleEntry) =>
terminal.id === sessionId && terminal.title === "Logs";
return (snapshot: TerminalTitleEntry[]) => snapshot.some(matches);
}
it("emits updated terminal titles after debounced title changes", async () => {
await withShell("/bin/sh", async () => {
manager = createTerminalManager();
const snapshots: TerminalTitleEntry[][] = [];
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push(input.terminals.map(toTitleEntry));
});
const session = await manager.createTerminal({ cwd: "/tmp" });
session.send({ type: "input", data: "printf '\\033]0;Logs\\007'\r" });
await waitForCondition(() => snapshots.some(hasLogsTitle(session.id)), 10000);
unsubscribe();
});
}, 10000);
it("emits empty snapshot when last terminal is removed", async () => {
manager = createTerminalManager();
const snapshots: Array<{ cwd: string; terminalCount: number }> = [];
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push({
cwd: input.cwd,
terminalCount: input.terminals.length,
});
});
const session = await manager.createTerminal({ cwd: "/tmp" });
manager.killTerminal(session.id);
expect(snapshots).toContainEqual({
cwd: "/tmp",
terminalCount: 0,
});
unsubscribe();
});
File diff suppressed because it is too large Load Diff