diff --git a/packages/app/e2e/helpers/app.ts b/packages/app/e2e/helpers/app.ts index 375eeb2ae..40a6e6751 100644 --- a/packages/app/e2e/helpers/app.ts +++ b/packages/app/e2e/helpers/app.ts @@ -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 { + // 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(); +} diff --git a/packages/app/e2e/permission-prompt.spec.ts b/packages/app/e2e/permission-prompt.spec.ts index d20336556..d2a83e64a 100644 --- a/packages/app/e2e/permission-prompt.spec.ts +++ b/packages/app/e2e/permission-prompt.spec.ts @@ -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(); } diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index f936f08b8..a4fafd74a 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -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 ( - + { 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); diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index cf33d7742..a9f0b8b93 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -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, { diff --git a/packages/server/src/client/daemon-client-v2.ts b/packages/server/src/client/daemon-client-v2.ts index 042e4c0c4..3ca26ff98 100644 --- a/packages/server/src/client/daemon-client-v2.ts +++ b/packages/server/src/client/daemon-client-v2.ts @@ -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); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 2b48b4a3f..e372d071b 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -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 }); } diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts index 279c099b9..950edcf2f 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -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) => diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 602968ec3..91dd98af6 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -532,6 +532,7 @@ class ClaudeAgentSession implements AgentSession { prompt: AgentPromptInput, _options?: AgentRunOptions ): AsyncGenerator { + 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, 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 { 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((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", diff --git a/packages/server/src/server/agent/providers/codex-mcp-agent.test.ts b/packages/server/src/server/agent/providers/codex-mcp-agent.test.ts index 226b179d0..891bcf2f1 100644 --- a/packages/server/src/server/agent/providers/codex-mcp-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-mcp-agent.test.ts @@ -16,6 +16,10 @@ import type { type ToolCallItem = Extract; +// 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", diff --git a/packages/server/src/server/agent/providers/codex-mcp-agent.ts b/packages/server/src/server/agent/providers/codex-mcp-agent.ts index c33779fd3..2b57f5396 100644 --- a/packages/server/src/server/agent/providers/codex-mcp-agent.ts +++ b/packages/server/src/server/agent/providers/codex-mcp-agent.ts @@ -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, diff --git a/packages/server/src/server/daemon-client-v2.e2e.test.ts b/packages/server/src/server/daemon-client-v2.e2e.test.ts index df63d5236..6735b3a90 100644 --- a/packages/server/src/server/daemon-client-v2.e2e.test.ts +++ b/packages/server/src/server/daemon-client-v2.e2e.test.ts @@ -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( 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", }); diff --git a/packages/server/src/server/daemon-e2e/agent-basics.e2e.test.ts b/packages/server/src/server/daemon-e2e/agent-basics.e2e.test.ts new file mode 100644 index 000000000..3c75df49f --- /dev/null +++ b/packages/server/src/server/daemon-e2e/agent-basics.e2e.test.ts @@ -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); + }); + + +}); diff --git a/packages/server/src/server/daemon-e2e/agent-configs.ts b/packages/server/src/server/daemon-e2e/agent-configs.ts new file mode 100644 index 000000000..69c413883 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/agent-configs.ts @@ -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; + +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"]; diff --git a/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts new file mode 100644 index 000000000..2f3979775 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts @@ -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((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( + (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( + (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( + (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 + ); + }); + + +}); diff --git a/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts b/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts new file mode 100644 index 000000000..b94128e96 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts @@ -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 + ); + }); + + +}); diff --git a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts new file mode 100644 index 000000000..be394d612 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts @@ -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 + ); + }); + + +}); diff --git a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts new file mode 100644 index 000000000..6d9e3e4d2 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts @@ -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 + ); + }); + + +}); diff --git a/packages/server/src/server/daemon-e2e/images.e2e.test.ts b/packages/server/src/server/daemon-e2e/images.e2e.test.ts new file mode 100644 index 000000000..7ba2b363b --- /dev/null +++ b/packages/server/src/server/daemon-e2e/images.e2e.test.ts @@ -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 + ); + }); + + +}); diff --git a/packages/server/src/server/daemon-e2e/models.e2e.test.ts b/packages/server/src/server/daemon-e2e/models.e2e.test.ts new file mode 100644 index 000000000..2fa97d38e --- /dev/null +++ b/packages/server/src/server/daemon-e2e/models.e2e.test.ts @@ -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 + ); + }); + + +}); diff --git a/packages/server/src/server/daemon-e2e/orchestration.e2e.test.ts b/packages/server/src/server/daemon-e2e/orchestration.e2e.test.ts new file mode 100644 index 000000000..37f990381 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/orchestration.e2e.test.ts @@ -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(); + 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 + ); + }); + + +}); diff --git a/packages/server/src/server/daemon-e2e/permissions-claude.e2e.test.ts b/packages/server/src/server/daemon-e2e/permissions-claude.e2e.test.ts new file mode 100644 index 000000000..cd40a85e8 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/permissions-claude.e2e.test.ts @@ -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 + ); + }); + + +}); diff --git a/packages/server/src/server/daemon-e2e/permissions-codex.e2e.test.ts b/packages/server/src/server/daemon-e2e/permissions-codex.e2e.test.ts new file mode 100644 index 000000000..cdfdd2799 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/permissions-codex.e2e.test.ts @@ -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((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 + ); + }); + + +}); diff --git a/packages/server/src/server/daemon-e2e/persistence.e2e.test.ts b/packages/server/src/server/daemon-e2e/persistence.e2e.test.ts new file mode 100644 index 000000000..4ebd40ad9 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/persistence.e2e.test.ts @@ -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((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 + ); + }); + + +}); diff --git a/packages/server/src/server/daemon-e2e/setup.ts b/packages/server/src/server/daemon-e2e/setup.ts new file mode 100644 index 000000000..690475fbc --- /dev/null +++ b/packages/server/src/server/daemon-e2e/setup.ts @@ -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); +} diff --git a/packages/server/src/server/daemon-e2e/streaming.e2e.test.ts b/packages/server/src/server/daemon-e2e/streaming.e2e.test.ts new file mode 100644 index 000000000..0ed05d292 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/streaming.e2e.test.ts @@ -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 + ); + }); + + +}); diff --git a/packages/server/src/server/daemon-e2e/tool-calls.e2e.test.ts b/packages/server/src/server/daemon-e2e/tool-calls.e2e.test.ts new file mode 100644 index 000000000..04542c2b3 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/tool-calls.e2e.test.ts @@ -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(); + 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 + ); + }); +}); diff --git a/packages/server/src/server/daemon-e2e/wait-for-idle.e2e.test.ts b/packages/server/src/server/daemon-e2e/wait-for-idle.e2e.test.ts new file mode 100644 index 000000000..12280b3f6 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/wait-for-idle.e2e.test.ts @@ -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); +}); diff --git a/packages/server/src/server/daemon.e2e.test.ts b/packages/server/src/server/daemon.e2e.test.ts deleted file mode 100644 index 2a4a7fa08..000000000 --- a/packages/server/src/server/daemon.e2e.test.ts +++ /dev/null @@ -1,3655 +0,0 @@ -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, - type DaemonTestContext, - useTempClaudeConfigDir, -} 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-")); -} - -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", - 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", - cwd: nonExistentCwd, - title: "Should Fail Agent", - }); - - expect(result.error).toContain("Working directory does not exist"); - expect(result.error).toContain(nonExistentCwd); - }); - - 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", - 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); - - // Verify permission timeline items - 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 permission granted timeline item - const hasGranted = timelineItems.some( - (item) => - item.type === "tool_call" && - item.name === "permission" && - item.status === "granted" - ); - expect(hasGranted).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", - 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); - - // Verify permission denied timeline item - 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); - } - } - - const hasDenied = timelineItems.some( - (item) => - item.type === "tool_call" && - item.name === "permission" && - item.status === "denied" - ); - expect(hasDenied).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", - 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", - 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", - 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((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 - ); - }); - - 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", - 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", - 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", - 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 - ); - }); - - 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", - 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("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", - 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(); - 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 - ); - }); - - 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", - 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", - 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", - 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((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( - (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", - 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( - (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( - (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", - 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", - 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 - ); - }); - - 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); - - // Verify permission timeline items - 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 permission granted timeline item - const hasGranted = timelineItems.some( - (item) => - item.type === "tool_call" && - item.name === "permission" && - item.status === "granted" - ); - expect(hasGranted).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); - - // Verify permission denied timeline item - 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); - } - } - - const hasDenied = timelineItems.some( - (item) => - item.type === "tool_call" && - item.name === "permission" && - item.status === "denied" - ); - expect(hasDenied).toBe(true); - - rmSync(cwd, { recursive: true, force: true }); - }, - 180000 - ); - }); - - 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", - 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", - 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", - 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", - 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", - 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", - 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 - ); - }); - - 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", - 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", - 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", - 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 - ); - }); - - 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 - ); - }); - - 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 - ); - }); - - 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", - 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("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("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((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", - 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", - 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 - ); - }); - - 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 agent = await ctx.client.createAgent({ - provider: "claude", - cwd, - title: "Interrupt During Tool Call Test", - modeId: "bypassPermissions", - }); - - expect(agent.id).toBeTruthy(); - - // Start a long-running tool call (sleep command) - - const msg1StartPosition = ctx.client.getMessageQueue().length; - - await ctx.client.sendMessage( - agent.id, - "Run: sleep 30 && echo 'done sleeping'" - ); - - // Wait for the tool call to start (agent should be running and we should see the bash tool) - - let sawToolCall = false; - const toolWaitStart = Date.now(); - while (Date.now() - toolWaitStart < 30000) { - const queue = ctx.client.getMessageQueue(); - for (let i = msg1StartPosition; 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 === "tool_call" - ) { - const tc = m.payload.event.item; - if (tc.name?.toLowerCase().includes("bash") || tc.name?.toLowerCase().includes("shell")) { - sawToolCall = true; - - break; - } - } - } - if (sawToolCall) break; - await new Promise(r => setTimeout(r, 200)); - } - - expect(sawToolCall).toBe(true); - - // Now send an interrupting message while the tool is running - - const msg2StartPosition = ctx.client.getMessageQueue().length; - - await ctx.client.sendMessage( - agent.id, - "Stop. Say exactly: 'interrupted tool call'" - ); - - // Track state transitions - - const stateTransitions: Array<{ status: string; timestamp: number; lastError?: string }> = []; - const monitorStart = Date.now(); - - while (Date.now() - monitorStart < 60000) { - const queue = ctx.client.getMessageQueue(); - for (let i = msg2StartPosition; i < queue.length; i++) { - const m = queue[i]; - if (m.type === "agent_state" && m.payload.id === agent.id) { - const lastRecorded = stateTransitions[stateTransitions.length - 1]; - if (!lastRecorded || lastRecorded.status !== m.payload.status) { - stateTransitions.push({ - status: m.payload.status, - timestamp: Date.now() - monitorStart, - lastError: m.payload.lastError, - }); - - } - } - } - - // Check if we reached idle/error after seeing running - const sawRunning = stateTransitions.some(s => s.status === "running"); - const sawFinal = stateTransitions.some(s => s.status === "idle" || s.status === "error"); - if (sawRunning && sawFinal) { - break; - } - - await new Promise(r => setTimeout(r, 200)); - } - - // Verify we got proper state transitions - expect(stateTransitions.length).toBeGreaterThan(0); - - // Check if we ended in idle (success) or error - const finalState = stateTransitions[stateTransitions.length - 1]; - - // Look for the response to message 2 - const queue = ctx.client.getMessageQueue(); - const assistantMessages: string[] = []; - let foundMsg2User = false; - - for (let i = msg2StartPosition; i < queue.length; i++) { - const m = queue[i]; - 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("interrupted tool call")) { - foundMsg2User = true; - } - if (foundMsg2User && item.type === "assistant_message" && item.text) { - assistantMessages.push(item.text); - } - } - } - - - // The key assertion: agent should have responded to message 2 - if (finalState.status === "idle") { - expect(assistantMessages.length).toBeGreaterThan(0); - } - - await ctx.client.deleteAgent(agent.id); - rmSync(cwd, { recursive: true, force: true }); - }, - 180000 - ); - - 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 - ); - }); - - 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(); - 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", - 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", - 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", - 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 - ); - }); -}); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 0a558e8e8..3cd86cbe5 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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 { + 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 { + 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); } diff --git a/packages/server/src/tasks/cli.ts b/packages/server/src/tasks/cli.ts index 17f9a3487..a0d85ea14 100644 --- a/packages/server/src/tasks/cli.ts +++ b/packages/server/src/tasks/cli.ts @@ -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 --body ..." - While implementing: add notes with "task note ..." - 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 # Run without planner (task must be well-defined) + task run --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 "new direction") + + Monitoring a running loop: + task plan # See execution timeline and progress + task show # Check specific task details + tail -f task-run.*.log # Follow the live log + + Steering mid-run: + task steer "focus on X first" + task steer "skip Y, it's not needed" + task steer "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/" + - "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 ") - .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 ", "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 --body "..." --accept "criterion"\` - create task (any level) -- \`task update --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 \` - view task details, body, acceptance criteria, and notes +- \`task children \` - 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 \` groups tasks. Use \`task tree\` and \`task children\`. +2. **Dependencies** (ordering): \`--deps \` 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 # drill into a branch +task show # 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 --body "..." --accept "criterion" -p \` - create task +- \`task update --title "..." --body "..." -p \` - update task properties +- \`task update --accept "criterion"\` - add acceptance criterion +- \`task update --clear-acceptance -a "new criterion"\` - replace all acceptance criteria +- \`task delete \` - remove a task permanently - \`task note "content"\` - add planning notes -- \`task delete \` - remove a task that's no longer needed - \`task fail \` - 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 -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 \`) +- **Reprioritized** to change execution order (use \`task update -p \`) +- **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 => { 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 diff --git a/packages/server/src/tasks/execution-order.test.ts b/packages/server/src/tasks/execution-order.test.ts new file mode 100644 index 000000000..63a709df5 --- /dev/null +++ b/packages/server/src/tasks/execution-order.test.ts @@ -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 = new Map(); + + addTask(task: Task): void { + this.tasks.set(task.id, task); + } + + async list(): Promise { + return [...this.tasks.values()]; + } + + async get(id: string): Promise { + return this.tasks.get(id) ?? null; + } + + async getDescendants(id: string): Promise { + 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 { + return []; + } + async getAncestors(): Promise { + return []; + } + async getChildren(id: string): Promise { + return [...this.tasks.values()].filter((t) => t.parentId === id); + } + async getReady(): Promise { + return []; + } + async getBlocked(): Promise { + return []; + } + async getClosed(): Promise { + return []; + } + async create(): Promise { + throw new Error("Not implemented"); + } + async update(): Promise { + throw new Error("Not implemented"); + } + async delete(): Promise { + throw new Error("Not implemented"); + } + async addDep(): Promise { + throw new Error("Not implemented"); + } + async removeDep(): Promise { + throw new Error("Not implemented"); + } + async setParent(): Promise { + throw new Error("Not implemented"); + } + async addNote(): Promise { + throw new Error("Not implemented"); + } + async open(): Promise { + throw new Error("Not implemented"); + } + async start(): Promise { + throw new Error("Not implemented"); + } + async close(): Promise { + throw new Error("Not implemented"); + } + async fail(): Promise { + throw new Error("Not implemented"); + } + async addAcceptanceCriteria(): Promise { + 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([ + ["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([["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"]); + }); +}); diff --git a/packages/server/src/tasks/execution-order.ts b/packages/server/src/tasks/execution-order.ts new file mode 100644 index 000000000..70c2a316e --- /dev/null +++ b/packages/server/src/tasks/execution-order.ts @@ -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; + /** Task IDs that are blocked/unreachable */ + blocked: Set; +} + +/** + * 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 { + 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(); + 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(); + 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 +): Map { + const childrenMap = new Map(); + + 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; +}