feat(server): improve task CLI with steering, execution order, and planner guidance

- Fix steering notes to check scope task instead of leaf task
- Fix NOT_DONE replan to use scope for bird's eye view
- Pass reason to planner prompt ("Why you were called" section)
- Add execution-order.ts with reusable computeExecutionOrder and buildSortedChildrenMap
- Make 'tree' an alias for 'plan' command
- Add comprehensive help text for run command and acceptance criteria
- Add planner guidance for propagating requirements to subtasks
This commit is contained in:
Mohamed Boudra
2026-01-17 11:46:38 +07:00
parent 420f25f1e9
commit d964415eba
33 changed files with 5205 additions and 3922 deletions
+7
View File
@@ -161,3 +161,10 @@ export async function waitForAgentIdle(page: Page, timeout = 30000) {
const stopButton = page.getByRole('button', { name: /stop|cancel/i });
await expect(stopButton).not.toBeVisible({ timeout });
}
export async function getToolCallCount(page: Page): Promise<number> {
// Tool calls are rendered as ExpandableBadge components with tool names like Bash, Write, Read, etc.
// They appear as pressable badges in the agent stream
const toolCallBadges = page.locator('[data-testid="tool-call-badge"]');
return toolCallBadges.count();
}
@@ -7,6 +7,7 @@ import {
allowPermission,
denyPermission,
waitForAgentIdle,
getToolCallCount,
} from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
@@ -37,6 +38,10 @@ test.describe('permission prompts', () => {
timeout: 10000,
})
.toBe(true);
// Verify exactly one tool call is visible (no duplicate permission badge)
const toolCallCount = await getToolCallCount(page);
expect(toolCallCount).toBe(1);
} finally {
await repo.cleanup();
}
@@ -61,6 +66,10 @@ test.describe('permission prompts', () => {
await waitForAgentIdle(page);
expect(existsSync(filePath)).toBe(false);
// Verify exactly one tool call is visible (no duplicate permission badge)
const toolCallCount = await getToolCallCount(page);
expect(toolCallCount).toBe(1);
} finally {
await repo.cleanup();
}
+4 -1
View File
@@ -1067,6 +1067,7 @@ interface ExpandableBadgeProps {
isError?: boolean;
isLastInSequence?: boolean;
disableOuterSpacing?: boolean;
testID?: string;
}
const ExpandableBadge = memo(function ExpandableBadge({
@@ -1081,6 +1082,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
isError = false,
isLastInSequence = false,
disableOuterSpacing,
testID,
}: ExpandableBadgeProps) {
const { theme } = useUnistyles();
const resolvedDisableOuterSpacing =
@@ -1140,7 +1142,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
}
return (
<View style={[expandableBadgeStylesheet.container, style]}>
<View style={[expandableBadgeStylesheet.container, style]} testID={testID}>
<Pressable
onPress={hasDetails ? onToggle : undefined}
disabled={!hasDetails}
@@ -1495,6 +1497,7 @@ export const ToolCall = memo(function ToolCall({
return (
<ExpandableBadge
testID="tool-call-badge"
label={toolName}
secondaryLabel={principalParam}
icon={IconComponent}
+1 -29
View File
@@ -65,19 +65,6 @@ function toolTimeline(
};
}
function permissionTimeline(id: string, status: string): AgentStreamEventPayload {
return {
type: "timeline",
provider: "claude",
item: {
type: "tool_call",
name: "permission_request",
status,
callId: id,
},
};
}
function todoTimeline(items: { text: string; completed: boolean }[]): AgentStreamEventPayload {
return {
type: "timeline",
@@ -643,21 +630,7 @@ function testHydratedUserMessagesPersist() {
});
}
// Test 8: Permission tool calls should not show in the timeline
function testPermissionToolCallFiltering() {
const timestamp = new Date('2025-01-01T12:00:00Z');
const updates = [
{ event: permissionTimeline('permission-1', 'pending'), timestamp },
{ event: permissionTimeline('permission-1', 'granted'), timestamp },
];
const state = hydrateStreamState(updates);
const permissionEntries = state.filter(isAgentToolCallItem);
assert.strictEqual(permissionEntries.length, 0);
}
// Test 9: Todo lists should consolidate into a single entry and update completions
// Test 8: Todo lists should consolidate into a single entry and update completions
function testTodoListConsolidation() {
const timestamp1 = new Date('2025-01-01T12:30:00Z');
const timestamp2 = new Date('2025-01-01T12:31:00Z');
@@ -1064,7 +1037,6 @@ describe('stream timeline reducers', () => {
it('preserves whitespace in assistant chunk concatenation', testAssistantWhitespacePreservation);
it('hydrates user messages and deduplicates optimistic/live entries', testUserMessageHydration);
it('retains hydrated user messages across providers', testHydratedUserMessagesPersist);
it('filters permission tool calls from the stream', testPermissionToolCallFiltering);
it('consolidates todo list updates', testTodoListConsolidation);
it('keeps timeline ids stable after list shrinkage', testTimelineIdStabilityAfterRemovals);
it('deduplicates live tool call entries', testToolCallDeduplicationLive);
-11
View File
@@ -480,14 +480,6 @@ function appendAgentToolCall(
return [...state, item];
}
function isPermissionToolCall(raw: unknown): boolean {
if (!raw || typeof raw !== "object") {
return false;
}
const candidate = raw as { name?: string };
return candidate.name === "permission_request";
}
const FAILED_STATUS_PATTERN =
/fail|error|deny|reject|cancel|abort|exception|refus/;
const COMPLETED_STATUS_PATTERN =
@@ -762,9 +754,6 @@ export function reduceStreamUpdate(
case "reasoning":
return appendThought(state, item.text, timestamp);
case "tool_call": {
if (isPermissionToolCall(item)) {
return state;
}
nextState = appendAgentToolCall(
state,
{
+17 -5
View File
@@ -1228,9 +1228,11 @@ export class DaemonClientV2 {
pendingPermissionIds.add(request.id);
}
}
let sawRunning =
current?.status === "running" ||
pendingPermissionIds.size > 0;
// Track whether we've seen a running state. Important: we only set this
// when we see running IN THE QUEUE, not based on current state. This prevents
// finding old idle states that occurred before the current run started.
let sawRunningInQueue = false;
let queuedIdle: AgentSnapshotPayload | null = null;
const updatePendingPermissions = (msg: SessionOutboundMessage): void => {
@@ -1267,10 +1269,11 @@ export class DaemonClientV2 {
(msg.payload.pendingPermissions?.length ?? 0) > 0 ||
pendingPermissionIds.size > 0;
if (status === "running" || hasPendingPermissions) {
sawRunning = true;
sawRunningInQueue = true;
queuedIdle = null; // Reset: any previous idle was before this run
}
if (
sawRunning &&
sawRunningInQueue &&
(status === "idle" || status === "error") &&
!hasPendingPermissions
) {
@@ -1281,6 +1284,15 @@ export class DaemonClientV2 {
return queuedIdle;
}
// If current state is running (or has pending permissions), we need to wait
// for the next idle. If current is already idle and we didn't see running
// in the queue, this is an edge case - the agent might not have started yet
// or was already idle. Use the current state's running status to seed the waiter.
let sawRunning =
sawRunningInQueue ||
current?.status === "running" ||
pendingPermissionIds.size > 0;
return this.waitFor(
(msg) => {
updatePendingPermissions(msg);
@@ -1013,6 +1013,10 @@ export class AgentManager {
}
private dispatchStream(agentId: string, event: AgentStreamEvent): void {
const eventDesc = event.type === "timeline"
? `timeline:${event.item.type}${event.item.type === "tool_call" ? `:${(event.item as any).name}` : ""}`
: event.type;
console.error(`[AGENT-MANAGER:dispatchStream] agentId=${agentId.substring(0, 8)} event=${eventDesc} t=${Date.now()}`);
this.dispatch({ type: "agent_stream", agentId, event });
}
@@ -504,14 +504,6 @@ describe("ClaudeAgentClient (SDK integration)", () => {
expect(captured).not.toBeNull();
expect(sawResolvedAllow).toBe(true);
expect(session.getPendingPermissions()).toHaveLength(0);
expect(
timeline.some(
(item) =>
item.type === "tool_call" &&
item.name === "permission" &&
item.status === "granted"
)
).toBe(true);
expect(
timeline.some(
(item) =>
@@ -578,14 +570,6 @@ describe("ClaudeAgentClient (SDK integration)", () => {
try {
expect(captured).not.toBeNull();
expect(sawResolvedDeny).toBe(true);
expect(
timeline.some(
(item) =>
item.type === "tool_call" &&
item.name === "permission" &&
item.status === "denied"
)
).toBe(true);
expect(
timeline.some(
(item) =>
@@ -657,14 +641,6 @@ describe("ClaudeAgentClient (SDK integration)", () => {
expect(captured).not.toBeNull();
expect(sawResolvedInterrupt).toBe(true);
expect(sawTerminalEvent).toBe(true);
expect(
timeline.some(
(item) =>
item.type === "tool_call" &&
item.name === "permission" &&
item.status === "denied"
)
).toBe(true);
expect(
timeline.some(
(item) =>
@@ -532,6 +532,7 @@ class ClaudeAgentSession implements AgentSession {
prompt: AgentPromptInput,
_options?: AgentRunOptions
): AsyncGenerator<AgentStreamEvent> {
console.error(`[CLAUDE-AGENT:stream] starting stream for prompt: "${typeof prompt === 'string' ? prompt.substring(0, 50) : 'object'}"`);
// Increment turn ID to invalidate any in-flight processPrompt() loops from previous turns.
// This prevents race conditions where an interrupted turn's events get mixed with the new turn.
const turnId = ++this.currentTurnId;
@@ -584,8 +585,12 @@ class ClaudeAgentSession implements AgentSession {
this.logger.error({ err: error }, "Unexpected error in forwardPromptEvents");
});
let eventCount = 0;
try {
console.error(`[CLAUDE-AGENT:stream] starting for-await-of loop on queue`);
for await (const event of queue) {
eventCount++;
console.error(`[CLAUDE-AGENT:stream] yielding event #${eventCount}: type=${event.type}${event.type === "timeline" ? ` item.type=${(event as any).item?.type}` : ""}`);
yield event;
if (
event.type === "turn_completed" ||
@@ -593,9 +598,11 @@ class ClaudeAgentSession implements AgentSession {
event.type === "turn_canceled"
) {
finishedNaturally = true;
console.error(`[CLAUDE-AGENT:stream] terminal event reached: ${event.type}, total events: ${eventCount}`);
break;
}
}
console.error(`[CLAUDE-AGENT:stream] queue iteration completed naturally, eventCount=${eventCount}`);
} finally {
if (!finishedNaturally && !cancelIssued) {
requestCancel();
@@ -679,12 +686,6 @@ class ClaudeAgentSession implements AgentSession {
updatedPermissions: this.normalizePermissionUpdates(response.updatedPermissions),
};
pending.resolve(result);
this.pushToolCall({
name: "permission",
status: "granted",
callId: pending.request.id,
input: pending.request.input,
});
} else {
const result: PermissionResult = {
behavior: "deny",
@@ -692,12 +693,6 @@ class ClaudeAgentSession implements AgentSession {
interrupt: response.interrupt,
};
pending.resolve(result);
this.pushToolCall({
name: "permission",
status: "denied",
callId: pending.request.id,
input: pending.request.input,
});
}
this.pushEvent({
@@ -967,6 +962,7 @@ class ClaudeAgentSession implements AgentSession {
queue: Pushable<AgentStreamEvent>,
turnId: number
) {
console.error(`[CLAUDE-AGENT:forwardPromptEvents] START turnId=${turnId}`);
// Create a turn-local context to track streaming state.
// This prevents race conditions when a new stream() call interrupts a running one.
const turnContext: TurnContext = {
@@ -974,21 +970,27 @@ class ClaudeAgentSession implements AgentSession {
streamedReasoningThisTurn: false,
};
let completedNormally = false;
let eventCount = 0;
try {
for await (const sdkEvent of this.processPrompt(message, turnId)) {
eventCount++;
// Check if this turn has been superseded before pushing events
if (this.currentTurnId !== turnId) {
console.error(`[CLAUDE-AGENT:forwardPromptEvents] turn superseded, breaking. currentTurnId=${this.currentTurnId} ourTurnId=${turnId}`);
break;
}
const events = this.translateMessageToEvents(sdkEvent, turnContext);
for (const event of events) {
queue.push(event);
if (event.type === "turn_completed") {
console.error(`[CLAUDE-AGENT:forwardPromptEvents] turn_completed pushed`);
completedNormally = true;
}
}
}
console.error(`[CLAUDE-AGENT:forwardPromptEvents] processPrompt loop done, eventCount=${eventCount} completedNormally=${completedNormally}`);
} catch (error) {
console.error(`[CLAUDE-AGENT:forwardPromptEvents] ERROR: ${error}`);
if (!this.turnCancelRequested && this.currentTurnId === turnId) {
queue.push({
type: "turn_failed",
@@ -1001,7 +1003,9 @@ class ClaudeAgentSession implements AgentSession {
// Use turn_canceled (not turn_failed) to distinguish intentional interruption from errors.
// Only emit if not already emitted by requestCancel() (indicated by turnCancelRequested).
const wasSuperseded = this.currentTurnId !== turnId;
console.error(`[CLAUDE-AGENT:forwardPromptEvents] FINALLY wasSuperseded=${wasSuperseded} completedNormally=${completedNormally} turnCancelRequested=${this.turnCancelRequested}`);
if (wasSuperseded && !completedNormally && !this.turnCancelRequested) {
console.error(`[CLAUDE-AGENT:forwardPromptEvents] pushing turn_canceled for superseded turn`);
queue.push({
type: "turn_canceled",
provider: "claude",
@@ -1009,6 +1013,7 @@ class ClaudeAgentSession implements AgentSession {
});
}
this.turnCancelRequested = false;
console.error(`[CLAUDE-AGENT:forwardPromptEvents] calling queue.end()`);
queue.end();
}
}
@@ -1016,15 +1021,22 @@ class ClaudeAgentSession implements AgentSession {
private async interruptActiveTurn(): Promise<void> {
const queryToInterrupt = this.query;
if (!queryToInterrupt || typeof queryToInterrupt.interrupt !== "function") {
this.logger.info("interruptActiveTurn: no query to interrupt");
return;
}
try {
this.logger.info("interruptActiveTurn: calling query.interrupt()...");
const t0 = Date.now();
await queryToInterrupt.interrupt();
this.logger.info({ durationMs: Date.now() - t0 }, "interruptActiveTurn: query.interrupt() returned");
// After interrupt(), the query iterator is done (returns done: true).
// Clear it so ensureQuery() creates a fresh query for the next turn.
// Also end the input stream and call return() to clean up the SDK process.
this.input?.end();
this.logger.info("interruptActiveTurn: calling query.return()...");
const t1 = Date.now();
await queryToInterrupt.return?.();
this.logger.info({ durationMs: Date.now() - t1 }, "interruptActiveTurn: query.return() returned");
this.query = null;
this.input = null;
} catch (error) {
@@ -1178,13 +1190,6 @@ class ClaudeAgentSession implements AgentSession {
metadata: Object.keys(metadata).length ? metadata : undefined,
};
this.pushToolCall({
name: "permission",
status: "requested",
callId: requestId,
input,
});
this.pushEvent({ type: "permission_requested", provider: "claude", request });
return await new Promise<PermissionResult>((resolve, reject) => {
@@ -1203,12 +1208,6 @@ class ClaudeAgentSession implements AgentSession {
this.pendingPermissions.delete(requestId);
cleanup();
const error = new Error("Permission request timed out");
this.pushToolCall({
name: "permission",
status: "denied",
callId: requestId,
input,
});
this.pushEvent({
type: "permission_resolved",
provider: "claude",
@@ -16,6 +16,10 @@ import type {
type ToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
function tmpCwd(): string {
return mkdtempSync(path.join(os.tmpdir(), "codex-mcp-e2e-"));
}
@@ -406,6 +410,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
const client = new CodexMcpAgentClient();
const config = {
provider: "codex",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
modeId: "full-access",
} satisfies AgentSessionConfig;
@@ -474,6 +480,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
const client = new CodexMcpAgentClient();
const config = {
provider: "codex",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
modeId: "full-access",
} satisfies AgentSessionConfig;
@@ -502,6 +510,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
const client = new CodexMcpAgentClient();
const config = {
provider: "codex",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
modeId: "full-access",
} satisfies AgentSessionConfig;
@@ -530,7 +540,7 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
if (event.item.type === "reasoning") {
sawReasoning = true;
}
if (event.item.type === "tool_call" && event.item.name !== "permission") {
if (event.item.type === "tool_call") {
toolCalls.push(event.item);
}
}
@@ -604,6 +614,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
const nodeModulesPath = resolveNodeModulesPath();
const config = {
provider: "codex",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
modeId: "full-access",
extra: {
@@ -750,6 +762,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
const nodeModulesPath = resolveNodeModulesPath();
const config = {
provider: "codex",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
modeId: "full-access",
extra: {
@@ -783,7 +797,7 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
for await (const event of session.stream(prompt)) {
if (event.type === "timeline" && providerFromEvent(event) === "codex") {
if (event.item.type === "tool_call" && event.item.name !== "permission") {
if (event.item.type === "tool_call") {
toolCalls.push(event.item);
}
}
@@ -894,6 +908,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
const client = new CodexMcpAgentClient();
const config = {
provider: "codex",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
modeId: "full-access",
} satisfies AgentSessionConfig;
@@ -950,6 +966,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
const client = new CodexMcpAgentClient();
const config = {
provider: "codex",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
modeId: "full-access",
} satisfies AgentSessionConfig;
@@ -1027,6 +1045,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
const client = new CodexMcpAgentClient();
const config = {
provider: "codex",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
modeId: "full-access",
} satisfies AgentSessionConfig;
@@ -1063,6 +1083,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
const client = new CodexMcpAgentClient();
const config = {
provider: "codex",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
modeId: "auto",
approvalPolicy: "on-request",
@@ -1107,14 +1129,6 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
expect(captured).not.toBeNull();
expect(sawPermissionResolved).toBe(true);
expect(session.getPendingPermissions()).toHaveLength(0);
expect(
timelineItems.some(
(item) =>
item.type === "tool_call" &&
item.name === "permission" &&
item.status === "granted"
)
).toBe(true);
expect(
timelineItems.some(
(item) => item.type === "tool_call" && item.name === "shell"
@@ -1140,6 +1154,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
const client = new CodexMcpAgentClient();
const config = {
provider: "codex",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
modeId: "read-only",
approvalPolicy: "on-request",
@@ -1171,14 +1187,6 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
}
expect(captured).not.toBeNull();
expect(
timelineItems.some(
(item) =>
item.type === "tool_call" &&
item.name === "permission" &&
item.status === "requested"
)
).toBe(true);
} finally {
await session?.close();
rmSync(cwd, { recursive: true, force: true });
@@ -1197,6 +1205,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
const client = new CodexMcpAgentClient();
const config = {
provider: "codex",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
modeId: "auto",
approvalPolicy: "on-request",
@@ -1242,14 +1252,6 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
expect(captured).not.toBeNull();
expect(sawPermissionDenied).toBe(true);
expect(
timelineItems.some(
(item) =>
item.type === "tool_call" &&
item.name === "permission" &&
item.status === "denied"
)
).toBe(true);
expect(existsSync(filePath)).toBe(false);
} finally {
await session?.close();
@@ -1269,6 +1271,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
const client = new CodexMcpAgentClient();
const config = {
provider: "codex",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
modeId: "auto",
approvalPolicy: "on-request",
@@ -1323,14 +1327,6 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
expect(captured).not.toBeNull();
expect(sawPermissionResolved).toBe(true);
expect(
timelineItems.some(
(item) =>
item.type === "tool_call" &&
item.name === "permission" &&
item.status === "denied"
)
).toBe(true);
expect(sawTurnFailed).toBe(true);
const message = failureMessage ? failureMessage : "";
expect(message).toMatch(/aborted|interrupted/i);
@@ -1353,6 +1349,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
const client = new CodexMcpAgentClient();
const config = {
provider: "codex",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
modeId: "full-access",
approvalPolicy: "on-request",
@@ -1428,6 +1426,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
const client = new CodexMcpAgentClient();
const config = {
provider: "codex",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
modeId: "full-access",
approvalPolicy: "on-request",
@@ -2555,6 +2555,11 @@ function buildCodexMcpConfig(
innerConfig.mcp_servers = mcpServers;
}
// Add reasoning effort to config if provided
if (typeof config.reasoningEffort === "string" && config.reasoningEffort.length > 0) {
innerConfig.model_reasoning_effort = config.reasoningEffort;
}
const configPayload: {
prompt: string;
cwd?: string;
@@ -2579,6 +2584,7 @@ function buildCodexMcpConfig(
configPayload.model = config.model;
}
// Add developer instructions for session resume context
if (developerInstructions) {
configPayload["developer-instructions"] = developerInstructions;
@@ -3113,17 +3119,6 @@ class CodexMcpAgentSession implements AgentSession {
this.pendingPermissionHandlers.delete(requestId);
this.pendingPermissions.delete(requestId);
this.resolvedPermissionRequests.add(requestId);
const status = response.behavior === "allow" ? "granted" : "denied";
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: createToolCallTimelineItem({
name: "permission",
status,
callId: pending.request.id,
input: pending.request.input,
}),
});
this.emitEvent({
type: "permission_resolved",
@@ -3453,16 +3448,6 @@ class CodexMcpAgentSession implements AgentSession {
}
private emitPermissionRequested(request: AgentPermissionRequest): void {
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: createToolCallTimelineItem({
name: "permission",
status: "requested",
callId: request.id,
input: request.input,
}),
});
this.emitEvent({
type: "permission_requested",
provider: CODEX_PROVIDER,
@@ -26,6 +26,10 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-client-v2-"));
}
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
function waitForSignal<T>(
timeoutMs: number,
setup: (
@@ -151,7 +155,9 @@ describe("daemon client v2 E2E", () => {
});
const agent = await ctx.client.createAgent({
provider: "codex",
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
title: "Daemon Client V2",
requestId: createRequestId,
@@ -192,7 +198,9 @@ describe("daemon client v2 E2E", () => {
});
const failResult = await ctx.client.createAgentExpectFail({
provider: "codex",
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd: "/this/path/does/not/exist/12345",
title: "Should Fail",
requestId: failRequestId,
@@ -401,7 +409,9 @@ describe("daemon client v2 E2E", () => {
const filePath = path.join(cwd, "permission.txt");
const agent = await ctx.client.createAgent({
provider: "codex",
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
title: "Permission Test",
modeId: "auto",
@@ -475,7 +485,9 @@ describe("daemon client v2 E2E", () => {
async () => {
const cwd = tmpCwd();
const agent = await ctx.client.createAgent({
provider: "codex",
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
title: "Raw Events Test",
});
@@ -656,7 +668,9 @@ describe("daemon client v2 E2E", () => {
writeFileSync(downloadFile, downloadContents, "utf-8");
const agent = await ctx.client.createAgent({
provider: "codex",
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
title: "Git/File Test",
});
@@ -0,0 +1,103 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync, readdirSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "../test-utils/index.js";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60000);
test("creates agent and receives response", async () => {
// Create a Codex agent
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd: "/tmp",
title: "Test Agent",
});
expect(agent.id).toBeTruthy();
expect(agent.provider).toBe("codex");
expect(agent.status).toBe("idle");
// Title may or may not be set depending on timing
expect(agent.cwd).toBe("/tmp");
// Send a simple message
await ctx.client.sendMessage(agent.id, "Say 'hello world' and nothing else");
// Wait for the agent to complete
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
// Verify agent completed without error
expect(finalState.status).toBe("idle");
expect(finalState.lastError).toBeUndefined();
expect(finalState.id).toBe(agent.id);
// Verify we received some stream events
const queue = ctx.client.getMessageQueue();
const streamEvents = queue.filter(
(m) => m.type === "agent_stream" && m.payload.agentId === agent.id
);
expect(streamEvents.length).toBeGreaterThan(0);
// Verify there was a turn_started event
const hasTurnStarted = streamEvents.some(
(m) =>
m.type === "agent_stream" && m.payload.event.type === "turn_started"
);
expect(hasTurnStarted).toBe(true);
// Verify there was a turn_completed event
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);
}, 180000); // 3 minute timeout for E2E test
test("fails to create agent with non-existent cwd", async () => {
const nonExistentCwd = "/this/path/does/not/exist/12345";
const result = await ctx.client.createAgentExpectFail({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd: nonExistentCwd,
title: "Should Fail Agent",
});
expect(result.error).toContain("Working directory does not exist");
expect(result.error).toContain(nonExistentCwd);
});
});
@@ -0,0 +1,69 @@
/**
* Shared agent configurations for e2e tests.
* Enables running the same tests against both Claude and Codex providers.
*/
export interface AgentTestConfig {
provider: "claude" | "codex";
model: string;
reasoningEffort?: string;
modes: {
full: string; // No permissions required
ask: string; // Requires permission approval
};
}
export const agentConfigs = {
claude: {
provider: "claude",
model: "haiku",
modes: {
full: "bypassPermissions",
ask: "default",
},
},
codex: {
provider: "codex",
model: "gpt-5.1-codex-mini",
reasoningEffort: "low",
modes: {
full: "full-access",
ask: "auto",
},
},
} as const satisfies Record<string, AgentTestConfig>;
export type AgentProvider = keyof typeof agentConfigs;
/**
* Get test config for creating an agent with full permissions (no prompts).
*/
export function getFullAccessConfig(provider: AgentProvider) {
const config = agentConfigs[provider];
const reasoningEffort = "reasoningEffort" in config ? config.reasoningEffort : undefined;
return {
provider: config.provider,
model: config.model,
...(reasoningEffort ? { reasoningEffort } : {}),
modeId: config.modes.full,
};
}
/**
* Get test config for creating an agent that requires permission approval.
*/
export function getAskModeConfig(provider: AgentProvider) {
const config = agentConfigs[provider];
const reasoningEffort = "reasoningEffort" in config ? config.reasoningEffort : undefined;
return {
provider: config.provider,
model: config.model,
...(reasoningEffort ? { reasoningEffort } : {}),
modeId: config.modes.ask,
};
}
/**
* Helper to run a test for each provider.
*/
export const allProviders: AgentProvider[] = ["claude", "codex"];
@@ -0,0 +1,455 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync, readdirSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "../test-utils/index.js";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
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, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
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 message queue before the "click" action
ctx.client.clearMessageQueue();
// Simulate clicking on the agent (initialize_agent_request)
// This is what happens when the user opens an agent in the UI
const refreshedState = await ctx.client.initializeAgent(agent.id);
// Verify agent is still idle
expect(refreshedState.status).toBe("idle");
// CRITICAL: The timestamp should NOT have changed
// Just opening/clicking an agent should not update its updatedAt
expect(refreshedState.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
const stateAfterClear = await ctx.client.initializeAgent(agent.id);
// Timestamp should STILL not have changed
expect(stateAfterClear.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, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
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.waitForAgentIdle(agent.id, 120000);
expect(finalState.status).toBe("idle");
// The timestamp SHOULD have been updated (should be later than initial)
const finalUpdatedAt = new Date(finalState.updatedAt);
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();
// Create Codex agent
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
title: "Cancel Test Agent",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// Clear message queue before sending prompt
ctx.client.clearMessageQueue();
// Send a prompt that triggers a long-running operation
await ctx.client.sendMessage(agent.id, "Run: sleep 30");
// Wait for the agent to start running (tool call starts)
let sawRunning = false;
const startPosition = ctx.client.getMessageQueue().length;
// Wait for running state
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Timeout waiting for agent to start running"));
}, 30000);
const checkForRunning = (): void => {
const queue = ctx.client.getMessageQueue();
for (let i = startPosition; i < queue.length; i++) {
const msg = queue[i];
if (msg.type === "agent_state" && msg.payload.id === agent.id) {
if (msg.payload.status === "running") {
sawRunning = true;
clearTimeout(timeout);
resolve();
return;
}
}
}
};
// Check periodically
const interval = setInterval(checkForRunning, 50);
const cleanup = (): void => {
clearInterval(interval);
clearTimeout(timeout);
};
// Override reject to cleanup
const originalReject = reject;
reject = (err): void => {
cleanup();
originalReject(err);
};
});
expect(sawRunning).toBe(true);
// 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
const finalState = await new Promise<AgentSnapshotPayload>(
(resolve, reject) => {
const timeout = setTimeout(() => {
reject(
new Error(
"Timeout waiting for agent to stop after cancel (>2 seconds)"
)
);
}, 5000); // Give extra margin, but test should complete in 2s
const queueStart = ctx.client.getMessageQueue().length;
const checkForStopped = (): void => {
const queue = ctx.client.getMessageQueue();
for (let i = queueStart; i < queue.length; i++) {
const msg = queue[i];
if (msg.type === "agent_state" && msg.payload.id === agent.id) {
if (
msg.payload.status === "idle" ||
msg.payload.status === "error"
) {
clearTimeout(timeout);
clearInterval(interval);
resolve(msg.payload);
return;
}
}
}
};
const interval = setInterval(checkForStopped, 50);
}
);
// Calculate how long the cancel took
const cancelDuration = Date.now() - cancelStart;
// Verify agent stopped within reasonable time (2 seconds)
expect(cancelDuration).toBeLessThan(2000);
// Verify agent is now idle or error
expect(["idle", "error"]).toContain(finalState.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();
// Create a Codex agent with default mode ("auto")
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
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
ctx.client.clearMessageQueue();
const startPosition = ctx.client.getMessageQueue().length;
// Switch to "read-only" mode
await ctx.client.setAgentMode(agent.id, "read-only");
// Wait for agent_state update 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_state"));
}, 10000);
const checkForModeChange = (): void => {
const queue = ctx.client.getMessageQueue();
for (let i = startPosition; i < queue.length; i++) {
const msg = queue[i];
if (
msg.type === "agent_state" &&
msg.payload.id === agent.id &&
msg.payload.currentModeId === "read-only"
) {
clearTimeout(timeout);
clearInterval(interval);
resolve(msg.payload);
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"
ctx.client.clearMessageQueue();
await ctx.client.sendMessage(agent.id, "Say 'hello' and nothing else");
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
// Mode should still be "read-only" after the message
expect(finalState.currentModeId).toBe("read-only");
// Also verify runtimeInfo has the updated modeId
expect(finalState.runtimeInfo?.modeId).toBe("read-only");
// Switch to another mode: "full-access"
ctx.client.clearMessageQueue();
const position2 = ctx.client.getMessageQueue().length;
await ctx.client.setAgentMode(agent.id, "full-access");
// Wait for agent_state update
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 = ctx.client.getMessageQueue();
for (let i = position2; i < queue.length; i++) {
const msg = queue[i];
if (
msg.type === "agent_state" &&
msg.payload.id === agent.id &&
msg.payload.currentModeId === "full-access"
) {
clearTimeout(timeout);
clearInterval(interval);
resolve(msg.payload);
return;
}
}
};
const interval = setInterval(checkForModeChange, 50);
}
);
expect(stateAfterFullAccess.currentModeId).toBe("full-access");
// Cleanup
rmSync(cwd, { recursive: true, force: true });
},
180000 // 3 minute timeout
);
});
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 = ctx.client.listAgents();
expect(initialAgents).toHaveLength(0);
// Create first agent
const agent1 = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd: cwd1,
title: "List Test Agent 1",
});
expect(agent1.id).toBeTruthy();
expect(agent1.status).toBe("idle");
// listAgents should now return 1 agent
const afterFirst = ctx.client.listAgents();
expect(afterFirst).toHaveLength(1);
expect(afterFirst[0].id).toBe(agent1.id);
// Title may or may not be set depending on timing
expect(afterFirst[0].cwd).toBe(cwd1);
// Create second agent
const agent2 = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd: cwd2,
title: "List Test Agent 2",
});
expect(agent2.id).toBeTruthy();
expect(agent2.status).toBe("idle");
// listAgents should now return 2 agents
const afterSecond = ctx.client.listAgents();
expect(afterSecond).toHaveLength(2);
// Verify both agents are present with correct IDs and states
const ids = afterSecond.map((a) => a.id);
expect(ids).toContain(agent1.id);
expect(ids).toContain(agent2.id);
const agent1State = afterSecond.find((a) => a.id === agent1.id);
const agent2State = afterSecond.find((a) => a.id === agent2.id);
// 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);
// listAgents should now return only 1 agent
const afterDelete = ctx.client.listAgents();
expect(afterDelete).toHaveLength(1);
expect(afterDelete[0].id).toBe(agent2.id);
expect(afterDelete[0].cwd).toBe(cwd2);
// Verify agent1 is no longer in the list
const deletedAgent = afterDelete.find((a) => a.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
);
});
});
@@ -0,0 +1,162 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync, readdirSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "../test-utils/index.js";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60000);
describe("file download tokens", () => {
test(
"issues token over WS and downloads via HTTP",
async () => {
const cwd = tmpCwd();
const filePath = path.join(cwd, "download.txt");
const fileContents = "download test payload";
writeFileSync(filePath, fileContents, "utf-8");
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
title: "Download Token Test Agent",
});
expect(agent.id).toBeTruthy();
const tokenResponse = await ctx.client.requestDownloadToken(
agent.id,
"download.txt"
);
expect(tokenResponse.error).toBeNull();
expect(tokenResponse.token).toBeTruthy();
expect(tokenResponse.fileName).toBe("download.txt");
const authHeader = ctx.daemon.agentMcpAuthHeader;
expect(authHeader).toBeTruthy();
const response = await fetch(
`http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`,
{ headers: { Authorization: authHeader! } }
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe(
tokenResponse.mimeType
);
const disposition = response.headers.get("content-disposition") ?? "";
expect(disposition).toContain("download.txt");
const body = await response.text();
expect(body).toBe(fileContents);
rmSync(cwd, { recursive: true, force: true });
},
60000
);
test(
"rejects invalid token",
async () => {
const authHeader = ctx.daemon.agentMcpAuthHeader;
expect(authHeader).toBeTruthy();
const response = await fetch(
`http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=invalid-token`,
{ headers: { Authorization: authHeader! } }
);
expect(response.status).toBe(403);
},
30000
);
test(
"rejects expired token",
async () => {
await ctx.cleanup();
ctx = await createDaemonTestContext({ downloadTokenTtlMs: 50 });
const cwd = tmpCwd();
const filePath = path.join(cwd, "expired.txt");
writeFileSync(filePath, "expired", "utf-8");
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
title: "Expired Token Test Agent",
});
const tokenResponse = await ctx.client.requestDownloadToken(
agent.id,
"expired.txt"
);
expect(tokenResponse.error).toBeNull();
expect(tokenResponse.token).toBeTruthy();
await new Promise((resolve) => setTimeout(resolve, 150));
const authHeader = ctx.daemon.agentMcpAuthHeader;
expect(authHeader).toBeTruthy();
const response = await fetch(
`http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`,
{ headers: { Authorization: authHeader! } }
);
expect(response.status).toBe(403);
rmSync(cwd, { recursive: true, force: true });
},
60000
);
test(
"rejects paths outside the agent cwd",
async () => {
const cwd = tmpCwd();
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
title: "Outside Path Token Test Agent",
});
const tokenResponse = await ctx.client.requestDownloadToken(
agent.id,
"../outside.txt"
);
expect(tokenResponse.token).toBeNull();
expect(tokenResponse.error).toBeTruthy();
rmSync(cwd, { recursive: true, force: true });
},
60000
);
});
});
@@ -0,0 +1,152 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync, readdirSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "../test-utils/index.js";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
describe("daemon E2E", () => {
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, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
title: "File Explorer Test",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// List directory contents
const result = await ctx.client.exploreFileSystem(agent.id, 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, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
title: "File Read Test",
});
expect(agent.id).toBeTruthy();
// Read file contents
const result = await ctx.client.exploreFileSystem(agent.id, testFile, "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, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
title: "File Explorer Error Test",
});
expect(agent.id).toBeTruthy();
// Try to list non-existent path
const nonExistent = path.join(cwd, "does-not-exist");
const result = await ctx.client.exploreFileSystem(agent.id, nonExistent, "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
);
});
});
@@ -0,0 +1,281 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync, readdirSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "../test-utils/index.js";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60000);
describe("getGitDiff", () => {
test(
"returns diff for modified file in git repo",
async () => {
const cwd = tmpCwd();
// Initialize git repo
const { execSync } = await import("child_process");
execSync("git init", { cwd, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
// Create and commit a file
const testFile = path.join(cwd, "test.txt");
writeFileSync(testFile, "original content\n");
execSync("git add test.txt", { cwd, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
cwd,
stdio: "pipe",
});
// Modify the file (creates unstaged changes)
writeFileSync(testFile, "modified content\n");
// Create agent in the git repo
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
title: "Git Diff Test",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// Get git diff
const result = await ctx.client.getGitDiff(agent.id);
// Verify diff returned without error
expect(result.error).toBeNull();
expect(result.diff).toBeTruthy();
expect(result.diff).toContain("test.txt");
expect(result.diff).toContain("-original content");
expect(result.diff).toContain("+modified content");
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
},
60000 // 1 minute timeout
);
test(
"returns empty diff when no changes",
async () => {
const cwd = tmpCwd();
// Initialize git repo with clean state
const { execSync } = await import("child_process");
execSync("git init", { cwd, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
// Create and commit a file
const testFile = path.join(cwd, "test.txt");
writeFileSync(testFile, "content\n");
execSync("git add test.txt", { cwd, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
cwd,
stdio: "pipe",
});
// Create agent in the git repo (no modifications)
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
title: "Git Diff Clean Test",
});
expect(agent.id).toBeTruthy();
// Get git diff - should be empty
const result = await ctx.client.getGitDiff(agent.id);
expect(result.error).toBeNull();
expect(result.diff).toBe("");
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
},
60000 // 1 minute timeout
);
test(
"returns error for non-git directory",
async () => {
const cwd = tmpCwd();
// Don't initialize git - just a regular directory
// Create agent in a non-git directory
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
title: "Git Diff Non-Git Test",
});
expect(agent.id).toBeTruthy();
// Get git diff - should return error
const result = await ctx.client.getGitDiff(agent.id);
expect(result.diff).toBe("");
expect(result.error).toBeTruthy();
expect(result.error).toContain("git");
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
},
60000 // 1 minute timeout
);
});
describe("getGitRepoInfo", () => {
test(
"returns repo info for git repo with branch and dirty state",
async () => {
const cwd = tmpCwd();
// Initialize git repo
const { execSync } = await import("child_process");
execSync("git init", { cwd, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
// Create and commit a file
const testFile = path.join(cwd, "test.txt");
writeFileSync(testFile, "original content\n");
execSync("git add test.txt", { cwd, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
cwd,
stdio: "pipe",
});
// Modify the file (makes repo dirty)
writeFileSync(testFile, "modified content\n");
// Create agent in the git repo
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
title: "Git Repo Info Test",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// Get git repo info
const result = await ctx.client.getGitRepoInfo(agent.id);
// Verify repo info returned without error
expect(result.error).toBeNull();
// macOS symlinks /var to /private/var, so we check containment
expect(result.repoRoot).toContain("daemon-e2e-");
expect(result.currentBranch).toBeTruthy();
expect(result.branches.length).toBeGreaterThan(0);
expect(result.branches.some((b) => b.isCurrent)).toBe(true);
expect(result.isDirty).toBe(true);
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
},
60000 // 1 minute timeout
);
test(
"returns clean state when no uncommitted changes",
async () => {
const cwd = tmpCwd();
// Initialize git repo with clean state
const { execSync } = await import("child_process");
execSync("git init", { cwd, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
// Create and commit a file (no uncommitted changes)
const testFile = path.join(cwd, "test.txt");
writeFileSync(testFile, "content\n");
execSync("git add test.txt", { cwd, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
cwd,
stdio: "pipe",
});
// Create agent in the git repo
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
title: "Git Repo Info Clean Test",
});
expect(agent.id).toBeTruthy();
// Get git repo info
const result = await ctx.client.getGitRepoInfo(agent.id);
expect(result.error).toBeNull();
expect(result.isDirty).toBe(false);
expect(result.currentBranch).toBeTruthy();
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
},
60000 // 1 minute timeout
);
test(
"returns error for non-git directory",
async () => {
const cwd = tmpCwd();
// Don't initialize git - just a regular directory
// Create agent in a non-git directory
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
title: "Git Repo Info Non-Git Test",
});
expect(agent.id).toBeTruthy();
// Get git repo info - should return error
const result = await ctx.client.getGitRepoInfo(agent.id);
// Server returns cwd as repoRoot even on error, so we just check for error
expect(result.error).toBeTruthy();
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
},
60000 // 1 minute timeout
);
});
});
@@ -0,0 +1,162 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync, readdirSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "../test-utils/index.js";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
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.waitForAgentIdle(agent.id, 120000);
expect(finalState.status).toBe("idle");
expect(finalState.lastError).toBeUndefined();
// Verify stream events show the agent processed the message
const queue = ctx.client.getMessageQueue();
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.waitForAgentIdle(agent.id, 120000);
expect(finalState.status).toBe("idle");
expect(finalState.lastError).toBeUndefined();
// Verify turn completed
const queue = ctx.client.getMessageQueue();
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
);
});
});
@@ -0,0 +1,82 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync, readdirSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "../test-utils/index.js";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60000);
describe("listProviderModels", () => {
test(
"returns model list for Codex provider",
async () => {
// List models for Codex provider - no agent needed
const result = await ctx.client.listProviderModels("codex");
// Verify response structure
expect(result.provider).toBe("codex");
expect(result.error).toBeNull();
expect(result.fetchedAt).toBeTruthy();
// Should return at least one model
expect(result.models).toBeTruthy();
expect(result.models.length).toBeGreaterThan(0);
// Verify model structure
const model = result.models[0];
expect(model.provider).toBe("codex");
expect(model.id).toBeTruthy();
expect(model.label).toBeTruthy();
},
60000 // 1 minute timeout
);
test(
"returns model list for Claude provider",
async () => {
// List models for Claude provider - no agent needed
const result = await ctx.client.listProviderModels("claude");
// Verify response structure
expect(result.provider).toBe("claude");
expect(result.error).toBeNull();
expect(result.fetchedAt).toBeTruthy();
// Should return at least one model
expect(result.models).toBeTruthy();
expect(result.models.length).toBeGreaterThan(0);
// Verify model structure
const model = result.models[0];
expect(model.provider).toBe("claude");
expect(model.id).toBeTruthy();
expect(model.label).toBeTruthy();
},
60000 // 1 minute timeout
);
});
});
@@ -0,0 +1,143 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync, readdirSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "../test-utils/index.js";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60000);
describe("multi-agent orchestration", () => {
test(
"parent agent creates child agent via agent-control MCP",
async () => {
const cwd = tmpCwd();
const childCwd = tmpCwd();
// Create parent Codex agent
const parent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
title: "Parent Agent",
});
expect(parent.id).toBeTruthy();
expect(parent.status).toBe("idle");
// Clear message queue before sending prompt
ctx.client.clearMessageQueue();
// Prompt the parent to create a child agent using agent-control MCP
const prompt = [
`Use the create_agent tool from the agent-control MCP server to create a new codex agent.`,
`Set the cwd to: ${childCwd}`,
`Set the title to: Child Agent`,
`Set agentType to: codex`,
`Do NOT set an initialPrompt - just create the agent.`,
`After creating the agent, reply with "CREATED" followed by the child's agentId.`,
].join(" ");
await ctx.client.sendMessage(parent.id, prompt);
// Wait for parent to complete
const afterCreate = await ctx.client.waitForAgentIdle(
parent.id,
120000
);
expect(afterCreate.status).toBe("idle");
// Verify timeline contains a tool call to create_agent
const queue = ctx.client.getMessageQueue();
const timelineItems: AgentTimelineItem[] = [];
for (const m of queue) {
if (
m.type === "agent_stream" &&
m.payload.agentId === parent.id &&
m.payload.event.type === "timeline"
) {
timelineItems.push(m.payload.event.item);
}
}
// Should have a tool call to create_agent from agent-control
const hasCreateAgentCall = timelineItems.some(
(item) =>
item.type === "tool_call" &&
item.name === "agent-control.create_agent"
);
expect(hasCreateAgentCall).toBe(true);
// Now verify we can see both agents via session_state
// Send a list_persisted_agents_request to trigger session_state refresh
// Or we can check the queue for agent_state messages
const agentStateMessages = queue.filter(
(m) => m.type === "agent_state"
);
// Extract unique agent IDs from state messages
const agentIds = new Set<string>();
for (const m of agentStateMessages) {
if (m.type === "agent_state") {
agentIds.add(m.payload.id);
}
}
// Should have at least 2 agents (parent + child)
expect(agentIds.size).toBeGreaterThanOrEqual(2);
expect(agentIds.has(parent.id)).toBe(true);
// Get the child agent ID from the tool call output
const createAgentCall = timelineItems.find(
(item) =>
item.type === "tool_call" &&
item.name === "agent-control.create_agent"
);
let childAgentId: string | null = null;
if (
createAgentCall &&
createAgentCall.type === "tool_call" &&
createAgentCall.output
) {
// The output contains the agentId
const output = createAgentCall.output as { agentId?: string };
if (output.agentId) {
childAgentId = output.agentId;
}
}
// Verify we found the child agent ID
expect(childAgentId).toBeTruthy();
expect(agentIds.has(childAgentId!)).toBe(true);
// Cleanup
rmSync(cwd, { recursive: true, force: true });
rmSync(childCwd, { recursive: true, force: true });
},
300000 // 5 minute timeout for multi-agent E2E
);
});
});
@@ -0,0 +1,190 @@
import { describe, test, expect, beforeEach, afterEach, beforeAll, afterAll } from "vitest";
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync, readdirSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import {
createDaemonTestContext,
useTempClaudeConfigDir,
type DaemonTestContext,
} from "../test-utils/index.js";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60000);
describe("permission flow: Claude", () => {
// Use isolated Claude config to ensure permission prompts are triggered
// (user's real config may have allow rules that auto-approve commands)
let restoreClaudeConfig: () => void;
beforeAll(() => {
restoreClaudeConfig = useTempClaudeConfigDir();
});
afterAll(() => {
restoreClaudeConfig();
});
test(
"approves permission and executes command",
async () => {
const cwd = tmpCwd();
const filePath = path.join(cwd, "permission.txt");
writeFileSync(filePath, "ok", "utf8");
// Create Claude agent with sandbox config that requires permission for bash
const agent = await ctx.client.createAgent({
provider: "claude",
cwd,
title: "Claude Permission Test",
modeId: "default",
extra: {
claude: {
sandbox: { enabled: true, autoAllowBashIfSandboxed: false },
},
},
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// Clear message queue before sending prompt
ctx.client.clearMessageQueue();
// Send a prompt that requires permission (rm command triggers approval)
const prompt = [
"You must call the Bash command tool with the exact command `rm -f permission.txt`.",
"After approval, run it and reply DONE.",
"Do not respond before the command finishes.",
].join(" ");
await ctx.client.sendMessage(agent.id, prompt);
// Wait for permission request
const permission = await ctx.client.waitForPermission(agent.id, 60000);
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.waitForAgentIdle(agent.id, 120000);
expect(finalState.status).toBe("idle");
// Verify the file was deleted
expect(existsSync(filePath)).toBe(false);
// Verify permission_resolved event was received
const queue = ctx.client.getMessageQueue();
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 });
},
180000
);
test(
"denies permission and prevents execution",
async () => {
const cwd = tmpCwd();
const filePath = path.join(cwd, "permission.txt");
writeFileSync(filePath, "ok", "utf8");
// Create Claude agent with sandbox config that requires permission for bash
const agent = await ctx.client.createAgent({
provider: "claude",
cwd,
title: "Claude Permission Deny Test",
modeId: "default",
extra: {
claude: {
sandbox: { enabled: true, autoAllowBashIfSandboxed: false },
},
},
});
expect(agent.id).toBeTruthy();
// Clear message queue before sending prompt
ctx.client.clearMessageQueue();
// Send a prompt that requires permission
const prompt = [
"You must call the Bash command tool with the exact command `rm -f permission.txt`.",
"If approval is denied, reply DENIED and stop.",
"Do not respond before the command finishes or the denial is confirmed.",
].join(" ");
await ctx.client.sendMessage(agent.id, prompt);
// Wait for permission request
const permission = await ctx.client.waitForPermission(agent.id, 60000);
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.waitForAgentIdle(agent.id, 120000);
expect(finalState.status).toBe("idle");
// Verify the file was NOT deleted
expect(existsSync(filePath)).toBe(true);
// Verify permission_resolved event was received with deny
const queue = ctx.client.getMessageQueue();
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 });
},
180000
);
});
});
@@ -0,0 +1,378 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync, readdirSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "../test-utils/index.js";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
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, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
title: "Codex Permission Test",
modeId: "auto",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// Clear message queue before sending prompt
ctx.client.clearMessageQueue();
// 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 permission = await ctx.client.waitForPermission(agent.id, 60000);
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.waitForAgentIdle(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 = ctx.client.getMessageQueue();
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 });
},
180000
);
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, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
title: "Codex Permission Deny Test",
modeId: "auto",
});
expect(agent.id).toBeTruthy();
// Clear message queue before sending prompt
ctx.client.clearMessageQueue();
// 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 permission = await ctx.client.waitForPermission(agent.id, 60000);
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.waitForAgentIdle(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 = ctx.client.getMessageQueue();
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 });
},
180000
);
// TODO: Fix this test - there's a race condition causing agent not found errors
test.skip(
"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, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
title: "Codex Interrupt Test",
modeId: "full-access",
});
expect(agent.id).toBeTruthy();
expect(agent.currentModeId).toBe("full-access");
// Send first message to start the agent
ctx.client.clearMessageQueue();
await ctx.client.sendMessage(agent.id, "List the files in the current directory.");
// Wait for agent to start running
await ctx.client.waitForAgentState(
agent.id,
(snapshot) => snapshot.status === "running",
10000
);
// 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.waitForAgentState(
agent.id,
(snapshot) =>
snapshot.status === "idle" || snapshot.status === "error",
30000
);
// Now send another message - this should work
ctx.client.clearMessageQueue();
await ctx.client.sendMessage(
agent.id,
"Say 'hello from interrupt test' and nothing else."
);
// Wait for this to complete
await ctx.client.waitForAgentIdle(agent.id, 60000);
// Verify we got an assistant message in the queue
const queue = ctx.client.getMessageQueue();
const hasAssistantMessage = queue.some(
(m) =>
m.type === "agent_stream" &&
m.agentId === agent.id &&
m.event?.type === "timeline" &&
m.event?.item?.type === "assistant_message"
);
expect(hasAssistantMessage).toBe(true);
rmSync(cwd, { recursive: true, force: true });
},
120000
);
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, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
title: "Codex Abort Stop Test",
modeId: "full-access",
});
expect(agent.id).toBeTruthy();
// Ask Codex to sleep 15 seconds then write a file
ctx.client.clearMessageQueue();
await ctx.client.sendMessage(
agent.id,
"Run this bash command: sleep 15 && echo 'abort-test-completed' > abort-test-file.txt"
);
// Wait 3 seconds for the command to start
await new Promise((r) => setTimeout(r, 3000));
// Cancel/interrupt the agent
await ctx.client.cancelAgent(agent.id);
// Wait 10 seconds - if abort works, file should NOT be written
// (sleep would have completed at 15s if not interrupted)
await new Promise((r) => setTimeout(r, 10000));
// Assert the file was NOT created (proving Codex actually stopped)
const fileExists = existsSync(filePath);
expect(fileExists).toBe(false);
rmSync(cwd, { recursive: true, force: true });
},
30000 // 30 second timeout
);
// TODO: Fix this test - there's a race condition causing timeout errors
test.skip(
"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, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
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.
ctx.client.clearMessageQueue();
const writePrompt =
"Write a file called mode-switch-test.txt with the content 'first'";
await ctx.client.sendMessage(agent.id, writePrompt);
// Step 3: Wait for permission request
const permission = await ctx.client.waitForPermission(agent.id, 60000);
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.waitForAgentIdle(agent.id, 120000);
// Verify file was NOT created after denial
expect(existsSync(filePath)).toBe(false);
// Step 5: Switch to "full-access" mode
ctx.client.clearMessageQueue();
const modeStartPosition = ctx.client.getMessageQueue().length;
await ctx.client.setAgentMode(agent.id, "full-access");
// Wait for mode change to be reflected in agent_state
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 = ctx.client.getMessageQueue();
for (let i = modeStartPosition; i < queue.length; i++) {
const msg = queue[i];
if (
msg.type === "agent_state" &&
msg.payload.id === agent.id &&
msg.payload.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.
ctx.client.clearMessageQueue();
const writePrompt2 =
"Write a file called mode-switch-test.txt with the content 'success'";
await ctx.client.sendMessage(agent.id, writePrompt2);
// Wait for agent to complete
await ctx.client.waitForAgentIdle(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("success");
// Verify no permission was requested in this second attempt
const queue = ctx.client.getMessageQueue();
const hasPermissionRequest = queue.some(
(m) =>
m.type === "agent_permission_request" &&
m.agentId === agent.id
);
expect(hasPermissionRequest).toBe(false);
rmSync(cwd, { recursive: true, force: true });
},
240000
);
});
});
@@ -0,0 +1,760 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync, readdirSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "../test-utils/index.js";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60000);
describe("persistence flow", () => {
test(
"persists and resumes Codex agent with conversation history",
async () => {
const cwd = tmpCwd();
// Create agent
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
title: "Persistence Test Agent",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
const originalAgentId = agent.id;
// Send a message to generate some state
await ctx.client.sendMessage(
agent.id,
"Say 'state saved' and nothing else"
);
// Wait for agent to complete
const afterMessage = await ctx.client.waitForAgentIdle(agent.id, 120000);
expect(afterMessage.status).toBe("idle");
// Get the timeline to verify we have messages
const queue = ctx.client.getMessageQueue();
const timelineItems: AgentTimelineItem[] = [];
for (const m of queue) {
if (
m.type === "agent_stream" &&
m.payload.agentId === agent.id &&
m.payload.event.type === "timeline"
) {
timelineItems.push(m.payload.event.item);
}
}
// Should have at least one assistant message
const assistantMessages = timelineItems.filter(
(item) => item.type === "assistant_message"
);
expect(assistantMessages.length).toBeGreaterThan(0);
// Get persistence handle from agent state
expect(afterMessage.persistence).toBeTruthy();
const persistence = afterMessage.persistence;
expect(persistence?.provider).toBe("codex");
expect(persistence?.sessionId).toBeTruthy();
// Codex uses conversationId in metadata for resumption
expect(
(persistence?.metadata as { conversationId?: string })?.conversationId
).toBeTruthy();
// Delete the agent from the current session
await ctx.client.deleteAgent(agent.id);
// Verify agent deletion was confirmed (agent_deleted event was received)
const queue2 = ctx.client.getMessageQueue();
const hasDeletedEvent = queue2.some(
(m) =>
m.type === "agent_deleted" && m.payload.agentId === originalAgentId
);
expect(hasDeletedEvent).toBe(true);
// Resume the agent using the persistence handle directly
// NOTE: Codex MCP doesn't implement listPersistedAgents() because conversations
// are stored internally by codex CLI. We resume by passing the persistence handle.
const resumedAgent = await ctx.client.resumeAgent(persistence!);
expect(resumedAgent.id).toBeTruthy();
expect(resumedAgent.status).toBe("idle");
expect(resumedAgent.cwd).toBe(cwd);
expect(resumedAgent.provider).toBe("codex");
// Note: AgentSnapshotPayload doesn't include timeline directly.
// Timeline events are streamed separately. The key verification
// is that we can send a follow-up message and the agent responds
// with awareness of the previous conversation context.
// Verify we can send another message to the resumed agent
// This proves the conversation context is preserved
ctx.client.clearMessageQueue();
await ctx.client.sendMessage(
resumedAgent.id,
"What did I ask you to say earlier?"
);
const afterResume = await ctx.client.waitForAgentIdle(
resumedAgent.id,
120000
);
expect(afterResume.status).toBe("idle");
// Verify we got a response
const resumeQueue = ctx.client.getMessageQueue();
const hasResumeResponse = resumeQueue.some((m) => {
if (m.type !== "agent_stream" || m.payload.event.type !== "timeline") {
return false;
}
return m.payload.event.item.type === "assistant_message";
});
expect(hasResumeResponse).toBe(true);
// Cleanup
await ctx.client.deleteAgent(resumedAgent.id);
rmSync(cwd, { recursive: true, force: true });
},
300000 // 5 minute timeout for persistence E2E
);
});
describe("timeline persistence across daemon restart", () => {
test(
"Codex agent timeline survives daemon restart",
async () => {
const cwd = tmpCwd();
// === Phase 1: Create agent and generate timeline items ===
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
title: "Restart Timeline Test Agent",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// Send a message to generate timeline items
await ctx.client.sendMessage(
agent.id,
"Say 'timeline test' and nothing else"
);
// Wait for agent to complete
const afterMessage = await ctx.client.waitForAgentIdle(agent.id, 120000);
expect(afterMessage.status).toBe("idle");
// Verify we have timeline items before restart
const queue = ctx.client.getMessageQueue();
const timelineItems: AgentTimelineItem[] = [];
for (const m of queue) {
if (
m.type === "agent_stream" &&
m.payload.agentId === agent.id &&
m.payload.event.type === "timeline"
) {
timelineItems.push(m.payload.event.item);
}
}
// Should have at least one assistant message
const assistantMessages = timelineItems.filter(
(item) => item.type === "assistant_message"
);
expect(assistantMessages.length).toBeGreaterThan(0);
// Get persistence handle
const persistence = afterMessage.persistence;
expect(persistence).toBeTruthy();
expect(persistence?.provider).toBe("codex");
expect(persistence?.sessionId).toBeTruthy();
// Record how many timeline items we had
const preRestartTimelineCount = timelineItems.length;
expect(preRestartTimelineCount).toBeGreaterThan(0);
// === Phase 2: Restart daemon ===
// Cleanup old context (stops daemon)
await ctx.cleanup();
// Create new daemon context (starts fresh daemon)
ctx = await createDaemonTestContext();
// === Phase 3: Resume agent and verify timeline is preserved ===
const resumedAgent = await ctx.client.resumeAgent(persistence!);
expect(resumedAgent.id).toBeTruthy();
expect(resumedAgent.status).toBe("idle");
expect(resumedAgent.provider).toBe("codex");
// Wait a moment for history events to be emitted
await new Promise((resolve) => setTimeout(resolve, 1000));
// Get timeline items that were emitted after resume
// Timeline items from history are sent as agent_stream_snapshot, not individual agent_stream
const resumeQueue = ctx.client.getMessageQueue();
const resumedTimelineItems: AgentTimelineItem[] = [];
// First check for agent_stream_snapshot (batched history)
for (const m of resumeQueue) {
if (
m.type === "agent_stream_snapshot" &&
(m.payload as { agentId: string }).agentId === resumedAgent.id
) {
const events = (m.payload as { events: Array<{ event: { type: string; item?: AgentTimelineItem } }> }).events;
for (const e of events) {
if (e.event.type === "timeline" && e.event.item) {
resumedTimelineItems.push(e.event.item);
}
}
}
}
// Also check for individual agent_stream events (in case they were sent that way)
for (const m of resumeQueue) {
if (
m.type === "agent_stream" &&
m.payload.agentId === resumedAgent.id &&
m.payload.event.type === "timeline"
) {
resumedTimelineItems.push(m.payload.event.item);
}
}
// CRITICAL ASSERTION: Timeline should NOT be empty after daemon restart
// This verifies that persisted history is loaded from disk (rollout files)
// when SESSION_HISTORY is empty due to daemon restart
expect(resumedTimelineItems.length).toBeGreaterThan(0);
// Verify the original messages are present
const resumedAssistant = resumedTimelineItems.filter(
(item) => item.type === "assistant_message"
);
expect(resumedAssistant.length).toBeGreaterThan(0);
// Cleanup
await ctx.client.deleteAgent(resumedAgent.id);
rmSync(cwd, { recursive: true, force: true });
},
300000 // 5 minute timeout for restart test
);
});
describe("external Codex session import", () => {
test(
"imports external codex exec session and preserves conversation context",
async () => {
const cwd = tmpCwd();
const { execSync, spawn } = await import("child_process");
// Initialize git repo (Codex requires a trusted directory)
execSync("git init", { cwd, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
writeFileSync(path.join(cwd, "README.md"), "# Test\n");
execSync("git add .", { cwd, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
cwd,
stdio: "pipe",
});
// === STEP 1: Run external codex exec with a memorable number ===
// We use spawn so we can send input and capture output
// Use a memorable number that we'll ask about later
const magicNumber = 69;
const prompt = `Remember this number: ${magicNumber}. Just confirm you've remembered it and reply with a single short sentence.`;
// Spawn codex exec and capture stdout to get session ID
let sessionId: string | null = null;
let codexOutput = "";
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
codexProcess.kill();
reject(new Error("Codex exec timeout after 120 seconds"));
}, 120000);
const codexProcess = spawn("codex", ["exec", prompt], {
cwd,
env: {
...process.env,
// Ensure full-access mode to avoid permission prompts
CODEX_SANDBOX: "danger-full-access",
CODEX_APPROVAL_POLICY: "never",
},
stdio: ["pipe", "pipe", "pipe"],
});
codexProcess.stdout.on("data", (data: Buffer) => {
const text = data.toString();
codexOutput += text;
// Look for session ID in output
// Format: "session id: 019b5ea3-25d5-7202-bd06-6b1db405e505"
const match = text.match(/session id:\s*([0-9a-f-]+)/i);
if (match) {
sessionId = match[1];
}
});
codexProcess.stderr.on("data", (data: Buffer) => {
const text = data.toString();
// Session ID might also appear in stderr
const match = text.match(/session id:\s*([0-9a-f-]+)/i);
if (match && !sessionId) {
sessionId = match[1];
}
});
codexProcess.on("close", (code) => {
clearTimeout(timeout);
if (code === 0 || sessionId) {
resolve();
} else {
reject(new Error(`codex exec failed with code ${code}`));
}
});
codexProcess.on("error", (err) => {
clearTimeout(timeout);
reject(err);
});
});
// Verify we captured the session ID
expect(sessionId).not.toBeNull();
expect(sessionId).toMatch(/^[0-9a-f-]+$/);
// === STEP 2: Find the transcript file for this session ===
// Codex stores transcripts at ~/.codex/sessions/**/*-{sessionId}.jsonl
const codexHome = process.env.CODEX_HOME || path.join(tmpdir(), "..", "..", "home", process.env.USER || "", ".codex");
const actualCodexHome = path.join(process.env.HOME || "", ".codex");
const sessionsDir = path.join(actualCodexHome, "sessions");
// Find the transcript file
function findTranscriptFile(dir: string, targetSessionId: string): string | null {
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const found = findTranscriptFile(fullPath, targetSessionId);
if (found) return found;
} else if (entry.isFile() && fullPath.endsWith(`-${targetSessionId}.jsonl`)) {
return fullPath;
}
}
} catch {
// Directory doesn't exist or not readable
}
return null;
}
const transcriptFile = findTranscriptFile(sessionsDir, sessionId!);
// Verify transcript file exists
expect(transcriptFile).not.toBeNull();
expect(existsSync(transcriptFile!)).toBe(true);
// Read and verify transcript has content
const transcriptContent = readFileSync(transcriptFile!, "utf-8");
expect(transcriptContent.length).toBeGreaterThan(0);
// === STEP 3: Import this session into the daemon ===
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
title: "External Session Import Test",
modeId: "full-access",
extra: {
codex: {
experimental_resume: transcriptFile,
},
},
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// === STEP 4: Ask the daemon agent about the number ===
ctx.client.clearMessageQueue();
await ctx.client.sendMessage(
agent.id,
"What was the number I asked you to remember earlier? Reply with just the number and nothing else."
);
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
expect(finalState.status).toBe("idle");
expect(finalState.lastError).toBeUndefined();
// === STEP 5: Verify the response contains the magic number ===
const queue = ctx.client.getMessageQueue();
const assistantMessages: string[] = [];
for (const m of queue) {
if (
m.type === "agent_stream" &&
m.payload.agentId === agent.id &&
m.payload.event.type === "timeline"
) {
const item = m.payload.event.item;
if (item.type === "assistant_message" && item.text) {
assistantMessages.push(item.text);
}
}
}
const fullResponse = assistantMessages.join("");
// CRITICAL ASSERTION: The response should contain the magic number
// This proves the daemon agent successfully loaded the external session's context
expect(fullResponse).toContain(String(magicNumber));
// === STEP 6: Verify history was present when importing ===
// Check that we received history timeline items (from the external session)
// These would be in agent_stream_snapshot if history was replayed
// Note: The experimental_resume feature loads history directly into Codex,
// so we may not see individual history items streamed back. The key test
// is that the agent can recall the number, which proves context was preserved.
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
},
300000 // 5 minute timeout for external session test
);
test(
"fails gracefully when resuming non-existent external session",
async () => {
const cwd = tmpCwd();
// Try to create agent with a non-existent transcript file
const fakeTranscriptFile = path.join(cwd, "non-existent-session.jsonl");
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
title: "Non-existent Session Test",
modeId: "full-access",
extra: {
codex: {
experimental_resume: fakeTranscriptFile,
},
},
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// The agent should still work, just without the resume context
// Send a simple message to verify it's functional
ctx.client.clearMessageQueue();
await ctx.client.sendMessage(agent.id, "Say 'hello' and nothing else.");
const finalState = await ctx.client.waitForAgentIdle(agent.id, 60000);
// Agent should complete (possibly with Codex warning about missing file,
// but should still function)
expect(["idle", "error"]).toContain(finalState.status);
// Verify we got some response
const queue = ctx.client.getMessageQueue();
const hasResponse = 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(hasResponse).toBe(true);
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
},
120000 // 2 minute timeout
);
});
describe("Claude persisted agent import", () => {
test("filters internal warmup entries from persisted Claude history", async () => {
const previousClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
const claudeConfigDir = mkdtempSync(path.join(tmpdir(), "claude-config-"));
process.env.CLAUDE_CONFIG_DIR = claudeConfigDir;
const projectDir = path.join(claudeConfigDir, "projects", "test-project");
mkdirSync(projectDir, { recursive: true });
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const cwd = "/tmp/claude-import-test";
const historyLines = [
JSON.stringify({
type: "user",
isSidechain: true,
sessionId,
cwd,
message: { role: "user", content: "Warmup" },
}),
JSON.stringify({
type: "user",
sessionId,
cwd,
message: { role: "user", content: "Real task prompt" },
}),
];
const historyPath = path.join(projectDir, `${sessionId}.jsonl`);
writeFileSync(historyPath, `${historyLines.join("\n")}\n`, "utf8");
try {
const persisted =
await ctx.daemon.daemon.agentManager.listPersistedAgents();
const claudeEntry = persisted.find((item) => item.sessionId === sessionId);
expect(claudeEntry).toBeTruthy();
expect(claudeEntry?.title).toBe("Real task prompt");
const timelineTexts = (claudeEntry?.timeline ?? [])
.map((item) => {
if (item.type === "user_message" || item.type === "assistant_message") {
return item.text;
}
return null;
})
.filter((text): text is string => typeof text === "string");
expect(timelineTexts).toContain("Real task prompt");
expect(timelineTexts).not.toContain("Warmup");
} finally {
if (previousClaudeConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = previousClaudeConfigDir;
}
rmSync(claudeConfigDir, { recursive: true, force: true });
}
});
});
describe("Codex persisted agent import", () => {
test("lists Codex sessions from rollout files", async () => {
const previousCodexSessionDir = process.env.CODEX_SESSION_DIR;
const codexSessionDir = mkdtempSync(path.join(tmpdir(), "codex-session-"));
process.env.CODEX_SESSION_DIR = codexSessionDir;
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const cwd = "/tmp/codex-import-test";
const now = new Date().toISOString();
const rolloutPath = path.join(codexSessionDir, `rollout-${sessionId}.jsonl`);
const lines = [
JSON.stringify({
timestamp: now,
type: "session_meta",
payload: { id: sessionId, timestamp: now, cwd },
}),
JSON.stringify({
timestamp: now,
type: "response_item",
payload: {
type: "message",
role: "user",
content: [{ type: "input_text", text: "Import this Codex session" }],
},
}),
JSON.stringify({
timestamp: now,
type: "response_item",
payload: {
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "Codex import ready" }],
},
}),
];
writeFileSync(rolloutPath, `${lines.join("\n")}\n`, "utf8");
try {
const persisted =
await ctx.daemon.daemon.agentManager.listPersistedAgents();
const codexEntry = persisted.find(
(item) => item.provider === "codex" && item.sessionId === sessionId
);
expect(codexEntry).toBeTruthy();
expect(codexEntry?.cwd).toBe(cwd);
const timelineTexts = (codexEntry?.timeline ?? [])
.map((item) => {
if (item.type === "user_message" || item.type === "assistant_message") {
return item.text;
}
return null;
})
.filter((text): text is string => typeof text === "string");
expect(timelineTexts).toContain("Import this Codex session");
expect(timelineTexts).toContain("Codex import ready");
} finally {
if (previousCodexSessionDir === undefined) {
delete process.env.CODEX_SESSION_DIR;
} else {
process.env.CODEX_SESSION_DIR = previousCodexSessionDir;
}
rmSync(codexSessionDir, { recursive: true, force: true });
}
});
});
describe("Claude session persistence", () => {
test(
"persists and resumes Claude agent with conversation history (remembers number)",
async () => {
const cwd = tmpCwd();
// Use a memorable number that we'll ask about later
const magicNumber = 69;
// === STEP 1: Create Claude agent and have it remember a number ===
const agent = await ctx.client.createAgent({
provider: "claude",
cwd,
title: "Claude Persistence Test",
modeId: "bypassPermissions",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
expect(agent.provider).toBe("claude");
// === STEP 2: Ask it to remember the number ===
await ctx.client.sendMessage(
agent.id,
`Remember this number: ${magicNumber}. Just confirm you've remembered it and reply with a single short sentence.`
);
const afterRemember = await ctx.client.waitForAgentIdle(agent.id, 120000);
expect(afterRemember.status).toBe("idle");
expect(afterRemember.lastError).toBeUndefined();
// Verify we got a confirmation response
let queue = ctx.client.getMessageQueue();
const confirmationMessages: string[] = [];
for (const m of queue) {
if (
m.type === "agent_stream" &&
m.payload.agentId === agent.id &&
m.payload.event.type === "timeline"
) {
const item = m.payload.event.item;
if (item.type === "assistant_message" && item.text) {
confirmationMessages.push(item.text);
}
}
}
const confirmationResponse = confirmationMessages.join("");
expect(confirmationResponse.length).toBeGreaterThan(0);
// === STEP 3: Get persistence handle and delete agent ===
expect(afterRemember.persistence).toBeTruthy();
const persistence = afterRemember.persistence;
expect(persistence?.provider).toBe("claude");
expect(persistence?.sessionId).toBeTruthy();
// Delete the agent
await ctx.client.deleteAgent(agent.id);
// === STEP 4: Resume the agent using persistence handle ===
ctx.client.clearMessageQueue();
const resumedAgent = await ctx.client.resumeAgent(persistence!);
expect(resumedAgent.id).toBeTruthy();
expect(resumedAgent.status).toBe("idle");
expect(resumedAgent.provider).toBe("claude");
// === STEP 5: Ask about the remembered number ===
ctx.client.clearMessageQueue();
await ctx.client.sendMessage(
resumedAgent.id,
"What was the number I asked you to remember earlier? Reply with just the number and nothing else."
);
const afterRecall = await ctx.client.waitForAgentIdle(resumedAgent.id, 120000);
expect(afterRecall.status).toBe("idle");
expect(afterRecall.lastError).toBeUndefined();
// === STEP 6: Verify the response contains the magic number ===
queue = ctx.client.getMessageQueue();
const recallMessages: string[] = [];
for (const m of queue) {
if (
m.type === "agent_stream" &&
m.payload.agentId === resumedAgent.id &&
m.payload.event.type === "timeline"
) {
const item = m.payload.event.item;
if (item.type === "assistant_message" && item.text) {
recallMessages.push(item.text);
}
}
}
const fullResponse = recallMessages.join("");
// CRITICAL ASSERTION: The response should contain the magic number
// This proves the Claude agent successfully preserved conversation context
expect(fullResponse).toContain(String(magicNumber));
// Cleanup
await ctx.client.deleteAgent(resumedAgent.id);
rmSync(cwd, { recursive: true, force: true });
},
300000 // 5 minute timeout for multiple Claude API calls
);
});
});
@@ -0,0 +1,50 @@
import { beforeAll, afterAll } from "vitest";
import { mkdtempSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "../test-utils/index.js";
import { agentConfigs } from "./agent-configs.js";
// Re-export for backward compatibility - prefer using agentConfigs instead
export const CODEX_TEST_MODEL = agentConfigs.codex.model;
export const CODEX_TEST_REASONING_EFFORT = agentConfigs.codex.reasoningEffort;
// Re-export agent configs
export {
agentConfigs,
getFullAccessConfig,
getAskModeConfig,
allProviders,
type AgentProvider,
type AgentTestConfig,
} from "./agent-configs.js";
export function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
// Shared daemon context for all e2e tests
let sharedCtx: DaemonTestContext | null = null;
export function getTestContext(): DaemonTestContext {
if (!sharedCtx) {
throw new Error("Test context not initialized. Did you call setupDaemonE2E()?");
}
return sharedCtx;
}
export function setupDaemonE2E(): void {
beforeAll(async () => {
sharedCtx = await createDaemonTestContext();
}, 30000);
afterAll(async () => {
if (sharedCtx) {
await sharedCtx.cleanup();
sharedCtx = null;
}
}, 60000);
}
@@ -0,0 +1,773 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync, readdirSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "../test-utils/index.js";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60000);
describe("Claude agent streaming text integrity", () => {
test(
"assistant_message text is coherent and not garbled during streaming",
async () => {
const cwd = tmpCwd();
// Create Claude agent
const agent = await ctx.client.createAgent({
provider: "claude",
cwd,
title: "Streaming Text Integrity Test",
});
expect(agent.id).toBeTruthy();
expect(agent.provider).toBe("claude");
// Send a message that should produce a longer, coherent response
// The agent should complete a sentence with proper grammar
await ctx.client.sendMessage(
agent.id,
"Please complete this sentence with exactly one more sentence: 'The quick brown fox jumps over the lazy dog.' Write a follow-up sentence about what the fox did next. Reply with just the two sentences, nothing else."
);
// Wait for agent to complete
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
expect(finalState.status).toBe("idle");
expect(finalState.lastError).toBeUndefined();
// Collect all assistant_message timeline events in order
const queue = ctx.client.getMessageQueue();
const assistantChunks: string[] = [];
for (const m of queue) {
if (
m.type === "agent_stream" &&
m.payload.agentId === agent.id &&
m.payload.event.type === "timeline"
) {
const item = m.payload.event.item;
if (item.type === "assistant_message" && item.text) {
assistantChunks.push(item.text);
}
}
}
// Should have received at least one assistant message chunk
expect(assistantChunks.length).toBeGreaterThan(0);
// Concatenate all chunks to form the complete response
const fullResponse = assistantChunks.join("");
// CRITICAL ASSERTION 1: Response should not be empty
expect(fullResponse.length).toBeGreaterThan(0);
// CRITICAL ASSERTION 2: Response should contain coherent English text
// Check for garbled patterns from the bug report:
// - "wasd" (random characters in word)
// - "passesd" (double letters incorrectly)
// - words cut off mid-word and merged with other words
// Check that the response contains real words and proper sentence structure
// A garbled response like "The agent wasd. my an a newdex" would fail these checks
// The response should contain "fox" or "dog" since we asked about them
const lowerResponse = fullResponse.toLowerCase();
const containsRelevantContent =
lowerResponse.includes("fox") ||
lowerResponse.includes("dog") ||
lowerResponse.includes("quick") ||
lowerResponse.includes("brown") ||
lowerResponse.includes("lazy") ||
lowerResponse.includes("jumps");
expect(containsRelevantContent).toBe(true);
// CRITICAL ASSERTION 3: Check for garbled text patterns
// These patterns indicate text corruption during streaming
const garbledPatterns = [
/\w{2,}d\.\s+[a-z]+\s+[a-z]+\s+[a-z]+d/, // "wasd. my an a ...d" pattern
/\b\w+sd\b/, // words ending in "sd" like "passesd", "wasd"
/\b\w+d\s+\w+d\s+\w+d\b/, // multiple consecutive words ending in "d"
/[a-z]{10,}/, // very long "words" that are actually merged text
];
for (const pattern of garbledPatterns) {
const match = fullResponse.match(pattern);
if (match) {
}
// Note: We log but don't fail on these patterns as they might occur in valid text
// The real test is whether the response is semantically coherent
}
// CRITICAL ASSERTION 4: Each individual chunk should not start/end mid-word in a corrupted way
// Check that we don't have incomplete Unicode or obviously broken text
for (let i = 0; i < assistantChunks.length; i++) {
const chunk = assistantChunks[i];
// Chunks should not contain null bytes or other corruption
expect(chunk).not.toMatch(/\x00/);
// Chunks should be valid UTF-8 (no replacement characters unless intentional)
expect(chunk).not.toMatch(/\uFFFD/);
}
// CRITICAL ASSERTION 5: Verify sentence completeness
// The response should contain at least one period (sentence ending)
expect(fullResponse).toMatch(/\./);
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
},
180000 // 3 minute timeout for Claude API call
);
});
describe("Claude agent streaming text integrity - long running", () => {
test(
"streaming chunks remain coherent after multiple back-and-forth messages",
async () => {
const cwd = tmpCwd();
// Create Claude agent with bypassPermissions mode to avoid permission prompts
const agent = await ctx.client.createAgent({
provider: "claude",
cwd,
title: "Long Running Streaming Test",
modeId: "bypassPermissions",
});
expect(agent.id).toBeTruthy();
expect(agent.provider).toBe("claude");
// === MESSAGE 1: Establish conversation context ===
await ctx.client.sendMessage(
agent.id,
"Remember the number 42. Just confirm you remember it."
);
let state = await ctx.client.waitForAgentIdle(agent.id, 120000);
expect(state.status).toBe("idle");
expect(state.lastError).toBeUndefined();
// === MESSAGE 2: Build on conversation ===
ctx.client.clearMessageQueue(); // Clear queue to isolate message 2
await ctx.client.sendMessage(
agent.id,
"Now remember the word 'elephant'. Just confirm you remember both the number and the word."
);
state = await ctx.client.waitForAgentIdle(agent.id, 120000);
expect(state.status).toBe("idle");
expect(state.lastError).toBeUndefined();
// === MESSAGE 3: This is where the bug was reported to manifest ===
// Clear queue so we can capture streaming chunks for message 3 only
ctx.client.clearMessageQueue();
await ctx.client.sendMessage(
agent.id,
"Write a complete sentence using both the number (42) and the word (elephant) you remembered. The sentence should be grammatically correct English."
);
state = await ctx.client.waitForAgentIdle(agent.id, 120000);
expect(state.status).toBe("idle");
expect(state.lastError).toBeUndefined();
// Collect all assistant_message timeline events from message 3
const queue = ctx.client.getMessageQueue();
const assistantChunks: string[] = [];
for (const m of queue) {
if (
m.type === "agent_stream" &&
m.payload.agentId === agent.id &&
m.payload.event.type === "timeline"
) {
const item = m.payload.event.item;
if (item.type === "assistant_message" && item.text) {
assistantChunks.push(item.text);
}
}
}
// Should have received at least one assistant message chunk
expect(assistantChunks.length).toBeGreaterThan(0);
// Concatenate all chunks to form the complete response
const fullResponse = assistantChunks.join("");
for (let i = 0; i < assistantChunks.length; i++) {
}
// CRITICAL ASSERTION 1: Response should contain expected content
const lowerResponse = fullResponse.toLowerCase();
const containsNumber = lowerResponse.includes("42");
const containsWord = lowerResponse.includes("elephant");
expect(containsNumber).toBe(true);
expect(containsWord).toBe(true);
// CRITICAL ASSERTION 2: Check for garbled text patterns
// These patterns indicate chunks being incorrectly split/merged
// Pattern from bug report: "acheck error" instead of "a typecheck error" (missing "type")
// Check consecutive chunks for suspicious splits
for (let i = 0; i < assistantChunks.length - 1; i++) {
const current = assistantChunks[i];
const next = assistantChunks[i + 1];
// Look for a chunk ending with a letter followed by a chunk starting with
// a letter that wouldn't make sense together (e.g., "a" + "check")
const currentEndsWithLetter = /[a-zA-Z]$/.test(current);
const nextStartsWithLetter = /^[a-zA-Z]/.test(next);
if (currentEndsWithLetter && nextStartsWithLetter) {
// This could be legitimate (word continues) or a split issue
// Log for debugging
}
}
// CRITICAL ASSERTION 3: Check for UTF-8 corruption
for (const chunk of assistantChunks) {
expect(chunk).not.toMatch(/\x00/); // No null bytes
expect(chunk).not.toMatch(/\uFFFD/); // No replacement characters
}
// CRITICAL ASSERTION 4: The full response should be valid English
// Check that the response has proper word spacing
const wordPattern = /\b[a-zA-Z]+\b/g;
const words = fullResponse.match(wordPattern) || [];
expect(words.length).toBeGreaterThan(3); // Should have multiple words
// Check for improperly concatenated words (very long "words" that shouldn't exist)
const suspiciouslyLongWords = words.filter(w => w.length > 20);
if (suspiciouslyLongWords.length > 0) {
}
// Allow some technical words but flag excessive length
expect(suspiciouslyLongWords.filter(w => w.length > 30).length).toBe(0);
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
},
300000 // 5 minute timeout for multiple Claude API calls
);
});
describe("Claude agent overlapping stream() calls race condition", () => {
test(
"interrupting message should produce coherent text without garbling from race condition",
async () => {
const cwd = tmpCwd();
// Create Claude agent with bypassPermissions mode
const agent = await ctx.client.createAgent({
provider: "claude",
cwd,
title: "Overlapping Streams Race Condition Test",
modeId: "bypassPermissions",
});
expect(agent.id).toBeTruthy();
expect(agent.provider).toBe("claude");
// === MESSAGE 1: Start a long-running prompt that will be interrupted ===
// Record queue position BEFORE message 1 to find the cutoff point later
const msg1StartPosition = ctx.client.getMessageQueue().length;
// Use sendMessage but don't await waitForAgentIdle - let it run
await ctx.client.sendMessage(
agent.id,
"Write a very detailed 500 word essay about the history of computing, starting from the earliest mechanical computers through modern quantum computing. Include specific dates, inventors, and technological milestones."
);
// Wait a short time for Turn 1 to start streaming (but not finish)
// This ensures forwardPromptEvents() is actively running
await new Promise(resolve => setTimeout(resolve, 2000));
// === MESSAGE 2: Immediately send another message to interrupt ===
// This triggers the race condition where Turn 2's forwardPromptEvents
// resets streamedAssistantTextThisTurn while Turn 1 is still reading it
// Record queue position BEFORE message 2 to find message 2 chunks
const msg2StartPosition = ctx.client.getMessageQueue().length;
await ctx.client.sendMessage(
agent.id,
"Stop. Just say exactly: 'Hello world from interrupted message'"
);
// Wait for Turn 2 to complete - use a manual polling approach
// We need to wait for: running -> idle (after msg2's user_message)
const maxWaitMs = 120000;
const pollIntervalMs = 500;
const startTime = Date.now();
let lastState: AgentSnapshotPayload | null = null;
while (Date.now() - startTime < maxWaitMs) {
// Check agent_state messages in the queue
const queue = ctx.client.getMessageQueue();
// Look for pattern: user_message (msg2) -> ... -> running -> ... -> idle/error
let sawMsg2UserMessage = false;
let sawRunningAfterMsg2 = false;
let sawIdleAfterRunning = false;
for (let i = msg2StartPosition; i < queue.length; i++) {
const msg = queue[i];
if (
msg.type === "agent_stream" &&
msg.payload.agentId === agent.id &&
msg.payload.event.type === "timeline"
) {
const item = msg.payload.event.item;
if (item.type === "user_message" && (item.text as string)?.includes("Hello world")) {
sawMsg2UserMessage = true;
}
}
if (msg.type === "agent_state" && msg.payload.id === agent.id) {
if (sawMsg2UserMessage && msg.payload.status === "running") {
sawRunningAfterMsg2 = true;
}
if (sawRunningAfterMsg2 && (msg.payload.status === "idle" || msg.payload.status === "error")) {
sawIdleAfterRunning = true;
lastState = msg.payload;
}
}
}
if (sawIdleAfterRunning) {
break;
}
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
}
expect(lastState).not.toBeNull();
expect(lastState!.status).toBe("idle");
expect(lastState!.lastError).toBeUndefined();
// Collect assistant_message chunks from message 2 only (after msg2StartPosition)
const queue = ctx.client.getMessageQueue();
const assistantChunks: string[] = [];
// Debug: dump all events from queue
for (let i = 0; i < queue.length; i++) {
const m = queue[i];
if (m.type === "agent_stream" && m.payload.agentId === agent.id) {
const event = m.payload.event;
if (event.type === "timeline") {
const item = event.item;
} else {
}
} else if (m.type === "agent_state" && m.payload.id === agent.id) {
}
}
// Find the user_message for message 2 to mark the boundary
let foundMsg2UserMessage = false;
for (let i = msg2StartPosition; i < queue.length; i++) {
const m = queue[i];
// Look for our user message to mark the start of message 2 context
if (
m.type === "agent_stream" &&
m.payload.agentId === agent.id &&
m.payload.event.type === "timeline"
) {
const item = m.payload.event.item;
if (item.type === "user_message" && (item.text as string)?.includes("Hello world")) {
foundMsg2UserMessage = true;
}
// Collect assistant messages after we found the user message
if (foundMsg2UserMessage && item.type === "assistant_message" && item.text) {
assistantChunks.push(item.text);
}
}
}
for (let i = 0; i < assistantChunks.length; i++) {
}
// Should have received at least one assistant message chunk
expect(assistantChunks.length).toBeGreaterThan(0);
// Concatenate all chunks
const fullResponse = assistantChunks.join("");
// CRITICAL ASSERTION: Response should contain coherent text
// If there's a race condition with flag corruption, we might get:
// - Missing chunks (suppression applied incorrectly)
// - Duplicate chunks (suppression NOT applied when it should be)
// - Garbled/mixed text from Turn 1 and Turn 2
// Check for basic coherence - should have recognizable words
const wordPattern = /\b[a-zA-Z]+\b/g;
const words = fullResponse.match(wordPattern) || [];
expect(words.length).toBeGreaterThan(0);
// Check for UTF-8 corruption
for (const chunk of assistantChunks) {
expect(chunk).not.toMatch(/\x00/); // No null bytes
expect(chunk).not.toMatch(/\uFFFD/); // No replacement characters
}
// Check for suspiciously long "words" that indicate missing spaces/garbling
const suspiciouslyLongWords = words.filter(w => w.length > 30);
if (suspiciouslyLongWords.length > 0) {
}
expect(suspiciouslyLongWords.length).toBe(0);
// CRITICAL: Verify the response is for message 2, not message 1
// Message 2 asked for "Hello world from interrupted message"
// If we see extensive content about "history of computing", that's race condition corruption
const lowerResponse = fullResponse.toLowerCase();
const containsComputingContent =
lowerResponse.includes("mechanical") ||
lowerResponse.includes("quantum") ||
lowerResponse.includes("inventor") ||
lowerResponse.includes("eniac") ||
lowerResponse.includes("babbage");
if (containsComputingContent) {
}
// This MUST fail if we got message 1's response instead of message 2's
expect(containsComputingContent).toBe(false);
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
},
180000 // 3 minute timeout
);
test(
"sending message while agent is executing a tool call",
async () => {
const cwd = tmpCwd();
const testStart = Date.now();
const log = (msg: string) => console.error(`[TEST t=${Date.now() - testStart}ms] ${msg}`);
log(`creating agent`);
const agent = await ctx.client.createAgent({
provider: "claude",
cwd,
title: "Interrupt During Tool Call Test",
modeId: "bypassPermissions",
});
log(`agent created: ${agent.id}`);
expect(agent.id).toBeTruthy();
const startPosition = ctx.client.getMessageQueue().length;
// Subscribe to all messages for logging
const unsubscribe = ctx.client.on((event) => {
if (event.type === "agent_state" && event.agentId === agent.id) {
log(`[EVENT] agent_state: status=${event.payload.status}`);
} else if (event.type === "agent_stream" && event.agentId === agent.id) {
const evt = event.event;
if (evt.type === "timeline") {
const item = evt.item as any;
const desc = item.type === "tool_call"
? `tool_call:${item.name} result=${item.result ? 'yes' : 'no'}`
: `${item.type}${item.text ? `:${item.text.substring(0, 30)}` : ''}`;
log(`[EVENT] timeline: ${desc}`);
} else {
log(`[EVENT] stream: ${evt.type}`);
}
}
});
// Send a message that triggers a long-running tool call
log(`sending first message`);
await ctx.client.sendMessage(agent.id, "Execute this exact bash command and wait for it to complete: sleep 30");
log(`first message sent`);
// Wait for agent to actually start executing a tool call (not just "running" status)
let sawToolCall = false;
let toolCallStartTime: number | null = null;
const waitStartTime = Date.now();
while (Date.now() - waitStartTime < 15000) {
const queue = ctx.client.getMessageQueue();
for (let i = startPosition; i < queue.length; i++) {
const msg = queue[i];
if (
msg.type === "agent_stream" &&
msg.payload.agentId === agent.id &&
msg.payload.event.type === "timeline"
) {
const itemType = msg.payload.event.item.type;
const itemName = (msg.payload.event.item as any).name || "";
if (itemType === "tool_call" && itemName.toLowerCase().includes("bash")) {
const toolCall = msg.payload.event.item as any;
// We want to see the tool call START (no result yet)
if (!toolCall.result) {
log(`saw bash tool call START: ${itemName}`);
sawToolCall = true;
toolCallStartTime = Date.now();
break;
}
}
}
}
if (sawToolCall) break;
await new Promise(r => setTimeout(r, 100));
}
log(`sawToolCall=${sawToolCall}`);
expect(sawToolCall).toBe(true);
// Give the tool call time to actually start executing
log(`waiting 2s for tool to be mid-execution...`);
await new Promise(r => setTimeout(r, 2000));
// Check if agent is still running
const queueBeforeStop = ctx.client.getMessageQueue();
let lastStatus = "unknown";
for (const msg of queueBeforeStop) {
if (msg.type === "agent_state" && msg.payload.id === agent.id) {
lastStatus = msg.payload.status;
}
}
log(`agent status before Stop: ${lastStatus}`);
// Send an interrupting message
const stopSentAt = Date.now();
log(`sending Stop message`);
await ctx.client.sendMessage(agent.id, "Stop");
log(`Stop message sent`);
// Agent should go idle within 5 seconds after interrupt
log(`waiting for idle...`);
const finalState = await ctx.client.waitForAgentIdle(agent.id, 10000);
const idleReceivedAt = Date.now();
log(`got idle state: ${finalState.status} (took ${idleReceivedAt - stopSentAt}ms after Stop)`);
expect(finalState.status).toBe("idle");
// Cleanup
unsubscribe();
log(`deleting agent`);
await ctx.client.deleteAgent(agent.id);
log(`agent deleted`);
rmSync(cwd, { recursive: true, force: true });
},
30000
);
test(
"rapid sequential messages to same agent",
async () => {
const cwd = tmpCwd();
const agent = await ctx.client.createAgent({
provider: "claude",
cwd,
title: "Rapid Sequential Messages Test",
modeId: "bypassPermissions",
});
expect(agent.id).toBeTruthy();
// Send 3 messages in rapid succession without waiting
const startPosition = ctx.client.getMessageQueue().length;
const msg1 = "Say: MESSAGE_ONE";
const msg2 = "Say: MESSAGE_TWO";
const msg3 = "Say: MESSAGE_THREE";
// Helper to count user messages in queue
const countUserMessages = (): number => {
let count = 0;
const queue = ctx.client.getMessageQueue();
for (let i = startPosition; i < queue.length; i++) {
const m = queue[i];
if (
m.type === "agent_stream" &&
m.payload.agentId === agent.id &&
m.payload.event.type === "timeline" &&
m.payload.event.item.type === "user_message"
) {
count++;
}
}
return count;
};
// Send all 3 messages
await ctx.client.sendMessage(agent.id, msg1);
await new Promise(r => setTimeout(r, 100));
await ctx.client.sendMessage(agent.id, msg2);
await new Promise(r => setTimeout(r, 100));
await ctx.client.sendMessage(agent.id, msg3);
// First, wait until all 3 user messages are recorded
const userMsgWaitStart = Date.now();
while (Date.now() - userMsgWaitStart < 30000) {
const count = countUserMessages();
if (count >= 3) break;
await new Promise(r => setTimeout(r, 200));
}
const finalUserMsgCount = countUserMessages();
// Now wait for agent to become idle after processing all 3 messages
// We need to see at least 3 running transitions to know all messages were processed
const waitStart = Date.now();
let runningCount = 0;
let finalState: AgentSnapshotPayload | null = null;
while (Date.now() - waitStart < 120000) {
const queue = ctx.client.getMessageQueue();
let currentRunningCount = 0;
let lastState: AgentSnapshotPayload | null = null;
for (let i = startPosition; i < queue.length; i++) {
const m = queue[i];
if (m.type === "agent_state" && m.payload.id === agent.id) {
if (m.payload.status === "running") currentRunningCount++;
lastState = m.payload;
}
}
runningCount = currentRunningCount;
// Need to have seen at least 3 running states (one per message) and end up idle
if (runningCount >= 3 && lastState && lastState.status === "idle") {
finalState = lastState;
break;
}
await new Promise(r => setTimeout(r, 500));
}
// Analyze what happened
const queue = ctx.client.getMessageQueue();
const userMessages: string[] = [];
const assistantMessages: string[] = [];
const stateChanges: string[] = [];
for (let i = startPosition; i < queue.length; i++) {
const m = queue[i];
if (m.type === "agent_state" && m.payload.id === agent.id) {
stateChanges.push(m.payload.status);
}
if (
m.type === "agent_stream" &&
m.payload.agentId === agent.id &&
m.payload.event.type === "timeline"
) {
const item = m.payload.event.item;
if (item.type === "user_message") {
userMessages.push((item.text as string) || "");
}
if (item.type === "assistant_message" && item.text) {
assistantMessages.push(item.text);
}
}
}
// All 3 user messages should have been recorded
expect(userMessages.length).toBe(3);
// Agent should have responded (at least to the final message)
expect(assistantMessages.length).toBeGreaterThan(0);
// The last turn should have completed successfully (not failed due to race condition)
const lastResponse = assistantMessages[assistantMessages.length - 1]?.toLowerCase() || "";
// Verify we got a proper turn_completed event (not turn_failed from race condition)
const turnCompletedEvents = queue.filter((m, i) =>
i >= startPosition &&
m.type === "agent_stream" &&
m.payload.agentId === agent.id &&
m.payload.event.type === "turn_completed"
);
const turnFailedEvents = queue.filter((m, i) =>
i >= startPosition &&
m.type === "agent_stream" &&
m.payload.agentId === agent.id &&
m.payload.event.type === "turn_failed"
);
// The final turn should complete successfully, not fail
expect(turnCompletedEvents.length).toBeGreaterThan(0);
// We might have some turn_failed from interrupted turns, but the last turn should succeed
expect(turnFailedEvents.length).toBe(0);
// The response should mention "three" since that was the last message sent
const combinedResponse = assistantMessages.join(" ").toLowerCase();
expect(combinedResponse).toContain("three");
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
},
180000
);
});
});
@@ -0,0 +1,314 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync, readdirSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "../test-utils/index.js";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
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
): AgentTimelineItem[] {
const byCallId = new Map<string, AgentTimelineItem>();
const noCallId: AgentTimelineItem[] = [];
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: AgentTimelineItem): void {
if (tc.type !== "tool_call") return;
}
test(
"Claude agent: Read tool",
async () => {
const cwd = tmpCwd();
const agent = await ctx.client.createAgent({
provider: "claude",
cwd,
title: "Claude Read Test",
modeId: "bypassPermissions",
});
ctx.client.clearMessageQueue();
await ctx.client.sendMessage(
agent.id,
"Read the file /etc/hosts and tell me how many lines it has. Be brief."
);
await ctx.client.waitForAgentIdle(agent.id, 120000);
const toolCalls = extractToolCalls(ctx.client.getMessageQueue(), agent.id);
expect(toolCalls.length).toBeGreaterThan(0);
for (const tc of toolCalls) {
logToolCall("CLAUDE_READ", tc);
}
const readCall = toolCalls.find((tc) => tc.type === "tool_call" && tc.name === "Read");
expect(readCall).toBeDefined();
expect(readCall?.name).toBe("Read");
expect(readCall?.input).toBeDefined();
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
},
180000
);
test(
"Claude agent: Bash tool",
async () => {
const cwd = tmpCwd();
const agent = await ctx.client.createAgent({
provider: "claude",
cwd,
title: "Claude Bash Test",
modeId: "bypassPermissions",
});
ctx.client.clearMessageQueue();
await ctx.client.sendMessage(
agent.id,
"Run `echo hello` and tell me what it outputs. Be brief."
);
await ctx.client.waitForAgentIdle(agent.id, 120000);
const toolCalls = extractToolCalls(ctx.client.getMessageQueue(), agent.id);
expect(toolCalls.length).toBeGreaterThan(0);
for (const tc of toolCalls) {
logToolCall("CLAUDE_BASH", tc);
}
const bashCall = toolCalls.find((tc) => tc.type === "tool_call" && tc.name === "Bash");
expect(bashCall).toBeDefined();
expect(bashCall?.name).toBe("Bash");
expect(bashCall?.input).toBeDefined();
// Command text should be in input.command
const bashInput = bashCall?.input as { command?: string } | undefined;
expect(bashInput?.command).toContain("echo");
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
},
180000
);
test(
"Claude agent: Edit tool",
async () => {
const cwd = tmpCwd();
const testFile = path.join(cwd, "test.txt");
writeFileSync(testFile, "hello world\n");
const agent = await ctx.client.createAgent({
provider: "claude",
cwd,
title: "Claude Edit Test",
modeId: "bypassPermissions",
});
ctx.client.clearMessageQueue();
await ctx.client.sendMessage(
agent.id,
`Edit the file ${testFile} and change "hello" to "goodbye". Be brief.`
);
await ctx.client.waitForAgentIdle(agent.id, 120000);
const toolCalls = extractToolCalls(ctx.client.getMessageQueue(), agent.id);
expect(toolCalls.length).toBeGreaterThan(0);
for (const tc of toolCalls) {
logToolCall("CLAUDE_EDIT", tc);
}
const editCall = toolCalls.find((tc) => tc.type === "tool_call" && tc.name === "Edit");
expect(editCall).toBeDefined();
expect(editCall?.input).toBeDefined();
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
},
180000
);
test(
"Codex agent: shell command",
async () => {
const cwd = tmpCwd();
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
title: "Codex Shell Test",
modeId: "full-access",
});
ctx.client.clearMessageQueue();
await ctx.client.sendMessage(
agent.id,
"Run `echo hello` and tell me what it outputs. Be brief."
);
await ctx.client.waitForAgentIdle(agent.id, 120000);
const toolCalls = extractToolCalls(ctx.client.getMessageQueue(), agent.id);
expect(toolCalls.length).toBeGreaterThan(0);
for (const tc of toolCalls) {
logToolCall("CODEX_SHELL", tc);
}
const shellCall = toolCalls.find((tc) => tc.type === "tool_call" && tc.name === "shell");
expect(shellCall).toBeDefined();
expect(shellCall?.name).toBe("shell");
expect(shellCall?.input).toBeDefined();
// Command text should be in input.command
const shellInput = shellCall?.input as { command?: string } | undefined;
expect(shellInput?.command).toContain("echo");
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
},
180000
);
test(
"Codex agent: file read",
async () => {
const cwd = tmpCwd();
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
title: "Codex Read Test",
modeId: "full-access",
});
ctx.client.clearMessageQueue();
await ctx.client.sendMessage(
agent.id,
"Read the file /etc/hosts and tell me how many lines it has. Be brief."
);
await ctx.client.waitForAgentIdle(agent.id, 120000);
const toolCalls = extractToolCalls(ctx.client.getMessageQueue(), agent.id);
expect(toolCalls.length).toBeGreaterThan(0);
for (const tc of toolCalls) {
logToolCall("CODEX_READ", tc);
}
// 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");
}
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
},
180000
);
test(
"Codex agent: file edit",
async () => {
const cwd = tmpCwd();
const testFile = path.join(cwd, "test.txt");
writeFileSync(testFile, "hello world\n");
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
title: "Codex Edit Test",
modeId: "full-access",
});
ctx.client.clearMessageQueue();
await ctx.client.sendMessage(
agent.id,
`Edit the file ${testFile} and change "hello" to "goodbye". Be brief.`
);
await ctx.client.waitForAgentIdle(agent.id, 120000);
const toolCalls = extractToolCalls(ctx.client.getMessageQueue(), 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);
rmSync(cwd, { recursive: true, force: true });
},
180000
);
});
});
@@ -0,0 +1,123 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "../test-utils/index.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "wait-for-idle-e2e-"));
}
/**
* Tests for waitForAgentIdle edge cases.
* Uses haiku for speed. 10s timeout per operation - if slower, it's a bug.
*/
describe("waitForAgentIdle edge cases", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 15000);
test("waitForAgentIdle immediately after sendMessage", async () => {
const cwd = tmpCwd();
const agent = await ctx.client.createAgent({
provider: "claude",
model: "haiku",
cwd,
title: "Immediate Wait Test",
modeId: "bypassPermissions",
});
// This was the original bug: waitForAgentIdle returned old idle states
await ctx.client.sendMessage(agent.id, "Say 'hello'");
const state = await ctx.client.waitForAgentIdle(agent.id, 10000);
expect(state.status).toBe("idle");
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
}, 15000);
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 waitForAgentIdle
// finds the idle AFTER the last running state
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.waitForAgentIdle(agent.id, 10000);
expect(state.status).toBe("idle");
// Verify all 3 messages were recorded
const queue = ctx.client.getMessageQueue();
const userMessages = queue.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 });
}, 15000);
test("two agents: waitForAgentIdle 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
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.waitForAgentIdle(agent2.id, 10000);
expect(state2.status).toBe("idle");
expect(state2.id).toBe(agent2.id);
const state1 = await ctx.client.waitForAgentIdle(agent1.id, 10000);
expect(state1.status).toBe("idle");
expect(state1.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 });
}, 25000);
});
File diff suppressed because it is too large Load Diff
+23 -13
View File
@@ -364,25 +364,21 @@ export class Session {
}
if (snapshot.lifecycle !== "running" && !snapshot.pendingRun) {
console.error(`[INTERRUPT:${agentId.substring(0, 8)}] not running, skipping. lifecycle=${snapshot.lifecycle} pendingRun=${!!snapshot.pendingRun}`);
return;
}
this.sessionLogger.info(
{ agentId },
`Interrupting active run for agent ${agentId}`
);
console.error(`[INTERRUPT:${agentId.substring(0, 8)}] starting interrupt. lifecycle=${snapshot.lifecycle} pendingRun=${!!snapshot.pendingRun}`);
try {
const t0 = Date.now();
const cancelled = await this.agentManager.cancelAgentRun(agentId);
console.error(`[INTERRUPT] cancelAgentRun returned cancelled=${cancelled} in ${Date.now() - t0}ms`);
if (!cancelled) {
this.sessionLogger.warn(
{ agentId },
`Agent ${agentId} reported running but no active run was cancelled`
);
console.error(`[INTERRUPT] WARNING: reported running but no active run was cancelled`);
}
// Wait for the agent to become idle after cancellation
// cancelAgentRun only initiates cancellation; doesn't wait for generator to terminate
const maxWaitMs = 5000;
const pollIntervalMs = 50;
const startTime = Date.now();
@@ -392,15 +388,17 @@ export class Session {
throw new Error(`Agent ${agentId} not found during cancellation wait`);
}
if (current.lifecycle !== "running" && !current.pendingRun) {
console.error(`[INTERRUPT] agent became idle after ${Date.now() - startTime}ms`);
break;
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
const finalState = this.agentManager.getAgent(agentId);
if (finalState?.lifecycle === "running" || finalState?.pendingRun) {
console.error(`[INTERRUPT] WARNING: agent still running after 5s wait! lifecycle=${finalState?.lifecycle} pendingRun=${!!finalState?.pendingRun}`);
}
} catch (error) {
this.sessionLogger.error(
{ err: error, agentId },
`Failed to interrupt agent ${agentId}`
);
console.error(`[INTERRUPT] ERROR:`, error);
throw error;
}
}
@@ -409,6 +407,7 @@ export class Session {
* Start streaming an agent run and forward results via the websocket broadcast
*/
private startAgentStream(agentId: string, prompt: AgentPromptInput): void {
console.error(`[SESSION:startAgentStream] starting for agent ${agentId.substring(0, 8)}, prompt="${typeof prompt === 'string' ? prompt.substring(0, 40) : 'object'}"`);
this.sessionLogger.info(
{ agentId },
`Starting agent stream for ${agentId}`
@@ -1007,6 +1006,7 @@ export class Session {
agentId: string,
requestId: string
): Promise<void> {
console.error(`[DELETE_AGENT] handleDeleteAgentRequest called for ${agentId} requestId=${requestId}`, new Error().stack);
this.sessionLogger.info(
{ agentId },
`Deleting agent ${agentId} from registry`
@@ -1060,14 +1060,18 @@ export class Session {
messageId?: string,
images?: Array<{ data: string; mimeType: string }>
): Promise<void> {
console.error(`[SESSION:handleSendAgentMessage] ENTERED agentId=${agentId.substring(0, 8)} text="${text.substring(0, 40)}"`);
this.sessionLogger.info(
{ agentId, textPreview: text.substring(0, 50), imageCount: images?.length ?? 0 },
`Sending text to agent ${agentId}${images && images.length > 0 ? ` with ${images.length} image attachment(s)` : ''}`
);
try {
console.error(`[SESSION:handleSendAgentMessage] calling ensureAgentLoaded...`);
await this.ensureAgentLoaded(agentId);
console.error(`[SESSION:handleSendAgentMessage] ensureAgentLoaded done`);
} catch (error) {
console.error(`[SESSION:handleSendAgentMessage] ensureAgentLoaded FAILED:`, error);
this.handleAgentRunError(
agentId,
error,
@@ -1077,8 +1081,12 @@ export class Session {
}
try {
const snapshotBeforeInterrupt = this.agentManager.getAgent(agentId);
console.error(`[SESSION:handleSendAgentMessage] before interrupt: lifecycle=${snapshotBeforeInterrupt?.lifecycle} pendingRun=${!!snapshotBeforeInterrupt?.pendingRun}`);
await this.interruptAgentIfRunning(agentId);
console.error(`[SESSION:handleSendAgentMessage] interrupt done`);
} catch (error) {
console.error(`[SESSION:handleSendAgentMessage] interrupt FAILED:`, error);
this.handleAgentRunError(
agentId,
error,
@@ -1088,6 +1096,7 @@ export class Session {
}
const prompt = this.buildAgentPrompt(text, images);
console.error(`[SESSION:handleSendAgentMessage] calling recordUserMessage...`);
try {
this.agentManager.recordUserMessage(agentId, text, { messageId });
@@ -1098,6 +1107,7 @@ export class Session {
);
}
console.error(`[SESSION:handleSendAgentMessage] calling startAgentStream...`);
this.startAgentStream(agentId, prompt);
}
+322 -99
View File
@@ -4,6 +4,10 @@ import { spawnSync } from "node:child_process";
import { appendFileSync, existsSync, openSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { FileTaskStore } from "./task-store.js";
import {
computeExecutionOrder,
buildSortedChildrenMap,
} from "./execution-order.js";
import type { AgentType, ModelName, Task } from "./types.js";
const TASKS_DIR = resolve(process.cwd(), ".tasks");
@@ -65,11 +69,6 @@ Examples:
# See completed work
task closed --scope abc123
# Run agent loop on an epic
task run abc123
task run abc123 --agent codex
task run --watch
Body vs Notes:
The BODY is the task's markdown document - edit it while grooming/defining the task.
NOTES are timestamped entries added during implementation to document progress.
@@ -77,6 +76,89 @@ Body vs Notes:
- While defining a task: edit the body with "task update <id> --body ..."
- While implementing: add notes with "task note <id> ..."
- When done: add a final note explaining what was done, then close
---
The Run Command (task run):
The run command executes an agent loop that works until acceptance criteria are met.
This can run for hours, days, or weeks for large epics.
Basic usage:
task run <scope-id> # Run without planner (task must be well-defined)
task run <scope-id> --plan # Run with planner (for larger epics)
Without --plan: The scoped task runs in a worker/judge loop. The worker implements,
the judge verifies. If not done, it loops. No breakdown happens - the task must be
atomic and well-defined from the start.
With --plan: The planner first breaks down the scope into subtasks, then workers
execute leaf tasks while the planner can reorganize as needed. Use this for epics
where you define WHAT you want but not HOW to achieve it.
When replanning happens:
- At the start (initial breakdown)
- When a task fails 5+ times (something is wrong with the approach)
- When you add a steering note (task steer <scope-id> "new direction")
Monitoring a running loop:
task plan <scope-id> # See execution timeline and progress
task show <task-id> # Check specific task details
tail -f task-run.*.log # Follow the live log
Steering mid-run:
task steer <scope-id> "focus on X first"
task steer <scope-id> "skip Y, it's not needed"
task steer <scope-id> "run e2e tests every iteration"
Steering triggers an immediate replan. Use it to course-correct without stopping.
Writing Good Acceptance Criteria:
Acceptance criteria determine when a task is DONE. They must be specific enough
that agents cannot "cheat" by taking shortcuts.
Bad (agents can game these):
- "tests pass" → could delete tests, skip them, or weaken assertions
- "code works" → subjective, unverifiable
- "feature complete" → vague
Good (specific and non-gameable):
- "npm test exits 0 with 0 failures and 0 skipped"
- "test count >= 150 (no deleting tests)"
- "coverage does not decrease from baseline"
- "npm run typecheck exits 0"
- "GET /api/users returns 200 with JSON array"
Philosophy: Specify the floor (minimum requirements) without capping the ceiling.
"Zero failures, zero skipped" is strict but doesn't prevent adding more tests.
For test-related tasks, consider:
--accept "npm test exits 0"
--accept "no test files deleted"
--accept "no .skip or test.todo added"
--accept "no assertions removed or weakened"
Acceptance Criteria vs Body Guidance:
Acceptance criteria = required OUTPUT (must-haves, verified at the end)
Body guidance = HOW to approach the work (instructions for the planner)
Acceptance criteria examples (verifiable end-state):
- "PR created with description and test plan"
- "Branch name follows convention: feat/<name>"
- "npm run typecheck exits 0"
Body guidance examples (process instructions):
- "Split work by module"
- "Commit after each subtask"
- "Run typecheck after each change"
The planner reads body guidance and propagates it into subtask acceptance criteria.
For example, if the body says "commit after each chunk of work", the planner adds
"git status shows clean working tree" to each subtask's criteria.
This makes process requirements verifiable at each step, not just at the end.
`
);
@@ -233,68 +315,106 @@ program
});
program
.command("tree <id>")
.description("Show task hierarchy with dependencies")
.action(async (id) => {
const root = await store.get(id);
if (!root) {
process.stderr.write(`Task not found: ${id}\n`);
process.exit(1);
}
// Build a map of all tasks for dependency lookups
.command("plan [scope]")
.alias("tree")
.description("Show execution timeline (tree view by default)")
.option("--flat", "Show flat list instead of tree")
.option("-d, --depth <n>", "Limit tree depth (0 = root only)")
.action(async (scopeId: string | undefined, opts) => {
const { timeline, orderMap, blocked } = await computeExecutionOrder(
store,
scopeId
);
const allTasks = await store.list();
const taskMap = new Map(allTasks.map((t) => [t.id, t]));
const maxDepth = opts.depth !== undefined ? parseInt(opts.depth, 10) : Infinity;
// Print a task line with optional dependency info
const printTask = (task: Task, prefix: string, connector: string) => {
const assignee = task.assignee ? ` @${task.assignee}` : "";
const priority = task.priority !== undefined ? ` !${task.priority}` : "";
process.stdout.write(
`${prefix}${connector}${task.id} [${task.status}] ${task.title}${priority}${assignee}\n`
);
// Print dependencies on next line with arrow
if (task.deps.length > 0) {
const depNames = task.deps
.map((depId) => {
const dep = taskMap.get(depId);
return dep ? `${dep.title} (${depId})` : depId;
})
.join(", ");
const depPrefix = prefix + (connector === "└── " ? " " : "│ ");
process.stdout.write(`${depPrefix}→ depends on: ${depNames}\n`);
}
const formatDeps = (task: Task): string => {
if (task.deps.length === 0) return "";
return ` (deps: ${task.deps.join(", ")})`;
};
// Print root task
const rootAssignee = root.assignee ? ` @${root.assignee}` : "";
const rootPriority = root.priority !== undefined ? ` !${root.priority}` : "";
process.stdout.write(`${root.id} [${root.status}] ${root.title}${rootPriority}${rootAssignee}\n`);
if (root.deps.length > 0) {
const depNames = root.deps
.map((depId) => {
const dep = taskMap.get(depId);
return dep ? `${dep.title} (${depId})` : depId;
})
.join(", ");
process.stdout.write(`→ depends on: ${depNames}\n`);
if (opts.flat) {
// Flat list view
const printTask = (t: Task, idx: number) => {
const priority = t.priority !== undefined ? `!${t.priority} ` : "";
const assignee = t.assignee ? ` @${t.assignee}` : "";
const num = String(idx + 1).padStart(3, " ");
const mark = t.status === "done" ? "✓" : " ";
const deps = formatDeps(t);
process.stdout.write(
`${mark}${num}. ${priority}${t.id} ${t.title}${assignee}${deps}\n`
);
};
for (let i = 0; i < timeline.length; i++) {
printTask(timeline[i], i);
}
} else {
// Tree view (default)
if (!scopeId) {
process.stderr.write("Tree view requires a scope ID\n");
process.exit(1);
}
const root = await store.get(scopeId);
if (!root) {
process.stderr.write(`Task not found: ${scopeId}\n`);
process.exit(1);
}
const sortedChildrenMap = buildSortedChildrenMap(allTasks, orderMap);
const printTask = (
task: Task,
prefix: string,
connector: string,
depth: number
) => {
const assignee = task.assignee ? ` @${task.assignee}` : "";
const priority =
task.priority !== undefined ? ` !${task.priority}` : "";
const mark = task.status === "done" ? "✓ " : " ";
const deps = formatDeps(task);
process.stdout.write(
`${mark}${prefix}${connector}${task.id} [${task.status}] ${task.title}${priority}${assignee}${deps}\n`
);
};
// Print root
const rootAssignee = root.assignee ? ` @${root.assignee}` : "";
const rootPriority =
root.priority !== undefined ? ` !${root.priority}` : "";
const rootMark = root.status === "done" ? "✓ " : " ";
const rootDeps = formatDeps(root);
process.stdout.write(
`${rootMark}${root.id} [${root.status}] ${root.title}${rootPriority}${rootAssignee}${rootDeps}\n`
);
// Recursively print children in execution order
const printChildren = (parentId: string, prefix: string, depth: number) => {
if (depth >= maxDepth) return;
const children = sortedChildrenMap.get(parentId) ?? [];
for (let i = 0; i < children.length; i++) {
const child = children[i];
const isLast = i === children.length - 1;
const connector = isLast ? "└── " : "├── ";
const childPrefix = prefix + (isLast ? " " : "│ ");
printTask(child, prefix, connector, depth);
printChildren(child.id, childPrefix, depth + 1);
}
};
printChildren(scopeId, "", 0);
}
// Recursively print children (hierarchy)
const printChildren = async (parentId: string, prefix: string) => {
const children = await store.getChildren(parentId);
for (let i = 0; i < children.length; i++) {
const child = children[i];
const isLast = i === children.length - 1;
const connector = isLast ? "└── " : "├── ";
const childPrefix = prefix + (isLast ? " " : "│ ");
printTask(child, prefix, connector);
await printChildren(child.id, childPrefix);
}
};
await printChildren(id, "");
if (blocked.size > 0) {
process.stdout.write(`\n... +${blocked.size} blocked/unreachable\n`);
}
if (timeline.length === 0) {
process.stdout.write("No tasks.\n");
}
});
program
@@ -590,7 +710,7 @@ ${task.body || "(no body)"}
${acceptanceCriteriaText}${notesText}`;
}
function makePlannerPrompt(task: Task, context: string, iteration: number): string {
function makePlannerPrompt(task: Task, context: string, reason: string): string {
return `${context}
---
@@ -599,7 +719,9 @@ You are a PLANNER agent. You ORCHESTRATE work - you do NOT do the work yourself.
Your job is to organize tasks so that WORKER agents can accomplish the top-level acceptance criteria.
Current iteration: ${iteration}
## Why you were called
${reason}
## Planning vs Working
@@ -617,29 +739,71 @@ As a planner you must NOT:
Take the task descriptions AS GIVEN and create well-organized subtasks for workers to investigate and implement.
## Task CLI Commands
- \`task show ${task.id}\` - view task details with parent context
- \`task list\` - see all tasks
- \`task tree ${task.id}\` - see full task hierarchy
- \`task children ${task.id}\` - list subtasks
- \`task ready\` - see what's ready to work on
- \`task create "title" --parent <id> --body "..." --accept "criterion"\` - create task (any level)
- \`task update <id> --title "..." --body "..." --accept "criterion"\` - update task
### Understanding the plan
- \`task plan ${task.id}\` - **START HERE**: shows full execution timeline (done ✓ then pending in order)
- \`task tree ${task.id}\` - see task hierarchy with dependencies (parent → children)
- \`task show <id>\` - view task details, body, acceptance criteria, and notes
- \`task children <id>\` - list direct children of a task
- \`task ready --scope ${task.id}\` - see what's ready to work on right now
- \`task blocked --scope ${task.id}\` - see what's blocked and why
### Navigating the task tree
The tree has two relationships:
1. **Parent/children** (hierarchy): \`--parent <id>\` groups tasks. Use \`task tree\` and \`task children\`.
2. **Dependencies** (ordering): \`--deps <id>\` blocks execution. Shown as \`← [dep1, dep2]\` in tree output.
To understand what workers will actually implement, find the **leaf tasks** (tasks with no children).
Parent tasks are just containers - workers execute leaf tasks.
**Always navigate down to leaves:**
\`\`\`
task tree ${task.id} # see full structure
task children <parent> # drill into a branch
task show <leaf> # read the actual work item
\`\`\`
When planning, ensure leaf tasks have:
- Clear acceptance criteria (verifiable by judge)
- Correct dependencies (won't run until deps are done)
- Appropriate priority (controls order among siblings)
### Modifying tasks
- \`task create "title" --parent <id> --body "..." --accept "criterion" -p <priority>\` - create task
- \`task update <id> --title "..." --body "..." -p <priority>\` - update task properties
- \`task update <id> --accept "criterion"\` - add acceptance criterion
- \`task update <id> --clear-acceptance -a "new criterion"\` - replace all acceptance criteria
- \`task delete <id>\` - remove a task permanently
- \`task note <id> "content"\` - add planning notes
- \`task delete <id>\` - remove a task that's no longer needed
- \`task fail <id>\` - mark task as failed if catastrophically stuck
### Priority (-p flag)
Priority controls execution order. **Lower number = higher priority** (executed first).
- \`-p 0\` - critical/urgent, do immediately
- \`-p 1\` - high priority
- \`-p 2\` - normal priority
- (no priority) - lowest, done after all prioritized tasks
When user steers with urgent changes, set priority on new tasks to ensure they run BEFORE existing tasks.
Example: \`task create "Fix critical bug" --parent ${task.id} -p 0 --accept "..."\`
Example: \`task update <id> -p 0\` - bump existing task to run next
## Your Scope
You can reorganize ANY task under this scope. The TOP-LEVEL task's acceptance criteria are IMMUTABLE - they are the north star. Everything else can be:
- Broken down into subtasks
- Deleted if no longer relevant
- Reordered/reprioritized
- Updated with better acceptance criteria
- **Deleted** if no longer relevant (use \`task delete <id>\`)
- **Reprioritized** to change execution order (use \`task update <id> -p <n>\`)
- **Broken down** into subtasks
- **Updated** with better acceptance criteria
User steering notes override the task body - follow them.
**You are FREE to delete tasks and start over.** If the plan isn't working, throw it away. If user steers in a new direction, delete obsolete tasks rather than leaving them in the queue.
Always keep the top-level goal in mind when reorganizing.
User steering notes (STEER:) override the task body - follow them immediately by:
1. Creating new tasks with high priority (-p 0 or -p 1)
2. Deleting tasks that are now obsolete
3. Adjusting priorities on existing tasks if needed
## Writing Good Acceptance Criteria
@@ -660,6 +824,33 @@ Good examples:
- "File src/utils/parser.ts exports parseConfig function"
- "Running \`node cli.js --help\` prints usage information"
## Propagating Requirements to Subtasks
Both body guidance AND acceptance criteria must be propagated to subtasks. Break them
down so requirements are verified at each step, not deferred until the end.
**Propagate body guidance** (process instructions → subtask criteria):
Example: Body says "commit after each chunk of work"
→ Add to EACH subtask: --accept "git status shows clean working tree"
Example: Body says "run typecheck after each change"
→ Add to EACH subtask: --accept "npm run typecheck exits 0"
**Propagate acceptance criteria** (scope them to the subtask's module/area):
Example: Top-level says "no test.skip added"
→ Subtask for module X: --accept "no test.skip in src/modules/X/**"
Example: Top-level says "npm test passes with 0 failures"
→ Subtask for auth module: --accept "npm test src/auth passes with 0 failures"
Example: Top-level says "coverage >= 80%"
→ Subtask for utils: --accept "coverage for src/utils >= 80%"
This ensures requirements are enforced incrementally. Don't wait until the end to
verify - by then it's too late to fix without rework.
## TDD Pattern
When the top-level task mentions "TDD" or "test-driven", you MUST structure subtasks as test-first pairs:
@@ -701,6 +892,17 @@ Key points:
Note: Multiple iterations are fine if there's progress. Only mark failed if truly stuck with no way forward.
## Before You Exit - Sanity Check
ALWAYS run these checks before finishing:
1. **Run \`task plan ${task.id}\`** - verify the execution order makes sense
2. **Check priorities** - are urgent/steering tasks at the top (low priority numbers)?
3. **Check acceptance criteria** - does every pending task have clear, verifiable criteria?
4. **Clean up obsolete tasks** - delete anything that's no longer relevant
If something looks wrong, fix it before exiting.
DO NOT implement tasks. Only plan and organize.
When done planning, simply exit. Do not mark tasks as done.
`;
@@ -836,7 +1038,7 @@ program
const runPlanner = async (task: Task, reason: string): Promise<boolean> => {
log(logFile, `[PLANNER] Running ${plannerModel} (${reason})...`);
const context = await buildTaskContext(task, scopeId);
const plannerPrompt = makePlannerPrompt(task, context, 1);
const plannerPrompt = makePlannerPrompt(task, context, reason);
runAgentWithModel(plannerPrompt, plannerModel, logFile);
// Check if planner created subtasks for this task
@@ -895,7 +1097,13 @@ program
}
const task = ready[0];
const workerModel = (task.assignee === "codex" ? "gpt-5.2" : "sonnet") as ModelName;
const baseModel = (
task.assignee === "codex" ? "gpt-5.2-codex" :
task.assignee ? task.assignee :
"sonnet"
) as ModelName;
let workerModel = baseModel;
const canUpgrade = baseModel === "sonnet"; // Only upgrade if starting from sonnet
log(logFile, `\n=== Starting task: ${task.id} - ${task.title} ===`);
await store.start(task.id);
@@ -904,27 +1112,36 @@ program
let iteration = 1;
let taskDone = false;
let consecutiveNotDone = 0;
let lastSeenSteerCount = task.notes.filter(n => n.content.startsWith("STEER:")).length;
// Track steering notes on scope (not leaf task) since that's where user adds them
const scopeTaskForSteer = scopeId ? await store.get(scopeId) : null;
let lastSeenSteerCount = scopeTaskForSteer?.notes.filter(n => n.content.startsWith("STEER:")).length ?? 0;
while ((maxIterations === 0 || iteration <= maxIterations) && !taskDone) {
const iterLabel = maxIterations === 0 ? `${iteration}` : `${iteration}/${maxIterations}`;
log(logFile, `[WORKER] Iteration ${iterLabel} with ${workerModel}...`);
// Check for new steering notes before worker runs
const preTask = await store.get(task.id);
if (!preTask) break;
const currentSteerCount = preTask.notes.filter(n => n.content.startsWith("STEER:")).length;
if (enablePlanner && currentSteerCount > lastSeenSteerCount) {
const newSteers = preTask.notes.filter(n => n.content.startsWith("STEER:")).slice(lastSeenSteerCount);
log(logFile, `[STEER] New steering note detected - triggering planner`);
lastSeenSteerCount = currentSteerCount;
const shouldRestart = await runPlanner(preTask, `steering: ${newSteers.map(n => n.content).join("; ")}`);
if (shouldRestart) {
const updatedTask = await store.get(task.id);
if (updatedTask && updatedTask.status === "in_progress") {
await store.update(task.id, { status: "open" });
// Check for new steering notes on scope before worker runs
if (enablePlanner && scopeId) {
const freshScope = await store.get(scopeId);
if (freshScope) {
const currentSteerCount = freshScope.notes.filter(n => n.content.startsWith("STEER:")).length;
if (currentSteerCount > lastSeenSteerCount) {
const newSteers = freshScope.notes
.filter(n => n.content.startsWith("STEER:"))
.slice(lastSeenSteerCount)
.map(n => n.content.replace(/^STEER:\s*/, ""));
log(logFile, `[STEER] New steering note detected - triggering planner`);
lastSeenSteerCount = currentSteerCount;
const reason = `User steering: ${newSteers.join("; ")}`;
const shouldRestart = await runPlanner(freshScope, reason);
if (shouldRestart) {
const updatedTask = await store.get(task.id);
if (updatedTask && updatedTask.status === "in_progress") {
await store.update(task.id, { status: "open" });
}
break;
}
}
break;
}
}
@@ -954,12 +1171,18 @@ program
consecutiveNotDone = 0;
} else {
consecutiveNotDone++;
// Only replan after 5 consecutive NOT_DONEs
if (enablePlanner && consecutiveNotDone >= 5) {
log(logFile, `[JUDGE] ${consecutiveNotDone} consecutive NOT_DONE - triggering planner`);
const currentTask = await store.get(task.id);
if (currentTask) {
const shouldRestart = await runPlanner(currentTask, `${consecutiveNotDone} consecutive NOT_DONE`);
// Upgrade to opus on first NOT_DONE (if not already using opus)
if (canUpgrade && workerModel === "sonnet") {
workerModel = "opus";
log(logFile, `[WORKER] Upgrading to opus after NOT_DONE`);
}
// Only replan after 5 consecutive NOT_DONEs - use scope for bird's eye view
if (enablePlanner && consecutiveNotDone >= 5 && scopeId) {
log(logFile, `[JUDGE] ${consecutiveNotDone} consecutive NOT_DONE on ${task.id} - triggering planner`);
const scopeTask = await store.get(scopeId);
if (scopeTask) {
const reason = `task ${task.id} "${task.title}" got ${consecutiveNotDone} consecutive NOT_DONE`;
const shouldRestart = await runPlanner(scopeTask, reason);
consecutiveNotDone = 0;
if (shouldRestart) {
// Planner created subtasks or marked failed, restart the main loop
@@ -0,0 +1,393 @@
import { describe, it, expect, beforeEach } from "vitest";
import { computeExecutionOrder, buildSortedChildrenMap } from "./execution-order.js";
import type { Task, TaskStore } from "./types.js";
/**
* In-memory task store for testing
*/
class MemoryTaskStore implements TaskStore {
private tasks: Map<string, Task> = new Map();
addTask(task: Task): void {
this.tasks.set(task.id, task);
}
async list(): Promise<Task[]> {
return [...this.tasks.values()];
}
async get(id: string): Promise<Task | null> {
return this.tasks.get(id) ?? null;
}
async getDescendants(id: string): Promise<Task[]> {
const result: Task[] = [];
const traverse = (parentId: string): void => {
for (const task of this.tasks.values()) {
if (task.parentId === parentId) {
result.push(task);
traverse(task.id);
}
}
};
traverse(id);
return result;
}
// Unused in tests but required by interface
async getDepTree(): Promise<Task[]> {
return [];
}
async getAncestors(): Promise<Task[]> {
return [];
}
async getChildren(id: string): Promise<Task[]> {
return [...this.tasks.values()].filter((t) => t.parentId === id);
}
async getReady(): Promise<Task[]> {
return [];
}
async getBlocked(): Promise<Task[]> {
return [];
}
async getClosed(): Promise<Task[]> {
return [];
}
async create(): Promise<Task> {
throw new Error("Not implemented");
}
async update(): Promise<Task> {
throw new Error("Not implemented");
}
async delete(): Promise<void> {
throw new Error("Not implemented");
}
async addDep(): Promise<void> {
throw new Error("Not implemented");
}
async removeDep(): Promise<void> {
throw new Error("Not implemented");
}
async setParent(): Promise<void> {
throw new Error("Not implemented");
}
async addNote(): Promise<void> {
throw new Error("Not implemented");
}
async open(): Promise<void> {
throw new Error("Not implemented");
}
async start(): Promise<void> {
throw new Error("Not implemented");
}
async close(): Promise<void> {
throw new Error("Not implemented");
}
async fail(): Promise<void> {
throw new Error("Not implemented");
}
async addAcceptanceCriteria(): Promise<void> {
throw new Error("Not implemented");
}
}
function makeTask(
id: string,
opts: {
title?: string;
status?: Task["status"];
parentId?: string;
deps?: string[];
priority?: number;
created?: string;
} = {}
): Task {
return {
id,
title: opts.title ?? id,
status: opts.status ?? "open",
parentId: opts.parentId,
deps: opts.deps ?? [],
priority: opts.priority,
created: opts.created ?? new Date().toISOString(),
body: "",
acceptanceCriteria: [],
notes: [],
};
}
describe("computeExecutionOrder", () => {
let store: MemoryTaskStore;
beforeEach(() => {
store = new MemoryTaskStore();
});
it("should return empty timeline for empty store", async () => {
const result = await computeExecutionOrder(store);
expect(result.timeline).toEqual([]);
expect(result.orderMap.size).toBe(0);
expect(result.blocked.size).toBe(0);
});
it("should order by priority (lower number first)", async () => {
store.addTask(makeTask("a", { priority: 2, created: "2024-01-01" }));
store.addTask(makeTask("b", { priority: 0, created: "2024-01-02" }));
store.addTask(makeTask("c", { priority: 1, created: "2024-01-03" }));
const result = await computeExecutionOrder(store);
const ids = result.timeline.map((t) => t.id);
expect(ids).toEqual(["b", "c", "a"]);
});
it("should order tasks without priority after prioritized tasks", async () => {
store.addTask(makeTask("a", { created: "2024-01-01" })); // no priority
store.addTask(makeTask("b", { priority: 1, created: "2024-01-02" }));
store.addTask(makeTask("c", { created: "2024-01-03" })); // no priority
const result = await computeExecutionOrder(store);
const ids = result.timeline.map((t) => t.id);
// b first (has priority), then a and c by created date
expect(ids).toEqual(["b", "a", "c"]);
});
it("should respect dependencies", async () => {
// c depends on b
store.addTask(makeTask("a", { created: "2024-01-01" }));
store.addTask(makeTask("b", { created: "2024-01-02" }));
store.addTask(makeTask("c", { deps: ["b"], created: "2024-01-03" }));
const result = await computeExecutionOrder(store);
const ids = result.timeline.map((t) => t.id);
// a and b can run (a first by created), then c after b
expect(ids).toEqual(["a", "b", "c"]);
});
it("should execute children before parent", async () => {
// Parent b has children b1 and b2
store.addTask(makeTask("b", { created: "2024-01-01" }));
store.addTask(makeTask("b1", { parentId: "b", created: "2024-01-02" }));
store.addTask(makeTask("b2", { parentId: "b", created: "2024-01-03" }));
const result = await computeExecutionOrder(store, "b");
const ids = result.timeline.map((t) => t.id);
// Children first, then parent
expect(ids).toEqual(["b1", "b2", "b"]);
});
it("should handle complex epic with phases and dependencies", async () => {
/**
* Epic structure:
*
* epic (root)
* ├── phase1 (priority 0)
* │ ├── p1-task1
* │ └── p1-task2 (depends on p1-task1)
* ├── phase2 (depends on phase1)
* │ ├── p2-task1
* │ └── p2-task2
* └── cleanup (no priority, depends on phase2)
*/
// Epic root
store.addTask(makeTask("epic", { created: "2024-01-01" }));
// Phase 1 - priority 0 (should run first)
store.addTask(
makeTask("phase1", {
parentId: "epic",
priority: 0,
created: "2024-01-02",
})
);
store.addTask(
makeTask("p1-task1", { parentId: "phase1", created: "2024-01-03" })
);
store.addTask(
makeTask("p1-task2", {
parentId: "phase1",
deps: ["p1-task1"],
created: "2024-01-04",
})
);
// Phase 2 - depends on phase1
store.addTask(
makeTask("phase2", {
parentId: "epic",
deps: ["phase1"],
created: "2024-01-05",
})
);
store.addTask(
makeTask("p2-task1", { parentId: "phase2", created: "2024-01-06" })
);
store.addTask(
makeTask("p2-task2", { parentId: "phase2", created: "2024-01-07" })
);
// Cleanup - no priority, depends on phase2
store.addTask(
makeTask("cleanup", {
parentId: "epic",
deps: ["phase2"],
created: "2024-01-08",
})
);
const result = await computeExecutionOrder(store, "epic");
const ids = result.timeline.map((t) => t.id);
// Expected order:
// 1. p1-task1 (leaf, no deps)
// 2. p1-task2 (leaf, deps on p1-task1)
// 3. phase1 (parent, children done)
// 4. p2-task1, p2-task2 (leaves, phase1 done)
// 5. phase2 (parent, children done, dep on phase1 done)
// 6. cleanup (dep on phase2 done)
// 7. epic (all children done)
expect(ids).toEqual([
"p1-task1",
"p1-task2",
"phase1",
"p2-task1",
"p2-task2",
"phase2",
"cleanup",
"epic",
]);
});
it("should handle priority override within same parent", async () => {
/**
* Parent with children where priority overrides created order:
*
* parent
* ├── child-a (no priority, created first)
* ├── child-b (priority 0, created second) <- should run first
* └── child-c (priority 1, created third)
*/
store.addTask(makeTask("parent", { created: "2024-01-01" }));
store.addTask(
makeTask("child-a", { parentId: "parent", created: "2024-01-02" })
);
store.addTask(
makeTask("child-b", {
parentId: "parent",
priority: 0,
created: "2024-01-03",
})
);
store.addTask(
makeTask("child-c", {
parentId: "parent",
priority: 1,
created: "2024-01-04",
})
);
const result = await computeExecutionOrder(store, "parent");
const ids = result.timeline.map((t) => t.id);
// b first (priority 0), c second (priority 1), a last (no priority), then parent
expect(ids).toEqual(["child-b", "child-c", "child-a", "parent"]);
});
it("should place done tasks first in historical order", async () => {
store.addTask(
makeTask("done-later", { status: "done", created: "2024-01-03" })
);
store.addTask(
makeTask("done-first", { status: "done", created: "2024-01-01" })
);
store.addTask(makeTask("pending", { created: "2024-01-02" }));
const result = await computeExecutionOrder(store);
const ids = result.timeline.map((t) => t.id);
// Done tasks first (by created), then pending
expect(ids).toEqual(["done-first", "done-later", "pending"]);
});
it("should mark tasks with unresolvable deps as blocked", async () => {
// b depends on external-dep which doesn't exist in scope
store.addTask(makeTask("a", { created: "2024-01-01" }));
store.addTask(
makeTask("b", { deps: ["external-dep"], created: "2024-01-02" })
);
const result = await computeExecutionOrder(store);
expect(result.timeline.map((t) => t.id)).toEqual(["a"]);
expect(result.blocked.has("b")).toBe(true);
});
it("should detect circular dependencies as blocked", async () => {
// a depends on b, b depends on a
store.addTask(makeTask("a", { deps: ["b"], created: "2024-01-01" }));
store.addTask(makeTask("b", { deps: ["a"], created: "2024-01-02" }));
const result = await computeExecutionOrder(store);
expect(result.timeline).toEqual([]);
expect(result.blocked.has("a")).toBe(true);
expect(result.blocked.has("b")).toBe(true);
});
it("should handle in_progress tasks same as open", async () => {
store.addTask(makeTask("a", { status: "in_progress", created: "2024-01-01" }));
store.addTask(makeTask("b", { status: "open", created: "2024-01-02" }));
const result = await computeExecutionOrder(store);
const ids = result.timeline.map((t) => t.id);
expect(ids).toEqual(["a", "b"]);
});
});
describe("buildSortedChildrenMap", () => {
it("should sort children by execution order", () => {
const tasks: Task[] = [
makeTask("parent"),
makeTask("child-a", { parentId: "parent" }),
makeTask("child-b", { parentId: "parent" }),
makeTask("child-c", { parentId: "parent" }),
];
// Execution order: child-c (0), child-a (1), child-b (2)
const orderMap = new Map<string, number>([
["child-c", 0],
["child-a", 1],
["child-b", 2],
["parent", 3],
]);
const childrenMap = buildSortedChildrenMap(tasks, orderMap);
const children = childrenMap.get("parent")!;
expect(children.map((t) => t.id)).toEqual(["child-c", "child-a", "child-b"]);
});
it("should handle tasks not in order map (put at end)", () => {
const tasks: Task[] = [
makeTask("parent"),
makeTask("child-a", { parentId: "parent" }),
makeTask("child-b", { parentId: "parent" }),
];
// Only child-b in order map
const orderMap = new Map<string, number>([["child-b", 0]]);
const childrenMap = buildSortedChildrenMap(tasks, orderMap);
const children = childrenMap.get("parent")!;
// child-b first (has order), child-a last (Infinity)
expect(children.map((t) => t.id)).toEqual(["child-b", "child-a"]);
});
});
@@ -0,0 +1,145 @@
import type { Task, TaskStore } from "./types.js";
function sortByPriorityThenCreated(a: Task, b: Task): number {
if (a.priority !== undefined && b.priority === undefined) return -1;
if (a.priority === undefined && b.priority !== undefined) return 1;
if (a.priority !== undefined && b.priority !== undefined) {
if (a.priority !== b.priority) return a.priority - b.priority;
}
return a.created.localeCompare(b.created);
}
export interface ExecutionOrderResult {
/** Tasks in execution order (done first, then pending) */
timeline: Task[];
/** Map from task ID to execution order index */
orderMap: Map<string, number>;
/** Task IDs that are blocked/unreachable */
blocked: Set<string>;
}
/**
* Computes execution order for tasks within a scope.
* Simulates running `task ready` repeatedly until no tasks remain.
*
* @param store - Task store for fetching tasks
* @param scopeId - Optional scope task ID (if omitted, uses all tasks)
* @returns Execution order result with timeline, order map, and blocked tasks
*/
export async function computeExecutionOrder(
store: TaskStore,
scopeId?: string
): Promise<ExecutionOrderResult> {
const allTasks = await store.list();
const taskMap = new Map(allTasks.map((t) => [t.id, t]));
// Get scoped tasks
let candidates: Task[];
if (scopeId) {
const scopeTask = await store.get(scopeId);
const descendants = await store.getDescendants(scopeId);
candidates = scopeTask ? [scopeTask, ...descendants] : descendants;
} else {
candidates = allTasks;
}
// Build children map
const childrenMap = new Map<string, Task[]>();
for (const t of allTasks) {
if (t.parentId) {
const siblings = childrenMap.get(t.parentId) ?? [];
siblings.push(t);
childrenMap.set(t.parentId, siblings);
}
}
const candidateIds = new Set(candidates.map((t) => t.id));
// Simulate execution: track done status
// Include all done tasks (not just in scope) for dep resolution
const simDone = new Set(
allTasks.filter((t) => t.status === "done").map((t) => t.id)
);
const remaining = new Set(
candidates
.filter((t) => t.status === "open" || t.status === "in_progress")
.map((t) => t.id)
);
const isReady = (taskId: string): boolean => {
const task = taskMap.get(taskId);
if (!task) return false;
// All deps done (deps can be outside scope)
const depsOk = task.deps.every((depId) => simDone.has(depId));
// All children done (only consider children in scope)
const children = (childrenMap.get(taskId) ?? []).filter((c) =>
candidateIds.has(c.id)
);
const childrenOk = children.every((c) => simDone.has(c.id));
return depsOk && childrenOk;
};
const timeline: Task[] = [];
const orderMap = new Map<string, number>();
let orderIdx = 0;
// Done tasks first (by created date = historical execution order)
const done = candidates
.filter((t) => t.status === "done")
.sort((a, b) => a.created.localeCompare(b.created));
for (const t of done) {
timeline.push(t);
orderMap.set(t.id, orderIdx++);
}
// Then pending tasks in execution order
while (remaining.size > 0) {
const readyNow = [...remaining]
.filter(isReady)
.map((tid) => taskMap.get(tid)!)
.sort(sortByPriorityThenCreated);
if (readyNow.length === 0) break; // No more can be done (cycle or blocked)
const next = readyNow[0];
timeline.push(next);
orderMap.set(next.id, orderIdx++);
simDone.add(next.id);
remaining.delete(next.id);
}
// Remaining are blocked/unreachable
const blocked = remaining;
return { timeline, orderMap, blocked };
}
/**
* Builds a map of parent ID to children sorted by execution order.
*/
export function buildSortedChildrenMap(
tasks: Task[],
orderMap: Map<string, number>
): Map<string, Task[]> {
const childrenMap = new Map<string, Task[]>();
for (const t of tasks) {
if (t.parentId) {
const siblings = childrenMap.get(t.parentId) ?? [];
siblings.push(t);
childrenMap.set(t.parentId, siblings);
}
}
// Sort each group by execution order
for (const [parentId, children] of childrenMap) {
children.sort((a, b) => {
const orderA = orderMap.get(a.id) ?? Infinity;
const orderB = orderMap.get(b.id) ?? Infinity;
return orderA - orderB;
});
childrenMap.set(parentId, children);
}
return childrenMap;
}