mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-03 23:23:21 +00:00
chore(lint): extract nested callbacks in server (max-nested-callbacks)
This commit is contained in:
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
+295
-304
@@ -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);
|
||||
|
||||
+367
-375
@@ -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
+69
-71
@@ -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 {
|
||||
|
||||
@@ -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
+193
-195
@@ -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
Reference in New Issue
Block a user