diff --git a/packages/cli/src/commands/daemon/runtime-toolchain.ts b/packages/cli/src/commands/daemon/runtime-toolchain.ts index 3b41d020f..1c679d76b 100644 --- a/packages/cli/src/commands/daemon/runtime-toolchain.ts +++ b/packages/cli/src/commands/daemon/runtime-toolchain.ts @@ -1,5 +1,5 @@ -import { spawnSync } from "node:child_process"; import { platform } from "node:os"; +import { execCommand } from "@getpaseo/server"; export interface NodePathFromPidResult { nodePath: string | null; @@ -13,63 +13,37 @@ function normalizeError(error: unknown): string { return String(error); } -function resolveNodePathFromPidUnix(pid: number): NodePathFromPidResult { - const result = spawnSync("ps", ["-o", "comm=", "-p", String(pid)], { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }); - - if (result.error) { - return { nodePath: null, error: `ps failed: ${normalizeError(result.error)}` }; +async function resolveNodePathFromPidUnix(pid: number): Promise { + try { + const { stdout } = await execCommand("ps", ["-o", "comm=", "-p", String(pid)]); + const resolved = stdout.trim(); + return resolved + ? { nodePath: resolved } + : { nodePath: null, error: "ps returned an empty command path" }; + } catch (error) { + return { nodePath: null, error: `ps failed: ${normalizeError(error)}` }; } - - if ((result.status ?? 1) !== 0) { - const details = result.stderr?.trim(); - return { - nodePath: null, - error: details ? `ps failed: ${details}` : `ps exited with code ${result.status ?? 1}`, - }; - } - - const resolved = result.stdout.trim(); - return resolved - ? { nodePath: resolved } - : { nodePath: null, error: "ps returned an empty command path" }; } -function runProcessProbe( +async function runProcessProbe( command: string, args: string[], -): { +): Promise<{ resolved: string | null; error?: string; -} { - const result = spawnSync(command, args, { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }); - - if (result.error) { - return { resolved: null, error: `${command} failed: ${normalizeError(result.error)}` }; +}> { + try { + const { stdout } = await execCommand(command, args); + const resolved = stdout.trim(); + return resolved + ? { resolved } + : { resolved: null, error: `${command} returned no executable path` }; + } catch (error) { + return { resolved: null, error: `${command} failed: ${normalizeError(error)}` }; } - - if ((result.status ?? 1) !== 0) { - const details = result.stderr?.trim(); - return { - resolved: null, - error: details - ? `${command} failed: ${details}` - : `${command} exited with code ${result.status ?? 1}`, - }; - } - - const resolved = result.stdout.trim(); - return resolved - ? { resolved } - : { resolved: null, error: `${command} returned no executable path` }; } -function resolveNodePathFromPidWindows(pid: number): NodePathFromPidResult { +async function resolveNodePathFromPidWindows(pid: number): Promise { const probes: Array<{ label: string; command: string; @@ -103,7 +77,7 @@ function resolveNodePathFromPidWindows(pid: number): NodePathFromPidResult { const errors: string[] = []; for (const probe of probes) { - const result = runProcessProbe(probe.command, probe.args); + const result = await runProcessProbe(probe.command, probe.args); if (result.resolved) { const resolved = probe.parseValue ? probe.parseValue(result.resolved) : result.resolved; if (resolved) { @@ -123,8 +97,8 @@ function resolveNodePathFromPidWindows(pid: number): NodePathFromPidResult { }; } -export function resolveNodePathFromPid(pid: number): NodePathFromPidResult { +export async function resolveNodePathFromPid(pid: number): Promise { return platform() === "win32" - ? resolveNodePathFromPidWindows(pid) - : resolveNodePathFromPidUnix(pid); + ? await resolveNodePathFromPidWindows(pid) + : await resolveNodePathFromPidUnix(pid); } diff --git a/packages/cli/src/commands/daemon/status.ts b/packages/cli/src/commands/daemon/status.ts index 49a582873..fa2b0f0fa 100644 --- a/packages/cli/src/commands/daemon/status.ts +++ b/packages/cli/src/commands/daemon/status.ts @@ -1,10 +1,11 @@ import type { Command } from "commander"; -import { execFile } from "node:child_process"; import { createRequire } from "node:module"; -import { promisify } from "node:util"; -import { getOrCreateServerId, findExecutable, applyProviderEnv } from "@getpaseo/server"; - -const execFileAsync = promisify(execFile); +import { + getOrCreateServerId, + findExecutable, + applyProviderEnv, + execCommand, +} from "@getpaseo/server"; import { tryConnectToDaemon } from "../../utils/client.js"; import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js"; import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js"; @@ -178,11 +179,9 @@ async function checkProviderBinary( } const env = applyProviderEnv(process.env); try { - const { stdout } = await execFileAsync(binaryPath, ["--version"], { - encoding: "utf8", + const { stdout } = await execCommand(binaryPath, ["--version"], { timeout: 5000, env, - windowsHide: true, }); return { path: binaryPath, version: stdout.trim() || null }; } catch { @@ -224,7 +223,7 @@ export async function runStatusCommand( if (!state.running) { daemonNode = "-"; } else if (state.pidInfo?.pid) { - const fromPid = resolveNodePathFromPid(state.pidInfo.pid); + const fromPid = await resolveNodePathFromPid(state.pidInfo.pid); daemonNode = fromPid.nodePath ?? `unknown (${fromPid.error ?? "could not resolve from PID"})`; } else { daemonNode = "unknown (no PID available)"; diff --git a/packages/server/scripts/test-mcp-inject.ts b/packages/server/scripts/test-mcp-inject.ts index 49e9bb666..beccc1bd2 100644 --- a/packages/server/scripts/test-mcp-inject.ts +++ b/packages/server/scripts/test-mcp-inject.ts @@ -123,11 +123,13 @@ async function verifyInjectedMcpForProvider( } async function main(): Promise { - if (!isProviderAvailable("claude")) { + const claudeAvailable = await isProviderAvailable("claude"); + if (!claudeAvailable) { throw new Error( "Claude is not available in this environment. Ensure the `claude` binary and credentials are configured.", ); } + const codexAvailable = await isProviderAvailable("codex"); const logger = pino({ level: "silent" }); const rootCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-mcp-inject-real-")); @@ -136,7 +138,7 @@ async function main(): Promise { const daemon = await createTestPaseoDaemon({ agentClients: { claude: new ClaudeAgentClient({ logger }), - ...(isProviderAvailable("codex") ? { codex: new CodexAppServerAgentClient(logger) } : {}), + ...(codexAvailable ? { codex: new CodexAppServerAgentClient(logger) } : {}), }, logger, }); @@ -159,7 +161,7 @@ async function main(): Promise { results.push(claudeResult); console.log(`[PASS] Claude MCP injection verified for agent ${claudeResult.agentId}`); - if (isProviderAvailable("codex")) { + if (codexAvailable) { const codexResult = await verifyInjectedMcpForProvider(client, "codex", codexCwd); createdAgentIds.push(codexResult.agentId); results.push(codexResult); diff --git a/packages/server/src/poc-commands/commands-poc.test.ts b/packages/server/src/poc-commands/commands-poc.test.ts index ec06a4ab8..70a3629b4 100644 --- a/packages/server/src/poc-commands/commands-poc.test.ts +++ b/packages/server/src/poc-commands/commands-poc.test.ts @@ -15,13 +15,12 @@ * This pattern is used in claude-agent.ts listModels(). */ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; -import { isCommandAvailableSync } from "../utils/executable.js"; +import { isCommandAvailable } from "../utils/executable.js"; const hasClaudeCredentials = !!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY; -const canRunClaudeIntegration = isCommandAvailableSync("claude") && hasClaudeCredentials; // Pattern from claude-agent.ts listModels(): // Use an empty async generator when you just need control methods @@ -30,88 +29,92 @@ function createEmptyPrompt(): AsyncGenerator { } describe("Claude Agent SDK Commands POC", () => { + let canRunClaudeIntegration = false; + + beforeAll(async () => { + canRunClaudeIntegration = (await isCommandAvailable("claude")) && hasClaudeCredentials; + }); + + beforeEach((context) => { + if (!canRunClaudeIntegration) { + context.skip(); + } + }); + describe("supportedCommands() API", () => { - test.runIf(canRunClaudeIntegration)( - "should return an array of SlashCommand objects", - async () => { - // Use the pattern from claude-agent.ts: - // Create a query with empty prompt generator for control methods - const emptyPrompt = createEmptyPrompt(); + test("should return an array of SlashCommand objects", async () => { + // Use the pattern from claude-agent.ts: + // Create a query with empty prompt generator for control methods + const emptyPrompt = createEmptyPrompt(); - const claudeQuery = query({ - prompt: emptyPrompt, - options: { - cwd: process.cwd(), - permissionMode: "plan", - includePartialMessages: false, - settingSources: ["user", "project"], // Required to load skills - }, - }); + const claudeQuery = query({ + prompt: emptyPrompt, + options: { + cwd: process.cwd(), + permissionMode: "plan", + includePartialMessages: false, + settingSources: ["user", "project"], // Required to load skills + }, + }); - try { - // supportedCommands() is a control method - works without iterating - const commands = await claudeQuery.supportedCommands(); + try { + // supportedCommands() is a control method - works without iterating + const commands = await claudeQuery.supportedCommands(); - // Should be an array - expect(Array.isArray(commands)).toBe(true); + // Should be an array + expect(Array.isArray(commands)).toBe(true); - // Verify structure - if (commands.length > 0) { - const firstCommand = commands[0]; - expect(typeof firstCommand.name).toBe("string"); - expect(typeof firstCommand.description).toBe("string"); - expect(typeof firstCommand.argumentHint).toBe("string"); - expect(firstCommand.name.startsWith("/")).toBe(false); - } - } finally { - if (typeof claudeQuery.return === "function") { - try { - await claudeQuery.return(); - } catch { - // ignore shutdown errors - } + // Verify structure + if (commands.length > 0) { + const firstCommand = commands[0]; + expect(typeof firstCommand.name).toBe("string"); + expect(typeof firstCommand.description).toBe("string"); + expect(typeof firstCommand.argumentHint).toBe("string"); + expect(firstCommand.name.startsWith("/")).toBe(false); + } + } finally { + if (typeof claudeQuery.return === "function") { + try { + await claudeQuery.return(); + } catch { + // ignore shutdown errors } } - }, - 30000, - ); + } + }, 30000); - test.runIf(canRunClaudeIntegration)( - "should have valid SlashCommand structure for all commands", - async () => { - const emptyPrompt = createEmptyPrompt(); + test("should have valid SlashCommand structure for all commands", async () => { + const emptyPrompt = createEmptyPrompt(); - const claudeQuery = query({ - prompt: emptyPrompt, - options: { - cwd: process.cwd(), - permissionMode: "plan", - settingSources: ["user", "project"], - }, - }); + const claudeQuery = query({ + prompt: emptyPrompt, + options: { + cwd: process.cwd(), + permissionMode: "plan", + settingSources: ["user", "project"], + }, + }); - try { - const commands = await claudeQuery.supportedCommands(); + try { + const commands = await claudeQuery.supportedCommands(); - expect(commands.length).toBeGreaterThan(0); + expect(commands.length).toBeGreaterThan(0); - // Verify all commands have valid structure - for (const cmd of commands) { - expect(cmd).toHaveProperty("name"); - expect(cmd).toHaveProperty("description"); - expect(cmd).toHaveProperty("argumentHint"); - expect(typeof cmd.name).toBe("string"); - expect(typeof cmd.description).toBe("string"); - expect(typeof cmd.argumentHint).toBe("string"); - expect(cmd.name.length).toBeGreaterThan(0); - expect(cmd.name.startsWith("/")).toBe(false); - } - } finally { - await claudeQuery.return?.(); + // Verify all commands have valid structure + for (const cmd of commands) { + expect(cmd).toHaveProperty("name"); + expect(cmd).toHaveProperty("description"); + expect(cmd).toHaveProperty("argumentHint"); + expect(typeof cmd.name).toBe("string"); + expect(typeof cmd.description).toBe("string"); + expect(typeof cmd.argumentHint).toBe("string"); + expect(cmd.name.length).toBeGreaterThan(0); + expect(cmd.name.startsWith("/")).toBe(false); } - }, - 30000, - ); + } finally { + await claudeQuery.return?.(); + } + }, 30000); }); describe("Command Execution", () => { diff --git a/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts b/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts index 11bd1923d..2820cdcf7 100644 --- a/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts +++ b/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts @@ -10,14 +10,14 @@ * CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY in the environment. * They are skipped automatically when credentials are unavailable. */ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import pino from "pino"; import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js"; -import { isCommandAvailableSync } from "../../../../utils/executable.js"; +import { isCommandAvailable } from "../../../../utils/executable.js"; import { ClaudeAgentClient } from "../claude-agent.js"; // --------------------------------------------------------------------------- @@ -28,7 +28,6 @@ const logger = pino({ level: "silent" }); const client = new ClaudeAgentClient({ logger }); const hasClaudeCredentials = !!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY; -const canRun = isCommandAvailableSync("claude") && hasClaudeCredentials; function tmpCwd(prefix: string): string { return mkdtempSync(path.join(tmpdir(), prefix)); @@ -194,332 +193,308 @@ function assertInvariants(events: AgentStreamEvent[], foregroundTurnIds: string[ // --------------------------------------------------------------------------- describe("Agent event stream redesign — integration", () => { - test.skipIf(!canRun)( - "Test 1: Basic foreground turn", - async () => { - const handle = await createSession({ cwdPrefix: "event-stream-basic-" }); + let canRun = false; - try { - const { turnId, events } = await startTurnAndCollectEvents( - handle.session, - "respond with just the word hello", - ); + beforeAll(async () => { + canRun = (await isCommandAvailable("claude")) && hasClaudeCredentials; + }); - const turnStarted = events.find( + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); + + test("Test 1: Basic foreground turn", async () => { + const handle = await createSession({ cwdPrefix: "event-stream-basic-" }); + + try { + const { turnId, events } = await startTurnAndCollectEvents( + handle.session, + "respond with just the word hello", + ); + + const turnStarted = events.find( + (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId, + ); + expect(turnStarted).toBeDefined(); + + const terminal = events.find( + (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId, + ); + expect(terminal).toBeDefined(); + + assertInvariants(events, [turnId]); + } finally { + await cleanupSession(handle); + } + }, 60_000); + + test("Test 2: No duplicate user_messages — THE BUG", async () => { + const handle = await createSession({ cwdPrefix: "event-stream-dedup-" }); + + try { + const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", { + extraMs: 3_000, + }); + + expect(userMessagesWithText(events, "say hi").length).toBeLessThanOrEqual(1); + + // No turn_started after terminal for the same turnId + const terminalIdx = events.findIndex( + (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId, + ); + const staleTurnStarted = events + .slice(terminalIdx + 1) + .filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId); + expect(staleTurnStarted.length).toBe(0); + + assertInvariants(events, [turnId]); + } finally { + await cleanupSession(handle); + } + }, 60_000); + + test("Test 3: Lifecycle doesn't get stuck in running", async () => { + const handle = await createSession({ cwdPrefix: "event-stream-lifecycle-" }); + + try { + const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", { + extraMs: 3_000, + }); + + const terminalIdx = events.findIndex( + (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId, + ); + const afterTerminal = events.slice(terminalIdx + 1); + + // No subsequent turn_started for same turnId + expect( + afterTerminal.filter( (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId, - ); - expect(turnStarted).toBeDefined(); + ).length, + ).toBe(0); - const terminal = events.find( - (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId, - ); - expect(terminal).toBeDefined(); - - assertInvariants(events, [turnId]); - } finally { - await cleanupSession(handle); + // Any turn_started after terminal must have a different turnId + for (const ts of afterTerminal.filter((e) => e.type === "turn_started" && hasTurnId(e))) { + expect((ts as EventWithTurnId).turnId).not.toBe(turnId); } - }, - 60_000, - ); - test.skipIf(!canRun)( - "Test 2: No duplicate user_messages — THE BUG", - async () => { - const handle = await createSession({ cwdPrefix: "event-stream-dedup-" }); + assertInvariants(events, [turnId]); + } finally { + await cleanupSession(handle); + } + }, 60_000); - try { - const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", { - extraMs: 3_000, - }); + test("Test 4: Autonomous run", async () => { + const handle = await createSession({ cwdPrefix: "event-stream-autonomous-" }); + const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`; - expect(userMessagesWithText(events, "say hi").length).toBeLessThanOrEqual(1); + try { + const { turnId: fgTurnId, events } = await startTurnAndCollectEvents( + handle.session, + [ + "Use the Task tool to start a background sub-agent.", + "In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE", + "Do not wait for task completion.", + "Reply immediately with exactly: SPAWNED", + `When the background task completes later, reply with exactly: ${autonomousWakeToken}`, + ].join(" "), + { + extraMs: 10_000, + timeoutMs: 60_000, + }, + ); - // No turn_started after terminal for the same turnId - const terminalIdx = events.findIndex( - (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId, - ); - const staleTurnStarted = events - .slice(terminalIdx + 1) - .filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId); - expect(staleTurnStarted.length).toBe(0); - - assertInvariants(events, [turnId]); - } finally { - await cleanupSession(handle); - } - }, - 60_000, - ); - - test.skipIf(!canRun)( - "Test 3: Lifecycle doesn't get stuck in running", - async () => { - const handle = await createSession({ cwdPrefix: "event-stream-lifecycle-" }); - - try { - const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", { - extraMs: 3_000, - }); - - const terminalIdx = events.findIndex( - (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId, - ); - const afterTerminal = events.slice(terminalIdx + 1); - - // No subsequent turn_started for same turnId - expect( - afterTerminal.filter( - (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId, - ).length, - ).toBe(0); - - // Any turn_started after terminal must have a different turnId - for (const ts of afterTerminal.filter((e) => e.type === "turn_started" && hasTurnId(e))) { - expect((ts as EventWithTurnId).turnId).not.toBe(turnId); - } - - assertInvariants(events, [turnId]); - } finally { - await cleanupSession(handle); - } - }, - 60_000, - ); - - test.skipIf(!canRun)( - "Test 4: Autonomous run", - async () => { - const handle = await createSession({ cwdPrefix: "event-stream-autonomous-" }); - const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`; - - try { - const { turnId: fgTurnId, events } = await startTurnAndCollectEvents( - handle.session, - [ - "Use the Task tool to start a background sub-agent.", - "In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE", - "Do not wait for task completion.", - "Reply immediately with exactly: SPAWNED", - `When the background task completes later, reply with exactly: ${autonomousWakeToken}`, - ].join(" "), - { - extraMs: 10_000, - timeoutMs: 60_000, - }, - ); - - const fgTerminalIdx = events.findIndex( - (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === fgTurnId, - ); - const afterForeground = events.slice(fgTerminalIdx + 1); - - // Autonomous turn_started with a different turnId - const autoStarts = afterForeground.filter( - (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId, - ) as EventWithTurnId[]; - if (autoStarts.length === 0) { - assertInvariants(events, [fgTurnId]); - return; - } - - const autoTurnId = autoStarts[0]!.turnId; - expect(fgTurnId).not.toBe(autoTurnId); - - // Autonomous turn reaches terminal - expect( - afterForeground.find( - (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === autoTurnId, - ), - ).toBeDefined(); + const fgTerminalIdx = events.findIndex( + (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === fgTurnId, + ); + const afterForeground = events.slice(fgTerminalIdx + 1); + // Autonomous turn_started with a different turnId + const autoStarts = afterForeground.filter( + (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId, + ) as EventWithTurnId[]; + if (autoStarts.length === 0) { assertInvariants(events, [fgTurnId]); - } finally { - await cleanupSession(handle); + return; } - }, - 90_000, - ); - test.skipIf(!canRun)( - "Test 5: Interruption", - async () => { - const handle = await createSession({ cwdPrefix: "event-stream-interrupt-" }); + const autoTurnId = autoStarts[0]!.turnId; + expect(fgTurnId).not.toBe(autoTurnId); - try { - let turnId: string | null = null; - const events = await new Promise((resolve, reject) => { - const collected: AgentStreamEvent[] = []; - let interrupted = false; + // Autonomous turn reaches terminal + expect( + afterForeground.find((e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === autoTurnId), + ).toBeDefined(); - const timeout = setTimeout(() => { + assertInvariants(events, [fgTurnId]); + } finally { + await cleanupSession(handle); + } + }, 90_000); + + test("Test 5: Interruption", async () => { + const handle = await createSession({ cwdPrefix: "event-stream-interrupt-" }); + + try { + let turnId: string | null = null; + const events = await new Promise((resolve, reject) => { + const collected: AgentStreamEvent[] = []; + let interrupted = false; + + const timeout = setTimeout(() => { + unsubscribe(); + reject(new Error("Timed out after 45000ms waiting for terminal event")); + }, 45_000); + + const unsubscribe = handle.session.subscribe((event) => { + collected.push(event); + if (!turnId && event.type === "turn_started" && hasTurnId(event)) { + turnId = event.turnId; + } + + // Once we see turn_started, fire the interrupt + if ( + !interrupted && + turnId && + event.type === "turn_started" && + hasTurnId(event) && + event.turnId === turnId + ) { + interrupted = true; + handle.session.interrupt().catch(() => undefined); + } + + // Resolve when we get a terminal event for this turn + if (turnId && isTerminalEvent(event) && hasTurnId(event) && event.turnId === turnId) { + clearTimeout(timeout); unsubscribe(); - reject(new Error("Timed out after 45000ms waiting for terminal event")); - }, 45_000); + resolve(collected); + } + }); - const unsubscribe = handle.session.subscribe((event) => { - collected.push(event); - if (!turnId && event.type === "turn_started" && hasTurnId(event)) { - turnId = event.turnId; - } - - // Once we see turn_started, fire the interrupt - if ( - !interrupted && - turnId && - event.type === "turn_started" && - hasTurnId(event) && - event.turnId === turnId - ) { - interrupted = true; - handle.session.interrupt().catch(() => undefined); - } - - // Resolve when we get a terminal event for this turn - if (turnId && isTerminalEvent(event) && hasTurnId(event) && event.turnId === turnId) { + void handle.session + .startTurn("write a very long essay about the history of computing") + .then((result) => { + if (turnId && turnId !== result.turnId) { clearTimeout(timeout); unsubscribe(); - resolve(collected); + reject( + new Error( + `Observed turn_started for ${turnId} but startTurn returned ${result.turnId}`, + ), + ); + return; } + turnId = result.turnId; + }) + .catch((error) => { + clearTimeout(timeout); + unsubscribe(); + reject(error); }); + }); - void handle.session - .startTurn("write a very long essay about the history of computing") - .then((result) => { - if (turnId && turnId !== result.turnId) { - clearTimeout(timeout); - unsubscribe(); - reject( - new Error( - `Observed turn_started for ${turnId} but startTurn returned ${result.turnId}`, - ), - ); - return; - } - turnId = result.turnId; - }) - .catch((error) => { - clearTimeout(timeout); - unsubscribe(); - reject(error); - }); - }); + expect(turnId).toBeDefined(); - expect(turnId).toBeDefined(); + // turn_canceled or turn_failed arrives for that turnId + const terminal = events.find( + (e) => + (e.type === "turn_canceled" || e.type === "turn_failed") && + hasTurnId(e) && + e.turnId === turnId, + ); + expect(terminal).toBeDefined(); - // turn_canceled or turn_failed arrives for that turnId - const terminal = events.find( - (e) => - (e.type === "turn_canceled" || e.type === "turn_failed") && - hasTurnId(e) && - e.turnId === turnId, - ); - expect(terminal).toBeDefined(); + // No further events for that turnId after terminal + const terminalIdx = events.indexOf(terminal!); + expect( + events.slice(terminalIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId).length, + ).toBe(0); - // No further events for that turnId after terminal - const terminalIdx = events.indexOf(terminal!); - expect( - events.slice(terminalIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId).length, - ).toBe(0); + assertInvariants(events, [turnId]); + } finally { + await cleanupSession(handle); + } + }, 60_000); - assertInvariants(events, [turnId]); - } finally { - await cleanupSession(handle); - } - }, - 60_000, - ); + test("Test 6: Sequential foreground turns", async () => { + const handle = await createSession({ cwdPrefix: "event-stream-sequential-" }); - test.skipIf(!canRun)( - "Test 6: Sequential foreground turns", - async () => { - const handle = await createSession({ cwdPrefix: "event-stream-sequential-" }); + try { + const { turnId: turnId1, events: events1 } = await startTurnAndCollectEvents( + handle.session, + "say first", + ); - try { - const { turnId: turnId1, events: events1 } = await startTurnAndCollectEvents( - handle.session, - "say first", - ); + const { turnId: turnId2, events: events2 } = await startTurnAndCollectEvents( + handle.session, + "say second", + ); - const { turnId: turnId2, events: events2 } = await startTurnAndCollectEvents( - handle.session, - "say second", - ); + const allEvents = [...events1, ...events2]; - const allEvents = [...events1, ...events2]; + expect(turnId1).not.toBe(turnId2); - expect(turnId1).not.toBe(turnId2); + // No events from turn 1 after turn 2 starts + const turn2StartIdx = allEvents.findIndex( + (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId2, + ); + expect( + allEvents.slice(turn2StartIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId1) + .length, + ).toBe(0); - // No events from turn 1 after turn 2 starts - const turn2StartIdx = allEvents.findIndex( - (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId2, - ); - expect( - allEvents.slice(turn2StartIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId1) - .length, - ).toBe(0); + assertInvariants(allEvents, [turnId1, turnId2]); + } finally { + await cleanupSession(handle); + } + }, 90_000); - assertInvariants(allEvents, [turnId1, turnId2]); - } finally { - await cleanupSession(handle); - } - }, - 90_000, - ); + test("Test 7: Fast-fail", async () => { + const handle = await createSession({ cwdPrefix: "event-stream-fast-fail-" }); - test.skipIf(!canRun)( - "Test 7: Fast-fail", - async () => { - const handle = await createSession({ cwdPrefix: "event-stream-fast-fail-" }); + try { + const { turnId, events } = await startTurnAndCollectEvents(handle.session, "", { + extraMs: 3_000, + }); - try { - const { turnId, events } = await startTurnAndCollectEvents(handle.session, "", { - extraMs: 3_000, - }); + // At most one turn_started + expect( + events.filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId) + .length, + ).toBeLessThanOrEqual(1); - // At most one turn_started - expect( - events.filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId) - .length, - ).toBeLessThanOrEqual(1); + // Terminal present + const terminal = events.find( + (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId, + ); + expect(terminal).toBeDefined(); - // Terminal present - const terminal = events.find( - (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId, - ); - expect(terminal).toBeDefined(); + // No stale turn_started after terminal + const terminalIdx = events.indexOf(terminal!); + expect(events.slice(terminalIdx + 1).filter((e) => e.type === "turn_started").length).toBe(0); - // No stale turn_started after terminal - const terminalIdx = events.indexOf(terminal!); - expect(events.slice(terminalIdx + 1).filter((e) => e.type === "turn_started").length).toBe( - 0, - ); + assertInvariants(events, [turnId]); + } finally { + await cleanupSession(handle); + } + }, 60_000); - assertInvariants(events, [turnId]); - } finally { - await cleanupSession(handle); - } - }, - 60_000, - ); + test("Test 8: User message dedup by text", async () => { + const handle = await createSession({ cwdPrefix: "event-stream-user-dedup-" }); - test.skipIf(!canRun)( - "Test 8: User message dedup by text", - async () => { - const handle = await createSession({ cwdPrefix: "event-stream-user-dedup-" }); + try { + const { turnId, events } = await startTurnAndCollectEvents(handle.session, "hello world", { + extraMs: 3_000, + }); - try { - const { turnId, events } = await startTurnAndCollectEvents(handle.session, "hello world", { - extraMs: 3_000, - }); + expect(userMessagesWithText(events, "hello world").length).toBeLessThanOrEqual(1); - expect(userMessagesWithText(events, "hello world").length).toBeLessThanOrEqual(1); - - assertInvariants(events, [turnId]); - } finally { - await cleanupSession(handle); - } - }, - 60_000, - ); + assertInvariants(events, [turnId]); + } finally { + await cleanupSession(handle); + } + }, 60_000); }); diff --git a/packages/server/src/server/agent/providers/claude-agent-commands.real.e2e.test.ts b/packages/server/src/server/agent/providers/claude-agent-commands.real.e2e.test.ts index 6bdbe3a14..8feb0c95e 100644 --- a/packages/server/src/server/agent/providers/claude-agent-commands.real.e2e.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent-commands.real.e2e.test.ts @@ -1,14 +1,26 @@ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import pino from "pino"; import type { AgentSlashCommand } from "../agent-sdk-types.js"; -import { isCommandAvailableSync } from "../../../utils/executable.js"; +import { isCommandAvailable } from "../../../utils/executable.js"; import { ClaudeAgentClient } from "./claude-agent.js"; // Real-Claude contract coverage: validates slash command shape from a live Claude CLI session. describe("claude agent commands contract (real)", () => { + let canRun = false; + + beforeAll(async () => { + canRun = await isCommandAvailable("claude"); + }); + + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); + test("lists slash commands with the expected contract", async () => { - expect(isCommandAvailableSync("claude")).toBe(true); + expect(await isCommandAvailable("claude")).toBe(true); const client = new ClaudeAgentClient({ logger: pino({ level: "silent" }), diff --git a/packages/server/src/server/agent/providers/claude-agent.integration.test.ts b/packages/server/src/server/agent/providers/claude-agent.integration.test.ts index 74d25c9fe..264d09960 100644 --- a/packages/server/src/server/agent/providers/claude-agent.integration.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.integration.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, beforeAll } from "vitest"; +import { describe, expect, test, beforeAll, beforeEach } from "vitest"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -6,7 +6,7 @@ import pino from "pino"; import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; import type { AgentSession, AgentStreamEvent, ToolCallTimelineItem } from "../agent-sdk-types.js"; -import { isCommandAvailableSync } from "../../../utils/executable.js"; +import { isCommandAvailable } from "../../../utils/executable.js"; import { ClaudeAgentClient } from "./claude-agent.js"; import { streamSession } from "./test-utils/session-stream-adapter.js"; @@ -174,111 +174,106 @@ async function cleanupSession(handle: { cwd: string; session: AgentSession }): P } describe("ClaudeAgentSession integration", () => { - const canRunClaudeIntegration = isCommandAvailableSync("claude") && hasClaudeCredentials; + let canRunClaudeIntegration = false; - beforeAll(() => { + beforeAll(async () => { + canRunClaudeIntegration = (await isCommandAvailable("claude")) && hasClaudeCredentials; if (canRunClaudeIntegration) { - expect(isCommandAvailableSync("claude")).toBe(true); + expect(await isCommandAvailable("claude")).toBe(true); } }); - test.runIf(canRunClaudeIntegration)( - "streams a basic response turn end-to-end", - async () => { - const handle = await createSession({ - cwdPrefix: "claude-agent-basic-response-", + beforeEach((context) => { + if (!canRunClaudeIntegration) { + context.skip(); + } + }); + + test("streams a basic response turn end-to-end", async () => { + const handle = await createSession({ + cwdPrefix: "claude-agent-basic-response-", + }); + + try { + const events = await collectUntilTerminal( + streamSession(handle.session, "Respond with exactly: HELLO_WORLD"), + ); + + expect(events[0]).toMatchObject({ + type: "turn_started", + provider: "claude", }); - - try { - const events = await collectUntilTerminal( - streamSession(handle.session, "Respond with exactly: HELLO_WORLD"), - ); - - expect(events[0]).toMatchObject({ - type: "turn_started", - provider: "claude", - }); - expect( - events.some( - (event) => - event.type === "timeline" && - event.item.type === "assistant_message" && - compactText(event.item.text).includes("hello_world"), - ), - ).toBe(true); - expect(events.at(-1)).toMatchObject({ - type: "turn_completed", - provider: "claude", - }); - } finally { - await cleanupSession(handle); - } - }, - 60_000, - ); - - test.runIf(canRunClaudeIntegration)( - "keeps bypassPermissions available after a thinking-option restart", - async () => { - const handle = await createSession({ - cwdPrefix: "claude-agent-bypass-restart-", - modeId: "bypassPermissions", + expect( + events.some( + (event) => + event.type === "timeline" && + event.item.type === "assistant_message" && + compactText(event.item.text).includes("hello_world"), + ), + ).toBe(true); + expect(events.at(-1)).toMatchObject({ + type: "turn_completed", + provider: "claude", }); + } finally { + await cleanupSession(handle); + } + }, 60_000); - try { - await handle.session.setMode("acceptEdits"); - await handle.session.setThinkingOption("high"); - await expect(handle.session.setMode("bypassPermissions")).resolves.toBeUndefined(); - } finally { - await cleanupSession(handle); - } - }, - 60_000, - ); + test("keeps bypassPermissions available after a thinking-option restart", async () => { + const handle = await createSession({ + cwdPrefix: "claude-agent-bypass-restart-", + modeId: "bypassPermissions", + }); - test.runIf(canRunClaudeIntegration)( - "supportedModels returns the current abstract Claude SDK model shape", - async () => { - const claudeQuery = query({ - prompt: createEmptyPrompt(), - options: { - cwd: process.cwd(), - permissionMode: "plan", - includePartialMessages: false, - settingSources: ["user", "project"], - }, - }); + try { + await handle.session.setMode("acceptEdits"); + await handle.session.setThinkingOption("high"); + await expect(handle.session.setMode("bypassPermissions")).resolves.toBeUndefined(); + } finally { + await cleanupSession(handle); + } + }, 60_000); - try { - const models = await claudeQuery.supportedModels(); + test("supportedModels returns the current abstract Claude SDK model shape", async () => { + const claudeQuery = query({ + prompt: createEmptyPrompt(), + options: { + cwd: process.cwd(), + permissionMode: "plan", + includePartialMessages: false, + settingSources: ["user", "project"], + }, + }); - expect(models.length).toBeGreaterThanOrEqual(3); - expect(models).toContainEqual( - expect.objectContaining({ - value: "default", - displayName: "Default (recommended)", - supportedEffortLevels: ["low", "medium", "high", "max"], - }), - ); - expect(models).toContainEqual( - expect.objectContaining({ - value: "haiku", - displayName: "Haiku", - description: expect.stringContaining("Haiku 4.5"), - }), - ); - expect( - models.some( - (model) => - model.description.includes("Opus 4.6") || model.description.includes("Sonnet 4.6"), - ), - ).toBe(true); - } finally { - await claudeQuery.return?.(); - } - }, - 60_000, - ); + try { + const models = await claudeQuery.supportedModels(); + + expect(models.length).toBeGreaterThanOrEqual(3); + expect(models).toContainEqual( + expect.objectContaining({ + value: "default", + displayName: "Default (recommended)", + supportedEffortLevels: ["low", "medium", "high", "max"], + }), + ); + expect(models).toContainEqual( + expect.objectContaining({ + value: "haiku", + displayName: "Haiku", + description: expect.stringContaining("Haiku 4.5"), + }), + ); + expect( + models.some( + (model) => + model.description.includes("Opus 4.6") || model.description.includes("Sonnet 4.6"), + ), + ).toBe(true); + } finally { + await claudeQuery.return?.(); + } + }, 60_000); test.runIf(canRunClaudeIntegration)( "runs a real Bash tool call and completes it", diff --git a/packages/server/src/server/agent/providers/claude-agent.max-effort.real.e2e.test.ts b/packages/server/src/server/agent/providers/claude-agent.max-effort.real.e2e.test.ts index 47f0ea9fc..1cf02a848 100644 --- a/packages/server/src/server/agent/providers/claude-agent.max-effort.real.e2e.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.max-effort.real.e2e.test.ts @@ -1,8 +1,8 @@ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import pino from "pino"; import type { AgentStreamEvent, AgentSession } from "../agent-sdk-types.js"; -import { isCommandAvailableSync } from "../../../utils/executable.js"; +import { isCommandAvailable } from "../../../utils/executable.js"; import { ClaudeAgentClient } from "./claude-agent.js"; import { streamSession } from "./test-utils/session-stream-adapter.js"; @@ -29,35 +29,43 @@ async function collectUntilTerminal(session: AgentSession): Promise { - test.runIf(isCommandAvailableSync("claude") && hasClaudeCredentials)( - "surfaces the Claude stderr diagnostic when bypassPermissions + max effort is unavailable", - async () => { - const client = new ClaudeAgentClient({ - logger: pino({ level: "silent" }), - }); - const session = await client.createSession({ - provider: "claude", - cwd: process.cwd(), - modeId: "bypassPermissions", - model: "claude-opus-4-6", - thinkingOptionId: "max", - }); + let canRun = false; - try { - const events = await collectUntilTerminal(session); - const failure = events.find( - (event): event is Extract => - event.type === "turn_failed", - ); + beforeAll(async () => { + canRun = (await isCommandAvailable("claude")) && hasClaudeCredentials; + }); - expect(failure).toBeDefined(); - expect(failure?.error).toContain("Claude Code process exited with code 1"); - expect(failure?.code).toBe("1"); - expect(failure?.diagnostic).toContain('Effort level "max" is not available'); - } finally { - await session.close().catch(() => undefined); - } - }, - 30_000, - ); + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); + + test("surfaces the Claude stderr diagnostic when bypassPermissions + max effort is unavailable", async () => { + const client = new ClaudeAgentClient({ + logger: pino({ level: "silent" }), + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + modeId: "bypassPermissions", + model: "claude-opus-4-6", + thinkingOptionId: "max", + }); + + try { + const events = await collectUntilTerminal(session); + const failure = events.find( + (event): event is Extract => + event.type === "turn_failed", + ); + + expect(failure).toBeDefined(); + expect(failure?.error).toContain("Claude Code process exited with code 1"); + expect(failure?.code).toBe("1"); + expect(failure?.diagnostic).toContain('Effort level "max" is not available'); + } finally { + await session.close().catch(() => undefined); + } + }, 30_000); }); diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 935583581..897e5a432 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -1,5 +1,4 @@ -import { execFile, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { promisify } from "node:util"; +import { type ChildProcessWithoutNullStreams } from "node:child_process"; import { randomUUID } from "node:crypto"; import fs from "node:fs"; import { promises } from "node:fs"; @@ -73,12 +72,11 @@ import type { PersistedAgentDescriptor, } from "../agent-sdk-types.js"; import { applyProviderEnv, type ProviderRuntimeSettings } from "../provider-launch-config.js"; -import { findExecutable } from "../../../utils/executable.js"; -import { spawnProcess } from "../../../utils/spawn.js"; +import { executableExists, findExecutable } from "../../../utils/executable.js"; +import { execCommand, spawnProcess } from "../../../utils/spawn.js"; import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js"; const fsPromises = promises; -const execFileAsync = promisify(execFile); const CLAUDE_SETTING_SOURCES: NonNullable = ["user", "project"]; type TurnState = "idle" | "foreground" | "autonomous"; @@ -1130,7 +1128,7 @@ export class ClaudeAgentClient implements AgentClient { async isAvailable(): Promise { const command = this.runtimeSettings?.command; if (command?.mode === "replace") { - return fs.existsSync(command.argv[0]); + return executableExists(command.argv[0]) !== null; } return true; } @@ -1186,14 +1184,10 @@ async function resolveClaudeVersion( try { if (command?.mode === "replace") { - const { stdout } = await execFileAsync( + const { stdout } = await execCommand( command.argv[0]!, [...command.argv.slice(1), "--version"], - { - encoding: "utf8", - timeout: 5_000, - windowsHide: true, - }, + { timeout: 5_000 }, ); return stdout.trim() || null; } @@ -1203,11 +1197,7 @@ async function resolveClaudeVersion( return null; } - const { stdout } = await execFileAsync(executable, ["--version"], { - encoding: "utf8", - timeout: 5_000, - windowsHide: true, - }); + const { stdout } = await execCommand(executable, ["--version"], { timeout: 5_000 }); return stdout.trim() || null; } catch { return null; diff --git a/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts b/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts index df2f2bcfe..4665c6bd1 100644 --- a/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts +++ b/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts @@ -4,9 +4,9 @@ import { mkdtempSync, rmSync, realpathSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { beforeAll, describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { query, type SDKMessage, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; -import { findExecutable, isCommandAvailableSync } from "../../../utils/executable.js"; +import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; class Pushable implements AsyncIterable { private queue: T[] = []; @@ -57,100 +57,103 @@ const hasClaudeCredentials = !!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY; describe("Claude SDK direct behavior", () => { - const canRunClaudeIntegration = isCommandAvailableSync("claude") && hasClaudeCredentials; + let canRunClaudeIntegration = false; - beforeAll(() => { + beforeAll(async () => { + canRunClaudeIntegration = (await isCommandAvailable("claude")) && hasClaudeCredentials; if (canRunClaudeIntegration) { - expect(isCommandAvailableSync("claude")).toBe(true); + expect(await isCommandAvailable("claude")).toBe(true); } }); - test.runIf(canRunClaudeIntegration)( - "shows what happens after interrupt()", - async () => { - const cwd = tmpCwd(); - const input = new Pushable(); - const claudeBinary = await findExecutable("claude"); + beforeEach((context) => { + if (!canRunClaudeIntegration) { + context.skip(); + } + }); - // Use same options as claude-agent.ts - const q = query({ - prompt: input, - options: { - cwd, - includePartialMessages: true, - permissionMode: "bypassPermissions", - ...(claudeBinary ? { pathToClaudeCodeExecutable: claudeBinary } : {}), - systemPrompt: { - type: "preset", - preset: "claude_code", - }, - settingSources: ["user", "project"], + test("shows what happens after interrupt()", async () => { + const cwd = tmpCwd(); + const input = new Pushable(); + const claudeBinary = await findExecutable("claude"); + + // Use same options as claude-agent.ts + const q = query({ + prompt: input, + options: { + cwd, + includePartialMessages: true, + permissionMode: "bypassPermissions", + ...(claudeBinary ? { pathToClaudeCodeExecutable: claudeBinary } : {}), + systemPrompt: { + type: "preset", + preset: "claude_code", }, + settingSources: ["user", "project"], + }, + }); + + try { + // Send first message + input.push({ + type: "user", + message: { role: "user", content: "Say exactly: MESSAGE_ONE" }, + parent_tool_use_id: null, + session_id: "", }); - try { - // Send first message - input.push({ - type: "user", - message: { role: "user", content: "Say exactly: MESSAGE_ONE" }, - parent_tool_use_id: null, - session_id: "", - }); + // Collect events until we see assistant, then interrupt + const msg1Events: SDKMessage[] = []; + for await (const event of q) { + msg1Events.push(event); - // Collect events until we see assistant, then interrupt - const msg1Events: SDKMessage[] = []; - for await (const event of q) { - msg1Events.push(event); - - if (event.type === "assistant") { - // Push MSG2 BEFORE interrupt (like our wrapper does when a new message comes in) - input.push({ - type: "user", - message: { role: "user", content: "Say exactly: MESSAGE_TWO" }, - parent_tool_use_id: null, - session_id: "", - }); - await q.interrupt(); - break; - } - if (event.type === "result") { - break; - } + if (event.type === "assistant") { + // Push MSG2 BEFORE interrupt (like our wrapper does when a new message comes in) + input.push({ + type: "user", + message: { role: "user", content: "Say exactly: MESSAGE_TWO" }, + parent_tool_use_id: null, + session_id: "", + }); + await q.interrupt(); + break; } - - // MSG2 was already pushed before interrupt - const msg2Events: SDKMessage[] = []; - for await (const event of q) { - msg2Events.push(event); - - if (event.type === "result") { - break; - } + if (event.type === "result") { + break; } + } - // Analyze response - let responseText = ""; - for (const event of msg2Events) { - if (event.type === "assistant" && "message" in event && event.message?.content) { - const content = event.message.content; - if (Array.isArray(content)) { - for (const block of content) { - if (block.type === "text" && block.text) { - responseText += block.text; - } + // MSG2 was already pushed before interrupt + const msg2Events: SDKMessage[] = []; + for await (const event of q) { + msg2Events.push(event); + + if (event.type === "result") { + break; + } + } + + // Analyze response + let responseText = ""; + for (const event of msg2Events) { + if (event.type === "assistant" && "message" in event && event.message?.content) { + const content = event.message.content; + if (Array.isArray(content)) { + for (const block of content) { + if (block.type === "text" && block.text) { + responseText += block.text; } } } } - - const sawResult = msg2Events.some((event) => event.type === "result"); - // The SDK may short-circuit after interrupt without a result event. - expect(sawResult || responseText.length === 0).toBe(true); - } finally { - input.end(); - rmSync(cwd, { recursive: true, force: true }); } - }, - 120000, - ); + + const sawResult = msg2Events.some((event) => event.type === "result"); + // The SDK may short-circuit after interrupt without a result event. + expect(sawResult || responseText.length === 0).toBe(true); + } finally { + input.end(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 120000); }); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 13c32633f..ae793a462 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -31,7 +31,7 @@ import type { Logger } from "pino"; import { execSync } from "node:child_process"; import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { existsSync, Dirent } from "node:fs"; +import { Dirent } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -47,7 +47,7 @@ import { resolveProviderCommandPrefix, type ProviderRuntimeSettings, } from "../provider-launch-config.js"; -import { findExecutable } from "../../../utils/executable.js"; +import { executableExists, findExecutable } from "../../../utils/executable.js"; import { spawnProcess } from "../../../utils/spawn.js"; import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js"; import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js"; @@ -4203,7 +4203,7 @@ export class CodexAppServerAgentClient implements AgentClient { async isAvailable(): Promise { const command = this.runtimeSettings?.command; if (command?.mode === "replace") { - return existsSync(command.argv[0]); + return executableExists(command.argv[0]) !== null; } return true; } diff --git a/packages/server/src/server/agent/providers/codex-plan-mode.real.e2e.test.ts b/packages/server/src/server/agent/providers/codex-plan-mode.real.e2e.test.ts index 1846281b6..dd87d7137 100644 --- a/packages/server/src/server/agent/providers/codex-plan-mode.real.e2e.test.ts +++ b/packages/server/src/server/agent/providers/codex-plan-mode.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -12,53 +12,61 @@ function tmpCwd(): string { } describe("Codex app-server provider (real) plan mode", () => { - test.runIf(isProviderAvailable("codex"))( - "maps gpt-5.4 markdown plans to a plan tool call instead of todo items", - async () => { - const cwd = tmpCwd(); - const client = new CodexAppServerAgentClient(createTestLogger()); + let canRun = false; + + beforeAll(async () => { + canRun = await isProviderAvailable("codex"); + }); + + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); + + test("maps gpt-5.4 markdown plans to a plan tool call instead of todo items", async () => { + const cwd = tmpCwd(); + const client = new CodexAppServerAgentClient(createTestLogger()); + + try { + const session = await client.createSession({ + provider: "codex", + cwd, + modeId: "auto", + model: "gpt-5.4", + thinkingOptionId: "medium", + }); try { - const session = await client.createSession({ - provider: "codex", - cwd, - modeId: "auto", - model: "gpt-5.4", - thinkingOptionId: "medium", - }); + await session.setFeature?.("plan_mode", true); - try { - await session.setFeature?.("plan_mode", true); + const result = await session.run( + "You are in plan mode. Produce a markdown plan with a short heading and exactly 3 bullets for implementing a login screen. Do not ask questions.", + ); - const result = await session.run( - "You are in plan mode. Produce a markdown plan with a short heading and exactly 3 bullets for implementing a login screen. Do not ask questions.", - ); + expect(result.timeline).not.toContainEqual( + expect.objectContaining({ + type: "todo", + }), + ); - expect(result.timeline).not.toContainEqual( - expect.objectContaining({ - type: "todo", - }), - ); + const planCall = result.timeline.find( + (item) => item.type === "tool_call" && item.detail.type === "plan", + ); - const planCall = result.timeline.find( - (item) => item.type === "tool_call" && item.detail.type === "plan", - ); - - expect(planCall).toBeDefined(); - if (!planCall || planCall.type !== "tool_call" || planCall.detail.type !== "plan") { - throw new Error("Expected a plan tool call"); - } - - expect(planCall.detail.text).toContain("Login"); - expect(planCall.detail.text).toContain("- "); - expect(result.finalText).toBe(planCall.detail.text); - } finally { - await session.close(); + expect(planCall).toBeDefined(); + if (!planCall || planCall.type !== "tool_call" || planCall.detail.type !== "plan") { + throw new Error("Expected a plan tool call"); } + + expect(planCall.detail.text).toContain("Login"); + expect(planCall.detail.text).toContain("- "); + expect(result.finalText).toBe(planCall.detail.text); } finally { - rmSync(cwd, { recursive: true, force: true }); + await session.close(); } - }, - 240_000, - ); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, 240_000); }); diff --git a/packages/server/src/server/agent/providers/diagnostic-utils.ts b/packages/server/src/server/agent/providers/diagnostic-utils.ts index ed12c63ee..a21f54bea 100644 --- a/packages/server/src/server/agent/providers/diagnostic-utils.ts +++ b/packages/server/src/server/agent/providers/diagnostic-utils.ts @@ -1,9 +1,5 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; - import type { ProviderRuntimeSettings } from "../provider-launch-config.js"; - -const execFileAsync = promisify(execFile); +import { execCommand } from "../../../utils/spawn.js"; type DiagnosticEntry = { label: string; @@ -49,11 +45,7 @@ export function toDiagnosticErrorMessage(error: unknown): string { export async function resolveBinaryVersion(binaryPath: string): Promise { try { - const { stdout } = await execFileAsync(binaryPath, ["--version"], { - encoding: "utf8", - timeout: 5_000, - windowsHide: true, - }); + const { stdout } = await execCommand(binaryPath, ["--version"], { timeout: 5_000 }); return stdout.trim() || "unknown"; } catch { return "unknown"; diff --git a/packages/server/src/server/agent/providers/opencode-agent-commands.real.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-agent-commands.real.e2e.test.ts index 3ee10d777..c54264978 100644 --- a/packages/server/src/server/agent/providers/opencode-agent-commands.real.e2e.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent-commands.real.e2e.test.ts @@ -1,13 +1,25 @@ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import pino from "pino"; import type { AgentSlashCommand } from "../agent-sdk-types.js"; -import { isCommandAvailableSync } from "../../../utils/executable.js"; +import { isCommandAvailable } from "../../../utils/executable.js"; import { OpenCodeAgentClient } from "./opencode-agent.js"; describe("opencode agent commands contract (real)", () => { + let canRun = false; + + beforeAll(async () => { + canRun = await isCommandAvailable("opencode"); + }); + + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); + test("lists slash commands with the expected contract", async () => { - expect(isCommandAvailableSync("opencode")).toBe(true); + expect(await isCommandAvailable("opencode")).toBe(true); const client = new OpenCodeAgentClient(pino({ level: "silent" })); const session = await client.createSession({ @@ -37,7 +49,7 @@ describe("opencode agent commands contract (real)", () => { }, 60_000); test("executes a slash command without arguments", async () => { - expect(isCommandAvailableSync("opencode")).toBe(true); + expect(await isCommandAvailable("opencode")).toBe(true); const client = new OpenCodeAgentClient(pino({ level: "silent" })); const session = await client.createSession({ diff --git a/packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts index 0f944b0c2..3874568c2 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts @@ -1,8 +1,8 @@ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import pino from "pino"; import type { AgentStreamEvent } from "../agent-sdk-types.js"; -import { isCommandAvailableSync } from "../../../utils/executable.js"; +import { isCommandAvailable } from "../../../utils/executable.js"; import { OpenCodeAgentClient } from "./opencode-agent.js"; import { streamSession } from "./test-utils/session-stream-adapter.js"; @@ -21,166 +21,158 @@ function isTerminalEvent(event: AgentStreamEvent): boolean { * when models are invalid, auth fails, or provider API calls fail. */ describe("opencode agent error handling (real)", () => { - test.runIf(isCommandAvailableSync("opencode"))( - "surfaces error for the exact opencode/ path used by paseo run", - async () => { - const client = new OpenCodeAgentClient(pino({ level: "silent" })); - const session = await client.createSession({ - provider: "opencode", - cwd: process.cwd(), - modeId: "build", - }); + let canRun = false; - try { - await session.setModel("opencode/adklasldkdas"); + beforeAll(async () => { + canRun = await isCommandAvailable("opencode"); + }); - const events: AgentStreamEvent[] = []; - for await (const event of streamSession(session, "hello")) { - events.push(event); - if (isTerminalEvent(event)) break; - } + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); - const terminal = events.find(isTerminalEvent); - expect(terminal).toBeDefined(); - expect(terminal!.type).toBe("turn_failed"); - } finally { - await session.close().catch(() => undefined); - } - }, - 45_000, - ); + test("surfaces error for the exact opencode/ path used by paseo run", async () => { + const client = new OpenCodeAgentClient(pino({ level: "silent" })); + const session = await client.createSession({ + provider: "opencode", + cwd: process.cwd(), + modeId: "build", + }); - test.runIf(isCommandAvailableSync("opencode"))( - "surfaces error for unknown provider model (fast path)", - async () => { - const client = new OpenCodeAgentClient(pino({ level: "silent" })); - const session = await client.createSession({ - provider: "opencode", - cwd: process.cwd(), - modeId: "build", - }); - - try { - await session.setModel("bogus-provider/totally-fake-model-12345"); - - const events: AgentStreamEvent[] = []; - for await (const event of streamSession(session, "Say hello")) { - events.push(event); - if (isTerminalEvent(event)) break; - } - - const terminal = events.find(isTerminalEvent); - expect(terminal).toBeDefined(); - expect(terminal!.type).toBe("turn_failed"); - } finally { - await session.close().catch(() => undefined); - } - }, - 30_000, - ); - - test.runIf(isCommandAvailableSync("opencode"))( - "sequential sessions: second session works after first errors", - async () => { - const client = new OpenCodeAgentClient(pino({ level: "silent" })); - - // Session 1: bogus model, will error quickly - const s1 = await client.createSession({ - provider: "opencode", - cwd: process.cwd(), - modeId: "build", - }); - await s1.setModel("bogus-provider/fake-model-12345"); - for await (const event of streamSession(s1, "Say hello")) { - if (isTerminalEvent(event)) break; - } - await s1.close(); - - // Session 2: different bogus model, should also work - const s2 = await client.createSession({ - provider: "opencode", - cwd: process.cwd(), - modeId: "build", - }); - await s2.setModel("bogus-provider/fake-model-67890"); + try { + await session.setModel("opencode/adklasldkdas"); const events: AgentStreamEvent[] = []; - for await (const event of streamSession(s2, "Say hello")) { + for await (const event of streamSession(session, "hello")) { events.push(event); if (isTerminalEvent(event)) break; } - await s2.close().catch(() => undefined); const terminal = events.find(isTerminalEvent); expect(terminal).toBeDefined(); expect(terminal!.type).toBe("turn_failed"); - }, - 30_000, - ); + } finally { + await session.close().catch(() => undefined); + } + }, 45_000); - test.runIf(isCommandAvailableSync("opencode"))( - "surfaces error for known provider with nonexistent model (retry path)", - async () => { - // When the provider is recognized (anthropic) but the model doesn't exist, - // OpenCode retries before surfacing the error. This must not hang. - const client = new OpenCodeAgentClient(pino({ level: "silent" })); - const session = await client.createSession({ - provider: "opencode", - cwd: process.cwd(), - modeId: "build", - }); + test("surfaces error for unknown provider model (fast path)", async () => { + const client = new OpenCodeAgentClient(pino({ level: "silent" })); + const session = await client.createSession({ + provider: "opencode", + cwd: process.cwd(), + modeId: "build", + }); - try { - await session.setModel("anthropic/claude-nonexistent-99"); + try { + await session.setModel("bogus-provider/totally-fake-model-12345"); - const events: AgentStreamEvent[] = []; - const start = Date.now(); - for await (const event of streamSession(session, "Say hello")) { - events.push(event); - if (isTerminalEvent(event)) break; - } - const elapsed = Date.now() - start; - - const terminal = events.find(isTerminalEvent); - expect(terminal).toBeDefined(); - expect(elapsed).toBeLessThan(30_000); - console.log(`[nonexistent model] elapsed=${elapsed}ms terminal=${terminal!.type}`); - } finally { - await session.close().catch(() => undefined); + const events: AgentStreamEvent[] = []; + for await (const event of streamSession(session, "Say hello")) { + events.push(event); + if (isTerminalEvent(event)) break; } - }, - 45_000, - ); - test.runIf(isCommandAvailableSync("opencode"))( - "surfaces fatal retry status from zai/glm-5.1 instead of hanging forever", - async () => { - const client = new OpenCodeAgentClient(pino({ level: "silent" })); - const session = await client.createSession({ - provider: "opencode", - cwd: process.cwd(), - modeId: "build", - }); + const terminal = events.find(isTerminalEvent); + expect(terminal).toBeDefined(); + expect(terminal!.type).toBe("turn_failed"); + } finally { + await session.close().catch(() => undefined); + } + }, 30_000); - try { - await session.setModel("zai/glm-5.1"); + test("sequential sessions: second session works after first errors", async () => { + const client = new OpenCodeAgentClient(pino({ level: "silent" })); - const events: AgentStreamEvent[] = []; - for await (const event of streamSession(session, "Say hello")) { - events.push(event); - if (isTerminalEvent(event)) break; - } + // Session 1: bogus model, will error quickly + const s1 = await client.createSession({ + provider: "opencode", + cwd: process.cwd(), + modeId: "build", + }); + await s1.setModel("bogus-provider/fake-model-12345"); + for await (const event of streamSession(s1, "Say hello")) { + if (isTerminalEvent(event)) break; + } + await s1.close(); - const terminal = events.find(isTerminalEvent); - expect(terminal).toBeDefined(); - expect(terminal!.type).toBe("turn_failed"); - expect((terminal!.type === "turn_failed" ? terminal!.error : "").toLowerCase()).toMatch( - /insufficient balance|resource package|recharge/, - ); - } finally { - await session.close().catch(() => undefined); + // Session 2: different bogus model, should also work + const s2 = await client.createSession({ + provider: "opencode", + cwd: process.cwd(), + modeId: "build", + }); + await s2.setModel("bogus-provider/fake-model-67890"); + + const events: AgentStreamEvent[] = []; + for await (const event of streamSession(s2, "Say hello")) { + events.push(event); + if (isTerminalEvent(event)) break; + } + await s2.close().catch(() => undefined); + + const terminal = events.find(isTerminalEvent); + expect(terminal).toBeDefined(); + expect(terminal!.type).toBe("turn_failed"); + }, 30_000); + + test("surfaces error for known provider with nonexistent model (retry path)", async () => { + // When the provider is recognized (anthropic) but the model doesn't exist, + // OpenCode retries before surfacing the error. This must not hang. + const client = new OpenCodeAgentClient(pino({ level: "silent" })); + const session = await client.createSession({ + provider: "opencode", + cwd: process.cwd(), + modeId: "build", + }); + + try { + await session.setModel("anthropic/claude-nonexistent-99"); + + const events: AgentStreamEvent[] = []; + const start = Date.now(); + for await (const event of streamSession(session, "Say hello")) { + events.push(event); + if (isTerminalEvent(event)) break; } - }, - 45_000, - ); + const elapsed = Date.now() - start; + + const terminal = events.find(isTerminalEvent); + expect(terminal).toBeDefined(); + expect(elapsed).toBeLessThan(30_000); + console.log(`[nonexistent model] elapsed=${elapsed}ms terminal=${terminal!.type}`); + } finally { + await session.close().catch(() => undefined); + } + }, 45_000); + + test("surfaces fatal retry status from zai/glm-5.1 instead of hanging forever", async () => { + const client = new OpenCodeAgentClient(pino({ level: "silent" })); + const session = await client.createSession({ + provider: "opencode", + cwd: process.cwd(), + modeId: "build", + }); + + try { + await session.setModel("zai/glm-5.1"); + + const events: AgentStreamEvent[] = []; + for await (const event of streamSession(session, "Say hello")) { + events.push(event); + if (isTerminalEvent(event)) break; + } + + const terminal = events.find(isTerminalEvent); + expect(terminal).toBeDefined(); + expect(terminal!.type).toBe("turn_failed"); + expect((terminal!.type === "turn_failed" ? terminal!.error : "").toLowerCase()).toMatch( + /insufficient balance|resource package|recharge/, + ); + } finally { + await session.close().catch(() => undefined); + } + }, 45_000); }); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 6675b45ee..9d2076cd1 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -1,5 +1,4 @@ import type { ChildProcess } from "node:child_process"; -import { existsSync } from "node:fs"; import { createOpencodeClient, type AssistantMessage as OpenCodeAssistantMessage, @@ -44,7 +43,7 @@ import { resolveProviderCommandPrefix, type ProviderRuntimeSettings, } from "../provider-launch-config.js"; -import { findExecutable } from "../../../utils/executable.js"; +import { executableExists, findExecutable } from "../../../utils/executable.js"; import { spawnProcess } from "../../../utils/spawn.js"; import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js"; import { @@ -917,7 +916,7 @@ export class OpenCodeAgentClient implements AgentClient { async isAvailable(): Promise { const command = this.runtimeSettings?.command; if (command?.mode === "replace") { - return existsSync(command.argv[0]); + return executableExists(command.argv[0]) !== null; } return true; } diff --git a/packages/server/src/server/agent/providers/opencode-assistant-message.real.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-assistant-message.real.e2e.test.ts index f13fedf11..cdb79a40e 100644 --- a/packages/server/src/server/agent/providers/opencode-assistant-message.real.e2e.test.ts +++ b/packages/server/src/server/agent/providers/opencode-assistant-message.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect } from "vitest"; +import { beforeAll, beforeEach, describe, test, expect } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -9,62 +9,66 @@ import { isProviderAvailable } from "../../daemon-e2e/agent-configs.js"; import type { AgentStreamEvent } from "../agent-sdk-types.js"; describe("OpenCode assistant message", () => { - test.runIf(isProviderAvailable("opencode"))( - "assistant_message appears in live stream with opencode/big-pickle", - async () => { - const cwd = mkdtempSync(path.join(tmpdir(), "opencode-msg-")); - const logger = pino({ level: "silent" }); - const client = new OpenCodeAgentClient(logger); + let canRun = false; - try { - const session = await client.createSession({ - provider: "opencode", - cwd, - model: "opencode/big-pickle", - modeId: "build", - }); + beforeAll(async () => { + canRun = await isProviderAvailable("opencode"); + }); - const result = await session.run("Say hello back in one sentence."); + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); - const assistantItems = result.timeline.filter((item) => item.type === "assistant_message"); - expect(assistantItems.length).toBeGreaterThan(0); - expect(result.finalText.length).toBeGreaterThan(0); - } finally { - rmSync(cwd, { recursive: true, force: true }); + test("assistant_message appears in live stream with opencode/big-pickle", async () => { + const cwd = mkdtempSync(path.join(tmpdir(), "opencode-msg-")); + const logger = pino({ level: "silent" }); + const client = new OpenCodeAgentClient(logger); + + try { + const session = await client.createSession({ + provider: "opencode", + cwd, + model: "opencode/big-pickle", + modeId: "build", + }); + + const result = await session.run("Say hello back in one sentence."); + + const assistantItems = result.timeline.filter((item) => item.type === "assistant_message"); + expect(assistantItems.length).toBeGreaterThan(0); + expect(result.finalText.length).toBeGreaterThan(0); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, 60_000); + + test("streamHistory returns assistant_message after a completed turn", async () => { + const cwd = mkdtempSync(path.join(tmpdir(), "opencode-history-")); + const logger = pino({ level: "silent" }); + const client = new OpenCodeAgentClient(logger); + + try { + const session = await client.createSession({ + provider: "opencode", + cwd, + }); + + const result = await session.run("Say hello back in one sentence."); + expect(result.timeline.some((item) => item.type === "assistant_message")).toBe(true); + + const historyEvents: AgentStreamEvent[] = []; + for await (const event of session.streamHistory()) { + historyEvents.push(event); } - }, - 60_000, - ); - test.runIf(isProviderAvailable("opencode"))( - "streamHistory returns assistant_message after a completed turn", - async () => { - const cwd = mkdtempSync(path.join(tmpdir(), "opencode-history-")); - const logger = pino({ level: "silent" }); - const client = new OpenCodeAgentClient(logger); - - try { - const session = await client.createSession({ - provider: "opencode", - cwd, - }); - - const result = await session.run("Say hello back in one sentence."); - expect(result.timeline.some((item) => item.type === "assistant_message")).toBe(true); - - const historyEvents: AgentStreamEvent[] = []; - for await (const event of session.streamHistory()) { - historyEvents.push(event); - } - - const historyAssistant = historyEvents.filter( - (e) => e.type === "timeline" && e.item.type === "assistant_message", - ); - expect(historyAssistant.length).toBeGreaterThan(0); - } finally { - rmSync(cwd, { recursive: true, force: true }); - } - }, - 60_000, - ); + const historyAssistant = historyEvents.filter( + (e) => e.type === "timeline" && e.item.type === "assistant_message", + ); + expect(historyAssistant.length).toBeGreaterThan(0); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, 60_000); }); diff --git a/packages/server/src/server/agent/providers/opencode-reasoning-dedup.real.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-reasoning-dedup.real.e2e.test.ts index b99030046..02cf2c224 100644 --- a/packages/server/src/server/agent/providers/opencode-reasoning-dedup.real.e2e.test.ts +++ b/packages/server/src/server/agent/providers/opencode-reasoning-dedup.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect } from "vitest"; +import { beforeAll, beforeEach, describe, test, expect } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -9,60 +9,68 @@ import { isProviderAvailable } from "../../daemon-e2e/agent-configs.js"; import type { AgentStreamEvent } from "../agent-sdk-types.js"; describe("OpenCode reasoning dedup", () => { - test.runIf(isProviderAvailable("opencode"))( - "reasoning content is not duplicated as assistant_message", - async () => { - const cwd = mkdtempSync(path.join(tmpdir(), "opencode-reasoning-dedup-")); - const logger = pino({ level: "silent" }); - const client = new OpenCodeAgentClient(logger); + let canRun = false; - try { - const session = await client.createSession({ - provider: "opencode", - cwd, - model: "opencode/gpt-5-nano", - modeId: "build", - }); + beforeAll(async () => { + canRun = await isProviderAvailable("opencode"); + }); - const streamedEvents: AgentStreamEvent[] = []; - session.subscribe((event) => { - streamedEvents.push(event); - }); + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); - const result = await session.run("What is 2+2? Think step by step."); + test("reasoning content is not duplicated as assistant_message", async () => { + const cwd = mkdtempSync(path.join(tmpdir(), "opencode-reasoning-dedup-")); + const logger = pino({ level: "silent" }); + const client = new OpenCodeAgentClient(logger); - const reasoningTexts: string[] = []; - const assistantTexts: string[] = []; + try { + const session = await client.createSession({ + provider: "opencode", + cwd, + model: "opencode/gpt-5-nano", + modeId: "build", + }); - for (const event of streamedEvents) { - if (event.type === "timeline") { - if (event.item.type === "reasoning") { - reasoningTexts.push(event.item.text); - } else if (event.item.type === "assistant_message") { - assistantTexts.push(event.item.text); - } + const streamedEvents: AgentStreamEvent[] = []; + session.subscribe((event) => { + streamedEvents.push(event); + }); + + const result = await session.run("What is 2+2? Think step by step."); + + const reasoningTexts: string[] = []; + const assistantTexts: string[] = []; + + for (const event of streamedEvents) { + if (event.type === "timeline") { + if (event.item.type === "reasoning") { + reasoningTexts.push(event.item.text); + } else if (event.item.type === "assistant_message") { + assistantTexts.push(event.item.text); } } - - const fullReasoningText = reasoningTexts.join(""); - const fullAssistantText = assistantTexts.join(""); - - // The model should produce reasoning - expect(reasoningTexts.length).toBeGreaterThan(0); - expect(fullReasoningText.length).toBeGreaterThan(0); - - // The assistant text should be the response, not the reasoning - expect(assistantTexts.length).toBeGreaterThan(0); - - // Reasoning text must NOT appear in the assistant text - const reasoningPrefix = fullReasoningText.slice(0, 50); - if (reasoningPrefix.length > 10) { - expect(fullAssistantText).not.toContain(reasoningPrefix); - } - } finally { - rmSync(cwd, { recursive: true, force: true }); } - }, - 120_000, - ); + + const fullReasoningText = reasoningTexts.join(""); + const fullAssistantText = assistantTexts.join(""); + + // The model should produce reasoning + expect(reasoningTexts.length).toBeGreaterThan(0); + expect(fullReasoningText.length).toBeGreaterThan(0); + + // The assistant text should be the response, not the reasoning + expect(assistantTexts.length).toBeGreaterThan(0); + + // Reasoning text must NOT appear in the assistant text + const reasoningPrefix = fullReasoningText.slice(0, 50); + if (reasoningPrefix.length > 10) { + expect(fullAssistantText).not.toContain(reasoningPrefix); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, 120_000); }); diff --git a/packages/server/src/server/daemon-e2e/agent-configs.ts b/packages/server/src/server/daemon-e2e/agent-configs.ts index ca66c17ae..a8e1d0245 100644 --- a/packages/server/src/server/daemon-e2e/agent-configs.ts +++ b/packages/server/src/server/daemon-e2e/agent-configs.ts @@ -7,9 +7,8 @@ import { join, dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { homedir } from "node:os"; import dotenv from "dotenv"; -import { isCommandAvailableSync } from "../../utils/executable.js"; +import { isCommandAvailable } from "../../utils/executable.js"; -// Load .env.test eagerly so isProviderAvailable() has credentials at collection time. const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); dotenv.config({ path: resolve(serverRoot, ".env.test"), override: true }); @@ -64,6 +63,7 @@ export const agentConfigs = { } as const satisfies Record; export type AgentProvider = keyof typeof agentConfigs; +const providerAvailabilityCache = new Map>(); /** * Get test config for creating an agent with full permissions (no prompts). @@ -101,31 +101,42 @@ export function getAskModeConfig(provider: AgentProvider) { * This MUST be a function (not a const) so process.env is read at call time, * after dotenv has injected the test credentials. */ -export function isProviderAvailable(provider: AgentProvider): boolean { - switch (provider) { - case "claude": - return ( - isCommandAvailableSync("claude") && - (Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY)) - ); - case "codex": - return ( - isCommandAvailableSync("codex") && - (existsSync(join(homedir(), ".codex", "auth.json")) || Boolean(process.env.OPENAI_API_KEY)) - ); - case "copilot": - return isCommandAvailableSync("copilot"); - case "opencode": - return isCommandAvailableSync("opencode"); - case "pi": - return ( - isCommandAvailableSync(process.env.PI_ACP_PI_COMMAND ?? "pi") && - (Boolean(process.env.OPENAI_API_KEY) || - Boolean(process.env.ANTHROPIC_API_KEY) || - Boolean(process.env.OPENROUTER_API_KEY) || - existsSync(join(homedir(), ".pi", "agent", "auth.json"))) - ); +export function isProviderAvailable(provider: AgentProvider): Promise { + const cached = providerAvailabilityCache.get(provider); + if (cached) { + return cached; } + + const availability = (async (): Promise => { + switch (provider) { + case "claude": + return ( + (await isCommandAvailable("claude")) && + (Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY)) + ); + case "codex": + return ( + (await isCommandAvailable("codex")) && + (existsSync(join(homedir(), ".codex", "auth.json")) || + Boolean(process.env.OPENAI_API_KEY)) + ); + case "copilot": + return await isCommandAvailable("copilot"); + case "opencode": + return await isCommandAvailable("opencode"); + case "pi": + return ( + (await isCommandAvailable(process.env.PI_ACP_PI_COMMAND ?? "pi")) && + (Boolean(process.env.OPENAI_API_KEY) || + Boolean(process.env.ANTHROPIC_API_KEY) || + Boolean(process.env.OPENROUTER_API_KEY) || + existsSync(join(homedir(), ".pi", "agent", "auth.json"))) + ); + } + })(); + + providerAvailabilityCache.set(provider, availability); + return availability; } /** diff --git a/packages/server/src/server/daemon-e2e/claude-autonomous-wake-simple.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-autonomous-wake-simple.real.e2e.test.ts index 6dd2fe622..465cd59a8 100644 --- a/packages/server/src/server/daemon-e2e/claude-autonomous-wake-simple.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-autonomous-wake-simple.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -18,98 +18,106 @@ function compactText(value: string): string { } describe("daemon E2E (real claude) - autonomous wake simple", () => { - test.runIf(isProviderAvailable("claude"))( - "hello + background sleep returns idle, then wakes once on completion", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { claude: new ClaudeAgentClient({ logger }) }, - logger, + let canRun = false; + + beforeAll(async () => { + canRun = await isProviderAvailable("claude"); + }); + + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); + + test("hello + background sleep returns idle, then wakes once on completion", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: "claude-autonomous-simple-real" }, }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-simple-real" }, - }); + const agent = await client.createAgent({ + cwd, + title: "claude-autonomous-simple-real", + ...getFullAccessConfig("claude"), + }); - const agent = await client.createAgent({ - cwd, - title: "claude-autonomous-simple-real", - ...getFullAccessConfig("claude"), - }); + const autonomousWakeToken = `AUTONOMOUS_SIMPLE_${Date.now().toString(36)}`; + await client.sendMessage( + agent.id, + [ + "Hello.", + "Use the Bash tool with run_in_background.", + "Run exactly: sleep 5", + "Do not wait for the task result.", + "Reply immediately with exactly: SPAWNED", + `When the background task completes later, reply with exactly: ${autonomousWakeToken}`, + ].join(" "), + ); - const autonomousWakeToken = `AUTONOMOUS_SIMPLE_${Date.now().toString(36)}`; - await client.sendMessage( - agent.id, - [ - "Hello.", - "Use the Bash tool with run_in_background.", - "Run exactly: sleep 5", - "Do not wait for the task result.", - "Reply immediately with exactly: SPAWNED", - `When the background task completes later, reply with exactly: ${autonomousWakeToken}`, - ].join(" "), - ); + const firstFinish = await client.waitForFinish(agent.id, 240_000); + expect(firstFinish.status).toBe("idle"); - const firstFinish = await client.waitForFinish(agent.id, 240_000); - expect(firstFinish.status).toBe("idle"); + const timelineAtIdle = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + const idleAssistantText = timelineAtIdle.entries + .filter( + ( + entry, + ): entry is typeof entry & { + item: { type: "assistant_message"; text: string }; + } => entry.item.type === "assistant_message", + ) + .map((entry) => entry.item.text) + .join("\n"); + expect(compactText(idleAssistantText)).toContain("spawned"); + expect( + timelineAtIdle.entries.some( + (entry) => entry.item.type === "tool_call" && entry.item.name === "Bash", + ), + ).toBe(true); - const timelineAtIdle = await client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 0, - projection: "canonical", - }); - const idleAssistantText = timelineAtIdle.entries - .filter( - ( - entry, - ): entry is typeof entry & { - item: { type: "assistant_message"; text: string }; - } => entry.item.type === "assistant_message", - ) - .map((entry) => entry.item.text) - .join("\n"); - expect(compactText(idleAssistantText)).toContain("spawned"); - expect( - timelineAtIdle.entries.some( - (entry) => entry.item.type === "tool_call" && entry.item.name === "Bash", - ), - ).toBe(true); + await client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 30_000, + ); - await client.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 30_000, - ); + const autonomousFinish = await client.waitForFinish(agent.id, 120_000); + expect(autonomousFinish.status).toBe("idle"); - const autonomousFinish = await client.waitForFinish(agent.id, 120_000); - expect(autonomousFinish.status).toBe("idle"); - - const finalTimeline = await client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 0, - projection: "canonical", - }); - const finalAssistantText = finalTimeline.entries - .filter( - ( - entry, - ): entry is typeof entry & { - item: { type: "assistant_message"; text: string }; - } => entry.item.type === "assistant_message", - ) - .map((entry) => entry.item.text) - .join("\n"); - expect(compactText(finalAssistantText)).toContain(autonomousWakeToken.toLowerCase()); - } finally { - await client.close().catch(() => undefined); - await daemon.close().catch(() => undefined); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 420_000, - ); + const finalTimeline = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + const finalAssistantText = finalTimeline.entries + .filter( + ( + entry, + ): entry is typeof entry & { + item: { type: "assistant_message"; text: string }; + } => entry.item.type === "assistant_message", + ) + .map((entry) => entry.item.text) + .join("\n"); + expect(compactText(finalAssistantText)).toContain(autonomousWakeToken.toLowerCase()); + } finally { + await client.close().catch(() => undefined); + await daemon.close().catch(() => undefined); + rmSync(cwd, { recursive: true, force: true }); + } + }, 420_000); }); diff --git a/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts index 1f6379e8d..b6675c5a4 100644 --- a/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import path from "node:path"; @@ -353,452 +353,339 @@ function summarizeTimelineEntry(entry: { } describe("daemon E2E (real claude) - autonomous wake from background task", () => { - test.runIf(isProviderAvailable("claude"))( - "A: background sleep returns idle, then wakes autonomously and appends timeline activity", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { claude: new ClaudeAgentClient({ logger }) }, - logger, + let canRun = false; + + beforeAll(async () => { + canRun = await isProviderAvailable("claude"); + }); + + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); + + test("A: background sleep returns idle, then wakes autonomously and appends timeline activity", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: "claude-autonomous-abc-a" }, }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-abc-a" }, - }); + const agent = await client.createAgent({ + cwd, + title: "claude-autonomous-abc-a", + ...getFullAccessConfig("claude"), + }); - const agent = await client.createAgent({ - cwd, - title: "claude-autonomous-abc-a", - ...getFullAccessConfig("claude"), - }); + const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`; + await client.sendMessage( + agent.id, + [ + BACKGROUND_TASK_SLEEP_5_PROMPT, + `When you later receive the task completion notification, reply with exactly: ${autonomousWakeToken}`, + ].join(" "), + ); + const firstFinish = await client.waitForFinish(agent.id, 240_000); + expect(firstFinish.status).toBe("idle"); - const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`; - await client.sendMessage( - agent.id, - [ - BACKGROUND_TASK_SLEEP_5_PROMPT, - `When you later receive the task completion notification, reply with exactly: ${autonomousWakeToken}`, - ].join(" "), - ); - const firstFinish = await client.waitForFinish(agent.id, 240_000); - expect(firstFinish.status).toBe("idle"); + const timelineAtIdle = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); - const timelineAtIdle = await client.fetchAgentTimeline(agent.id, { + await client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 30_000, + ); + + const autonomousFinish = await client.waitForFinish(agent.id, 120_000); + expect(autonomousFinish.status).toBe("idle"); + + const timelineAfterWake = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + expect(timelineAfterWake.entries.length).toBeGreaterThanOrEqual( + timelineAtIdle.entries.length, + ); + let sawTimelineGrowth = timelineAfterWake.entries.length > timelineAtIdle.entries.length; + const growthDeadline = Date.now() + 20_000; + while (!sawTimelineGrowth && Date.now() < growthDeadline) { + await sleep(250); + const nextTimeline = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0, projection: "canonical", }); + sawTimelineGrowth = nextTimeline.entries.length > timelineAtIdle.entries.length; + } + expect(sawTimelineGrowth).toBe(true); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 420_000); - await client.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 30_000, - ); + test("B: immediate HELLO before task notification returns promptly without deadlock", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: "claude-autonomous-abc-b" }, + }); + + const agent = await client.createAgent({ + cwd, + title: "claude-autonomous-abc-b", + ...getFullAccessConfig("claude"), + }); + + await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_PROMPT); + const kickoffFinish = await client.waitForFinish(agent.id, 240_000); + expect(kickoffFinish.status).toBe("idle"); + + await client.sendMessage(agent.id, "say exactly HELLO"); + const helloFinish = await client.waitForFinish(agent.id, 180_000); + expect(helloFinish.status).toBe("idle"); + expect((helloFinish.lastMessage ?? "").trim().toUpperCase()).toContain("HELLO"); + + // The background task may complete before, during, or after the HELLO + // turn. When the task_notification races with HELLO, the notification + // is handled during the foreground turn and there is no separate + // autonomous running edge afterwards. Wait for a possible autonomous + // wake; if none arrives within the expected sleep window, verify the + // agent settled to idle (notification was already processed). + const autonomousWake = await client + .waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running", 15_000) + .catch(() => null); + + if (autonomousWake) { const autonomousFinish = await client.waitForFinish(agent.id, 120_000); expect(autonomousFinish.status).toBe("idle"); - - const timelineAfterWake = await client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 0, - projection: "canonical", - }); - expect(timelineAfterWake.entries.length).toBeGreaterThanOrEqual( - timelineAtIdle.entries.length, - ); - let sawTimelineGrowth = timelineAfterWake.entries.length > timelineAtIdle.entries.length; - const growthDeadline = Date.now() + 20_000; - while (!sawTimelineGrowth && Date.now() < growthDeadline) { - await sleep(250); - const nextTimeline = await client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 0, - projection: "canonical", - }); - sawTimelineGrowth = nextTimeline.entries.length > timelineAtIdle.entries.length; - } - expect(sawTimelineGrowth).toBe(true); - } finally { - await client.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); + } else { + const current = await client.fetchAgent(agent.id); + expect(current.agent.status).toBe("idle"); } - }, - 420_000, - ); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 420_000); - test.runIf(isProviderAvailable("claude"))( - "B: immediate HELLO before task notification returns promptly without deadlock", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { claude: new ClaudeAgentClient({ logger }) }, - logger, + test("C: interrupt during overlap returns quickly and does not leave agent stuck running", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: "claude-autonomous-abc-c" }, }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-abc-b" }, - }); + const agent = await client.createAgent({ + cwd, + title: "claude-autonomous-abc-c", + ...getFullAccessConfig("claude"), + }); - const agent = await client.createAgent({ - cwd, - title: "claude-autonomous-abc-b", - ...getFullAccessConfig("claude"), - }); + await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_PROMPT); + const firstKickoff = await client.waitForFinish(agent.id, 240_000); + expect(firstKickoff.status).toBe("idle"); + await client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 30_000, + ); + const firstAutonomousFinish = await client.waitForFinish(agent.id, 120_000); + expect(firstAutonomousFinish.status).toBe("idle"); - await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_PROMPT); - const kickoffFinish = await client.waitForFinish(agent.id, 240_000); - expect(kickoffFinish.status).toBe("idle"); + await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_REPEAT_PROMPT); + const secondKickoff = await client.waitForFinish(agent.id, 240_000); + expect(secondKickoff.status).toBe("idle"); - await client.sendMessage(agent.id, "say exactly HELLO"); - const helloFinish = await client.waitForFinish(agent.id, 180_000); - expect(helloFinish.status).toBe("idle"); - expect((helloFinish.lastMessage ?? "").trim().toUpperCase()).toContain("HELLO"); + await client.sendMessage(agent.id, "say exactly HELLO"); + const helloFinish = await client.waitForFinish(agent.id, 180_000); + expect(helloFinish.status).toBe("idle"); - // The background task may complete before, during, or after the HELLO - // turn. When the task_notification races with HELLO, the notification - // is handled during the foreground turn and there is no separate - // autonomous running edge afterwards. Wait for a possible autonomous - // wake; if none arrives within the expected sleep window, verify the - // agent settled to idle (notification was already processed). - const autonomousWake = await client - .waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running", 15_000) - .catch(() => null); + // The second background task may complete before, during, or after + // HELLO. When it races with HELLO, the notification is handled during + // the foreground turn and there is no separate autonomous running edge. + const autonomousWake = await client + .waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running", 15_000) + .catch(() => null); - if (autonomousWake) { - const autonomousFinish = await client.waitForFinish(agent.id, 120_000); - expect(autonomousFinish.status).toBe("idle"); - } else { - const current = await client.fetchAgent(agent.id); - expect(current.agent.status).toBe("idle"); - } - } finally { - await client.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); + if (autonomousWake) { + const startedAt = Date.now(); + await client.cancelAgent(agent.id); + const interruptDurationMs = Date.now() - startedAt; + expect(interruptDurationMs).toBeLessThan(10_000); + + const settled = await client.waitForFinish(agent.id, 20_000); + expect(settled.status).toBe("idle"); } - }, - 420_000, - ); - test.runIf(isProviderAvailable("claude"))( - "C: interrupt during overlap returns quickly and does not leave agent stuck running", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { claude: new ClaudeAgentClient({ logger }) }, - logger, + const finalResult = await client.fetchAgent(agent.id); + expect(finalResult?.agent.status).toBe("idle"); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 600_000); + + test("returns to running after background sleep completes without a second prompt", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: "claude-autonomous-wake-real" }, }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-abc-c" }, - }); - - const agent = await client.createAgent({ - cwd, - title: "claude-autonomous-abc-c", - ...getFullAccessConfig("claude"), - }); - - await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_PROMPT); - const firstKickoff = await client.waitForFinish(agent.id, 240_000); - expect(firstKickoff.status).toBe("idle"); - await client.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 30_000, - ); - const firstAutonomousFinish = await client.waitForFinish(agent.id, 120_000); - expect(firstAutonomousFinish.status).toBe("idle"); - - await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_REPEAT_PROMPT); - const secondKickoff = await client.waitForFinish(agent.id, 240_000); - expect(secondKickoff.status).toBe("idle"); - - await client.sendMessage(agent.id, "say exactly HELLO"); - const helloFinish = await client.waitForFinish(agent.id, 180_000); - expect(helloFinish.status).toBe("idle"); - - // The second background task may complete before, during, or after - // HELLO. When it races with HELLO, the notification is handled during - // the foreground turn and there is no separate autonomous running edge. - const autonomousWake = await client - .waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running", 15_000) - .catch(() => null); - - if (autonomousWake) { - const startedAt = Date.now(); - await client.cancelAgent(agent.id); - const interruptDurationMs = Date.now() - startedAt; - expect(interruptDurationMs).toBeLessThan(10_000); - - const settled = await client.waitForFinish(agent.id, 20_000); - expect(settled.status).toBe("idle"); - } - - const finalResult = await client.fetchAgent(agent.id); - expect(finalResult?.agent.status).toBe("idle"); - } finally { - await client.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 600_000, - ); - - test.runIf(isProviderAvailable("claude"))( - "returns to running after background sleep completes without a second prompt", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { claude: new ClaudeAgentClient({ logger }) }, - logger, + const agent = await client.createAgent({ + cwd, + title: "claude-autonomous-wake-real", + ...getFullAccessConfig("claude"), }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-wake-real" }, - }); + await client.sendMessage( + agent.id, + [ + "Use a background task to run exactly: sleep 5", + "Do not wait for it to finish.", + "After launching it, reply with exactly: SPAWNED", + ].join(" "), + ); - const agent = await client.createAgent({ - cwd, - title: "claude-autonomous-wake-real", - ...getFullAccessConfig("claude"), - }); + const firstFinish = await client.waitForFinish(agent.id, 240_000); + expect(firstFinish.status).toBe("idle"); - await client.sendMessage( - agent.id, - [ - "Use a background task to run exactly: sleep 5", - "Do not wait for it to finish.", - "After launching it, reply with exactly: SPAWNED", - ].join(" "), - ); - - const firstFinish = await client.waitForFinish(agent.id, 240_000); - expect(firstFinish.status).toBe("idle"); - - const timelineBeforeWake = await client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 0, - projection: "canonical", - }); - const summarized = timelineBeforeWake.entries.map(summarizeTimelineEntry); - // Required by reproduction request: log timeline at idle edge before autonomous wake. - // eslint-disable-next-line no-console - console.log("TIMELINE_BEFORE_AUTONOMOUS_WAKE\n" + summarized.join("\n")); - expect(summarized.some((line) => line.includes("SPAWNED"))).toBe(true); - - // No new user prompt here: we expect autonomous transition caused by - // background task completion notification. - await client.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 30_000, - ); - } finally { - await client.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 420_000, - ); - - test.runIf(isProviderAvailable("claude"))( - "accepts a new prompt after background sleep finishes and replies HELLO", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { claude: new ClaudeAgentClient({ logger }) }, - logger, + const timelineBeforeWake = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const summarized = timelineBeforeWake.entries.map(summarizeTimelineEntry); + // Required by reproduction request: log timeline at idle edge before autonomous wake. + // eslint-disable-next-line no-console + console.log("TIMELINE_BEFORE_AUTONOMOUS_WAKE\n" + summarized.join("\n")); + expect(summarized.some((line) => line.includes("SPAWNED"))).toBe(true); - try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-followup-real" }, - }); + // No new user prompt here: we expect autonomous transition caused by + // background task completion notification. + await client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 30_000, + ); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 420_000); - const agent = await client.createAgent({ - cwd, - title: "claude-autonomous-followup-real", - ...getFullAccessConfig("claude"), - }); + test("accepts a new prompt after background sleep finishes and replies HELLO", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - await client.sendMessage( - agent.id, - [ - "Use a background task to run exactly: sleep 5", - "Do not wait for it to finish.", - "After launching it, reply with exactly: SPAWNED", - ].join(" "), - ); - const firstFinish = await client.waitForFinish(agent.id, 240_000); - expect(firstFinish.status).toBe("idle"); - - await new Promise((resolve) => setTimeout(resolve, 6_000)); - - await client.sendMessage(agent.id, "say exactly HELLO"); - const secondFinish = await client.waitForFinish(agent.id, 240_000); - expect(secondFinish.status).toBe("idle"); - expect((secondFinish.lastMessage ?? "").toUpperCase()).toContain("HELLO"); - } finally { - await client.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 420_000, - ); - - test.runIf(isProviderAvailable("claude"))( - "repro: do-it-again + immediate hello can hang after autonomous wake under churn", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { claude: new ClaudeAgentClient({ logger }) }, - logger, + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: "claude-autonomous-followup-real" }, }); - const wsUrl = `ws://127.0.0.1:${daemon.port}/ws`; - const client = new DaemonClient({ url: wsUrl }); - try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: "claude-hang-repro-real" }, - }); - - const agent = await client.createAgent({ - cwd, - title: "claude-hang-repro-real", - ...getFullAccessConfig("claude"), - }); - - for (let cycle = 0; cycle < 20; cycle += 1) { - const noise = runPreHelloNoise({ wsUrl, durationMs: 9_000 }); - - await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_PROMPT); - const firstKickoff = await client.waitForFinish(agent.id, 180_000); - expect(firstKickoff.status).toBe("idle"); - - await client.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 30_000, - ); - const firstCompletion = await client.waitForFinish(agent.id, 90_000); - expect(firstCompletion.status).toBe("idle"); - - await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_REPEAT_PROMPT); - const secondKickoff = await client.waitForFinish(agent.id, 180_000); - expect(secondKickoff.status).toBe("idle"); - - await client.sendMessage(agent.id, "say exactly HELLO"); - const helloReply = await client.waitForFinish(agent.id, 180_000); - expect(helloReply.status).toBe("idle"); - expect((helloReply.lastMessage ?? "").trim().toUpperCase()).toContain("HELLO"); - - const runningSnapshot = await client.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 30_000, - ); - - const timelineAtWake = await client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 0, - projection: "canonical", - }); - - // eslint-disable-next-line no-console - console.log( - JSON.stringify({ - cycle, - wakeUpdatedAt: runningSnapshot.updatedAt, - entriesAtWake: timelineAtWake.entries.length, - }), - ); - - await sleep(6_000); - const timelineAfterWake = await client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 0, - projection: "canonical", - }); - expect(timelineAfterWake.entries.length).toBeGreaterThanOrEqual( - timelineAtWake.entries.length, - ); - - let secondCompletion; - try { - secondCompletion = await client.waitForFinish(agent.id, 20_000); - } catch { - const atTimeoutResult = await client.fetchAgent(agent.id); - // eslint-disable-next-line no-console - console.log( - JSON.stringify({ - cycle, - phase: "second_completion_timeout", - statusAtTimeout: atTimeoutResult?.agent.status ?? "unknown", - }), - ); - secondCompletion = await client.waitForFinish(agent.id, 30_000); - } - expect(secondCompletion.status).toBe("idle"); - - await noise; - } - } finally { - await client.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 900_000, - ); - - test.runIf(isProviderAvailable("claude"))( - "repro: second background sleep completion after HELLO should settle back to idle", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { claude: new ClaudeAgentClient({ logger }) }, - logger, + const agent = await client.createAgent({ + cwd, + title: "claude-autonomous-followup-real", + ...getFullAccessConfig("claude"), }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: "claude-background-repro-real" }, - }); + await client.sendMessage( + agent.id, + [ + "Use a background task to run exactly: sleep 5", + "Do not wait for it to finish.", + "After launching it, reply with exactly: SPAWNED", + ].join(" "), + ); + const firstFinish = await client.waitForFinish(agent.id, 240_000); + expect(firstFinish.status).toBe("idle"); - const agent = await client.createAgent({ - cwd, - title: "claude-background-repro-real", - ...getFullAccessConfig("claude"), - }); + await new Promise((resolve) => setTimeout(resolve, 6_000)); + + await client.sendMessage(agent.id, "say exactly HELLO"); + const secondFinish = await client.waitForFinish(agent.id, 240_000); + expect(secondFinish.status).toBe("idle"); + expect((secondFinish.lastMessage ?? "").toUpperCase()).toContain("HELLO"); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 420_000); + + test("repro: do-it-again + immediate hello can hang after autonomous wake under churn", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + const wsUrl = `ws://127.0.0.1:${daemon.port}/ws`; + const client = new DaemonClient({ url: wsUrl }); + + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: "claude-hang-repro-real" }, + }); + + const agent = await client.createAgent({ + cwd, + title: "claude-hang-repro-real", + ...getFullAccessConfig("claude"), + }); + + for (let cycle = 0; cycle < 20; cycle += 1) { + const noise = runPreHelloNoise({ wsUrl, durationMs: 9_000 }); await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_PROMPT); const firstKickoff = await client.waitForFinish(agent.id, 180_000); @@ -809,7 +696,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = (snapshot) => snapshot.status === "running", 30_000, ); - const firstCompletion = await client.waitForFinish(agent.id, 60_000); + const firstCompletion = await client.waitForFinish(agent.id, 90_000); expect(firstCompletion.status).toBe("idle"); await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_REPEAT_PROMPT); @@ -821,14 +708,37 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = expect(helloReply.status).toBe("idle"); expect((helloReply.lastMessage ?? "").trim().toUpperCase()).toContain("HELLO"); - await client.waitForAgentUpsert( + const runningSnapshot = await client.waitForAgentUpsert( agent.id, (snapshot) => snapshot.status === "running", 30_000, ); - // This is the failure point seen in production: agent flips to running - // on task completion and never settles back to idle. + const timelineAtWake = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + + // eslint-disable-next-line no-console + console.log( + JSON.stringify({ + cycle, + wakeUpdatedAt: runningSnapshot.updatedAt, + entriesAtWake: timelineAtWake.entries.length, + }), + ); + + await sleep(6_000); + const timelineAfterWake = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + expect(timelineAfterWake.entries.length).toBeGreaterThanOrEqual( + timelineAtWake.entries.length, + ); + let secondCompletion; try { secondCompletion = await client.waitForFinish(agent.id, 20_000); @@ -837,256 +747,322 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = // eslint-disable-next-line no-console console.log( JSON.stringify({ - phase: "second_background_completion_timeout", + cycle, + phase: "second_completion_timeout", statusAtTimeout: atTimeoutResult?.agent.status ?? "unknown", }), ); secondCompletion = await client.waitForFinish(agent.id, 30_000); } expect(secondCompletion.status).toBe("idle"); - } finally { - await client.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); + + await noise; } - }, - 600_000, - ); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 900_000); - test.runIf(isProviderAvailable("claude"))( - "stress: immediate HELLO before task notification should not leave autonomous run stuck", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { claude: new ClaudeAgentClient({ logger }) }, - logger, + test("repro: second background sleep completion after HELLO should settle back to idle", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: "claude-background-repro-real" }, }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const agent = await client.createAgent({ + cwd, + title: "claude-background-repro-real", + ...getFullAccessConfig("claude"), + }); + + await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_PROMPT); + const firstKickoff = await client.waitForFinish(agent.id, 180_000); + expect(firstKickoff.status).toBe("idle"); + + await client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 30_000, + ); + const firstCompletion = await client.waitForFinish(agent.id, 60_000); + expect(firstCompletion.status).toBe("idle"); + + await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_REPEAT_PROMPT); + const secondKickoff = await client.waitForFinish(agent.id, 180_000); + expect(secondKickoff.status).toBe("idle"); + + await client.sendMessage(agent.id, "say exactly HELLO"); + const helloReply = await client.waitForFinish(agent.id, 180_000); + expect(helloReply.status).toBe("idle"); + expect((helloReply.lastMessage ?? "").trim().toUpperCase()).toContain("HELLO"); + + await client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 30_000, + ); + + // This is the failure point seen in production: agent flips to running + // on task completion and never settles back to idle. + let secondCompletion; try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-race-stress-real" }, - }); - - const agent = await client.createAgent({ - cwd, - title: "claude-autonomous-race-stress-real", - ...getFullAccessConfig("claude"), - }); - - for (let cycle = 0; cycle < 20; cycle += 1) { - const helloToken = `HELLO_CYCLE_${cycle}`; - - await client.sendMessage( - agent.id, - [ - "Use the Task tool (not a Bash background process).", - "Start a background task that runs exactly: sleep 3", - "Do not wait for the task result.", - "Immediately reply with exactly: SPAWNED", - ].join(" "), - ); - - const firstFinish = await client.waitForFinish(agent.id, 240_000); - expect(firstFinish.status).toBe("idle"); - - // Race path under test: immediately send the next user prompt before - // the background-task completion notification wakes Claude again. - await client.sendMessage(agent.id, `say exactly ${helloToken}`); - const secondFinish = await client.waitForFinish(agent.id, 240_000); - expect(secondFinish.status).toBe("idle"); - expect(secondFinish.lastMessage?.trim()).toBe(helloToken); - - const wakeSnapshot = await client.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 20_000, - ); - - const timelineAtWake = await client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 0, - projection: "canonical", - }); - - // eslint-disable-next-line no-console - console.log( - JSON.stringify({ - cycle, - autonomousWakeUpdatedAt: wakeSnapshot.updatedAt, - timelineEntriesAtWake: timelineAtWake.entries.length, - }), - ); - - await sleep(3_000); - const timelineAfterWake = await client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 0, - projection: "canonical", - }); - expect(timelineAfterWake.entries.length).toBeGreaterThanOrEqual( - timelineAtWake.entries.length, - ); - - await client.cancelAgent(agent.id); - const afterCancel = await client.waitForFinish(agent.id, 10_000); - expect(afterCancel.status).toBe("idle"); - } - } finally { - await client.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); + secondCompletion = await client.waitForFinish(agent.id, 20_000); + } catch { + const atTimeoutResult = await client.fetchAgent(agent.id); + // eslint-disable-next-line no-console + console.log( + JSON.stringify({ + phase: "second_background_completion_timeout", + statusAtTimeout: atTimeoutResult?.agent.status ?? "unknown", + }), + ); + secondCompletion = await client.waitForFinish(agent.id, 30_000); } - }, - 900_000, - ); + expect(secondCompletion.status).toBe("idle"); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 600_000); - test.runIf(isProviderAvailable("claude"))( - "repro: transcript/timeline parity after do-it-again + hello race (hang + interrupt + drop)", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { claude: new ClaudeAgentClient({ logger }) }, - logger, + test("stress: immediate HELLO before task notification should not leave autonomous run stuck", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: "claude-autonomous-race-stress-real" }, }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: "claude-transcript-parity-race" }, - }); + const agent = await client.createAgent({ + cwd, + title: "claude-autonomous-race-stress-real", + ...getFullAccessConfig("claude"), + }); - const agent = await client.createAgent({ - cwd, - title: "claude-transcript-parity-race", - ...getFullAccessConfig("claude"), - }); + for (let cycle = 0; cycle < 20; cycle += 1) { + const helloToken = `HELLO_CYCLE_${cycle}`; - await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_PROMPT); - const firstKickoff = await client.waitForFinish(agent.id, 180_000); - expect(firstKickoff.status).toBe("idle"); + await client.sendMessage( + agent.id, + [ + "Use the Task tool (not a Bash background process).", + "Start a background task that runs exactly: sleep 3", + "Do not wait for the task result.", + "Immediately reply with exactly: SPAWNED", + ].join(" "), + ); - await client.waitForAgentUpsert( + const firstFinish = await client.waitForFinish(agent.id, 240_000); + expect(firstFinish.status).toBe("idle"); + + // Race path under test: immediately send the next user prompt before + // the background-task completion notification wakes Claude again. + await client.sendMessage(agent.id, `say exactly ${helloToken}`); + const secondFinish = await client.waitForFinish(agent.id, 240_000); + expect(secondFinish.status).toBe("idle"); + expect(secondFinish.lastMessage?.trim()).toBe(helloToken); + + const wakeSnapshot = await client.waitForAgentUpsert( agent.id, (snapshot) => snapshot.status === "running", - 30_000, + 20_000, ); - const firstAutonomousCompletion = await client.waitForFinish(agent.id, 60_000); - expect(firstAutonomousCompletion.status).toBe("idle"); - await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_REPEAT_PROMPT); - const secondKickoff = await client.waitForFinish(agent.id, 180_000); - expect(secondKickoff.status).toBe("idle"); + const timelineAtWake = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); - await client.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 30_000, + // eslint-disable-next-line no-console + console.log( + JSON.stringify({ + cycle, + autonomousWakeUpdatedAt: wakeSnapshot.updatedAt, + timelineEntriesAtWake: timelineAtWake.entries.length, + }), ); - await sleep(500); - await client.sendMessage(agent.id, "say exactly HELLO"); - let waitError: Error | null = null; + await sleep(3_000); + const timelineAfterWake = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + expect(timelineAfterWake.entries.length).toBeGreaterThanOrEqual( + timelineAtWake.entries.length, + ); + + await client.cancelAgent(agent.id); + const afterCancel = await client.waitForFinish(agent.id, 10_000); + expect(afterCancel.status).toBe("idle"); + } + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 900_000); + + test("repro: transcript/timeline parity after do-it-again + hello race (hang + interrupt + drop)", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: "claude-transcript-parity-race" }, + }); + + const agent = await client.createAgent({ + cwd, + title: "claude-transcript-parity-race", + ...getFullAccessConfig("claude"), + }); + + await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_PROMPT); + const firstKickoff = await client.waitForFinish(agent.id, 180_000); + expect(firstKickoff.status).toBe("idle"); + + await client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 30_000, + ); + const firstAutonomousCompletion = await client.waitForFinish(agent.id, 60_000); + expect(firstAutonomousCompletion.status).toBe("idle"); + + await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_REPEAT_PROMPT); + const secondKickoff = await client.waitForFinish(agent.id, 180_000); + expect(secondKickoff.status).toBe("idle"); + + await client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 30_000, + ); + await sleep(500); + await client.sendMessage(agent.id, "say exactly HELLO"); + + let waitError: Error | null = null; + try { + await client.waitForFinish(agent.id, 20_000); + } catch (error) { + waitError = + error instanceof Error ? error : new Error(String(error ?? "wait_for_finish failed")); + } + + let afterWaitResult = await client.fetchAgent(agent.id); + let cancelRecovered = true; + if (afterWaitResult?.agent.status === "running") { + await client.cancelAgent(agent.id); try { - await client.waitForFinish(agent.id, 20_000); - } catch (error) { - waitError = - error instanceof Error ? error : new Error(String(error ?? "wait_for_finish failed")); + const afterCancel = await client.waitForFinish(agent.id, 15_000); + cancelRecovered = afterCancel.status === "idle"; + } catch { + cancelRecovered = false; } - - let afterWaitResult = await client.fetchAgent(agent.id); - let cancelRecovered = true; - if (afterWaitResult?.agent.status === "running") { - await client.cancelAgent(agent.id); - try { - const afterCancel = await client.waitForFinish(agent.id, 15_000); - cancelRecovered = afterCancel.status === "idle"; - } catch { - cancelRecovered = false; - } - afterWaitResult = await client.fetchAgent(agent.id); - } - - expect(waitError).toBeNull(); - expect(cancelRecovered).toBe(true); - - const afterWait = afterWaitResult?.agent ?? null; - const sessionId = - afterWait?.persistence?.sessionId ?? afterWait?.runtimeInfo?.sessionId ?? null; - expect(typeof sessionId === "string" && sessionId.length > 0).toBe(true); - const transcriptCwd = afterWait?.cwd ?? cwd; - const transcriptPath = resolveClaudeTranscriptPath({ - cwd: transcriptCwd, - sessionId: sessionId as string, - }); - - let evidence: TranscriptRaceEvidence | null = null; - const transcriptDeadline = Date.now() + 20_000; - while (Date.now() < transcriptDeadline) { - const lines = readTranscriptLines(transcriptPath); - evidence = extractTranscriptRaceEvidence(lines); - if (evidence) { - break; - } - await sleep(250); - } - if (!evidence) { - const transcriptDump = summarizeTranscriptTail(readTranscriptLines(transcriptPath)); - throw new Error( - [ - "Failed to extract transcript race evidence (hello assistant and notification-turn assistant).", - `transcriptPath=${transcriptPath}`, - `waitError=${waitError ? waitError.message : "null"}`, - `afterWaitStatus=${afterWait?.status ?? "unknown"}`, - `cancelRecovered=${cancelRecovered}`, - "transcriptTail:", - transcriptDump, - ].join("\n"), - ); - } - - const helloAssistant = compactText(evidence.helloAssistantText); - const notificationAssistant = compactText(evidence.notificationOutcomeAssistantText); - let assistantTexts: string[] = []; - let assistantTextCombined = ""; - const timelineDeadline = Date.now() + 20_000; - while (Date.now() < timelineDeadline) { - const timeline = await client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 0, - projection: "canonical", - }); - assistantTexts = timeline.entries - .filter( - ( - entry, - ): entry is { - item: { type: "assistant_message"; text: string }; - } => entry.item.type === "assistant_message", - ) - .map((entry) => compactText(entry.item.text)); - assistantTextCombined = assistantTexts.join(""); - if ( - assistantTextCombined.includes(helloAssistant) && - assistantTextCombined.includes(notificationAssistant) - ) { - break; - } - await sleep(250); - } - - expect(assistantTextCombined.includes(helloAssistant)).toBe(true); - expect(assistantTextCombined.includes(notificationAssistant)).toBe(true); - } finally { - await client.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); + afterWaitResult = await client.fetchAgent(agent.id); } - }, - 600_000, - ); + + expect(waitError).toBeNull(); + expect(cancelRecovered).toBe(true); + + const afterWait = afterWaitResult?.agent ?? null; + const sessionId = + afterWait?.persistence?.sessionId ?? afterWait?.runtimeInfo?.sessionId ?? null; + expect(typeof sessionId === "string" && sessionId.length > 0).toBe(true); + const transcriptCwd = afterWait?.cwd ?? cwd; + const transcriptPath = resolveClaudeTranscriptPath({ + cwd: transcriptCwd, + sessionId: sessionId as string, + }); + + let evidence: TranscriptRaceEvidence | null = null; + const transcriptDeadline = Date.now() + 20_000; + while (Date.now() < transcriptDeadline) { + const lines = readTranscriptLines(transcriptPath); + evidence = extractTranscriptRaceEvidence(lines); + if (evidence) { + break; + } + await sleep(250); + } + if (!evidence) { + const transcriptDump = summarizeTranscriptTail(readTranscriptLines(transcriptPath)); + throw new Error( + [ + "Failed to extract transcript race evidence (hello assistant and notification-turn assistant).", + `transcriptPath=${transcriptPath}`, + `waitError=${waitError ? waitError.message : "null"}`, + `afterWaitStatus=${afterWait?.status ?? "unknown"}`, + `cancelRecovered=${cancelRecovered}`, + "transcriptTail:", + transcriptDump, + ].join("\n"), + ); + } + + const helloAssistant = compactText(evidence.helloAssistantText); + const notificationAssistant = compactText(evidence.notificationOutcomeAssistantText); + let assistantTexts: string[] = []; + let assistantTextCombined = ""; + const timelineDeadline = Date.now() + 20_000; + while (Date.now() < timelineDeadline) { + const timeline = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + assistantTexts = timeline.entries + .filter( + ( + entry, + ): entry is { + item: { type: "assistant_message"; text: string }; + } => entry.item.type === "assistant_message", + ) + .map((entry) => compactText(entry.item.text)); + assistantTextCombined = assistantTexts.join(""); + if ( + assistantTextCombined.includes(helloAssistant) && + assistantTextCombined.includes(notificationAssistant) + ) { + break; + } + await sleep(250); + } + + expect(assistantTextCombined.includes(helloAssistant)).toBe(true); + expect(assistantTextCombined.includes(notificationAssistant)).toBe(true); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 600_000); }); diff --git a/packages/server/src/server/daemon-e2e/model-resolution-on-init.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/model-resolution-on-init.real.e2e.test.ts index 5668a904f..1c76df462 100644 --- a/packages/server/src/server/daemon-e2e/model-resolution-on-init.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/model-resolution-on-init.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -14,52 +14,60 @@ function tmpCwd(): string { } describe("daemon E2E (real claude) - model resolution on init", () => { - test.runIf(isProviderAvailable("claude"))( - "runtimeInfo.model is set as soon as the agent starts running, not after turn completes", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { claude: new ClaudeAgentClient({ logger }) }, - logger, + let canRun = false; + + beforeAll(async () => { + canRun = await isProviderAvailable("claude"); + }); + + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); + + test("runtimeInfo.model is set as soon as the agent starts running, not after turn completes", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: "model-init-test" }, }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: "model-init-test" }, - }); + const modelsResult = await client.listProviderModels("claude", { cwd }); + const catalogModelIds = new Set(modelsResult.models.map((m) => m.id)); - const modelsResult = await client.listProviderModels("claude", { cwd }); - const catalogModelIds = new Set(modelsResult.models.map((m) => m.id)); + const agent = await client.createAgent({ + provider: "claude", + cwd, + model: "haiku", + title: "model-init-test", + }); - const agent = await client.createAgent({ - provider: "claude", - cwd, - model: "haiku", - title: "model-init-test", - }); + await client.sendMessage(agent.id, "Reply with exactly: OK"); - await client.sendMessage(agent.id, "Reply with exactly: OK"); + const snapshot = await client.waitForAgentUpsert( + agent.id, + (s) => s.runtimeInfo?.model != null, + 60_000, + ); - const snapshot = await client.waitForAgentUpsert( - agent.id, - (s) => s.runtimeInfo?.model != null, - 60_000, - ); + expect(snapshot.runtimeInfo?.model).toBeTruthy(); + expect(catalogModelIds.has(snapshot.runtimeInfo!.model!)).toBe(true); + expect(snapshot.status).toBe("running"); - expect(snapshot.runtimeInfo?.model).toBeTruthy(); - expect(catalogModelIds.has(snapshot.runtimeInfo!.model!)).toBe(true); - expect(snapshot.status).toBe("running"); - - await client.waitForFinish(agent.id, 60_000); - } finally { - await client.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 120_000, - ); + await client.waitForFinish(agent.id, 60_000); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 120_000); }); diff --git a/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts index 42cbcdcde..9a5e5f7a5 100644 --- a/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -14,50 +14,58 @@ function tmpCwd(): string { } describe("daemon E2E (real claude) - runtime model reconciliation", () => { - test.runIf(isProviderAvailable("claude"))( - "normalizes runtime model to a model ID exposed by the provider catalog", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { claude: new ClaudeAgentClient({ logger }) }, - logger, + let canRun = false; + + beforeAll(async () => { + canRun = await isProviderAvailable("claude"); + }); + + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); + + test("normalizes runtime model to a model ID exposed by the provider catalog", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: "claude-runtime-model-reconcile" }, }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: "claude-runtime-model-reconcile" }, - }); + const modelsResult = await client.listProviderModels("claude", { cwd }); + expect(modelsResult.error).toBeNull(); + expect(modelsResult.models.length).toBeGreaterThan(0); + const modelIds = new Set(modelsResult.models.map((model) => model.id)); - const modelsResult = await client.listProviderModels("claude", { cwd }); - expect(modelsResult.error).toBeNull(); - expect(modelsResult.models.length).toBeGreaterThan(0); - const modelIds = new Set(modelsResult.models.map((model) => model.id)); + const agent = await client.createAgent({ + provider: "claude", + cwd, + title: "claude-runtime-model-reconcile", + }); - const agent = await client.createAgent({ - provider: "claude", - cwd, - title: "claude-runtime-model-reconcile", - }); + await client.sendMessage(agent.id, "Reply with exactly: OK"); + const finish = await client.waitForFinish(agent.id, 180_000); + expect(finish.status).toBe("idle"); - await client.sendMessage(agent.id, "Reply with exactly: OK"); - const finish = await client.waitForFinish(agent.id, 180_000); - expect(finish.status).toBe("idle"); - - const snapshot = await client.fetchAgent(agent.id); - expect(snapshot).not.toBeNull(); - const runtimeModelId = snapshot?.agent.runtimeInfo?.model ?? null; - expect(typeof runtimeModelId).toBe("string"); - expect(runtimeModelId).not.toBe(""); - expect(modelIds.has(runtimeModelId as string)).toBe(true); - } finally { - await client.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 300_000, - ); + const snapshot = await client.fetchAgent(agent.id); + expect(snapshot).not.toBeNull(); + const runtimeModelId = snapshot?.agent.runtimeInfo?.model ?? null; + expect(typeof runtimeModelId).toBe("string"); + expect(runtimeModelId).not.toBe(""); + expect(modelIds.has(runtimeModelId as string)).toBe(true); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 300_000); }); diff --git a/packages/server/src/server/daemon-e2e/opencode-custom-agents.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-custom-agents.real.e2e.test.ts index 190d8e189..b0d569647 100644 --- a/packages/server/src/server/daemon-e2e/opencode-custom-agents.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-custom-agents.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect } from "vitest"; +import { beforeAll, beforeEach, describe, test, expect } from "vitest"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -29,55 +29,63 @@ async function createHarness(): Promise<{ } describe("daemon E2E (real opencode) - custom agent discovery", () => { - test.runIf(isProviderAvailable("opencode"))( - "custom agents defined in opencode.json appear in availableModes", - async () => { - const cwd = tmpCwd(); - writeFileSync( - path.join(cwd, "opencode.json"), - JSON.stringify({ - agent: { - "paseo-e2e-custom": { - description: "Custom agent for Paseo daemon E2E test", - mode: "primary", - }, + let canRun = false; + + beforeAll(async () => { + canRun = await isProviderAvailable("opencode"); + }); + + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); + + test("custom agents defined in opencode.json appear in availableModes", async () => { + const cwd = tmpCwd(); + writeFileSync( + path.join(cwd, "opencode.json"), + JSON.stringify({ + agent: { + "paseo-e2e-custom": { + description: "Custom agent for Paseo daemon E2E test", + mode: "primary", }, - }), + }, + }), + ); + + const { client, daemon } = await createHarness(); + + try { + const agent = await client.createAgent({ + provider: "opencode", + cwd, + title: "OpenCode custom agent discovery", + }); + + // Wait for modes to be populated (they arrive after session init) + const snapshot = await client.waitForAgentUpsert( + agent.id, + (s) => s.availableModes.length > 0, + 30_000, ); - const { client, daemon } = await createHarness(); + expect(snapshot.availableModes.some((m) => m.id === "build")).toBe(true); + expect(snapshot.availableModes.some((m) => m.id === "plan")).toBe(true); - try { - const agent = await client.createAgent({ - provider: "opencode", - cwd, - title: "OpenCode custom agent discovery", - }); + const custom = snapshot.availableModes.find((m) => m.id === "paseo-e2e-custom"); + expect(custom).toBeDefined(); + expect(custom!.description).toBe("Custom agent for Paseo daemon E2E test"); - // Wait for modes to be populated (they arrive after session init) - const snapshot = await client.waitForAgentUpsert( - agent.id, - (s) => s.availableModes.length > 0, - 30_000, - ); - - expect(snapshot.availableModes.some((m) => m.id === "build")).toBe(true); - expect(snapshot.availableModes.some((m) => m.id === "plan")).toBe(true); - - const custom = snapshot.availableModes.find((m) => m.id === "paseo-e2e-custom"); - expect(custom).toBeDefined(); - expect(custom!.description).toBe("Custom agent for Paseo daemon E2E test"); - - // System agents should not leak through - expect(snapshot.availableModes.some((m) => m.id === "compaction")).toBe(false); - expect(snapshot.availableModes.some((m) => m.id === "summary")).toBe(false); - expect(snapshot.availableModes.some((m) => m.id === "title")).toBe(false); - } finally { - await client.close().catch(() => undefined); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 60_000, - ); + // System agents should not leak through + expect(snapshot.availableModes.some((m) => m.id === "compaction")).toBe(false); + expect(snapshot.availableModes.some((m) => m.id === "summary")).toBe(false); + expect(snapshot.availableModes.some((m) => m.id === "title")).toBe(false); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 60_000); }); diff --git a/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts index e3d000f9f..879cb61ee 100644 --- a/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -29,85 +29,89 @@ async function createHarness(): Promise<{ } describe("daemon E2E (real opencode) - initial prompt wait", () => { - test.runIf(isProviderAvailable("opencode"))( - "waitForFinish does not resolve before an initial prompt using opencode/big-pickle actually completes", - async () => { - const cwd = tmpCwd(); - const { client, daemon } = await createHarness(); + let canRun = false; - try { - const models = await client.listProviderModels("opencode"); - expect(models.models.some((model) => model.id === "zai/glm-5.1")).toBe(true); + beforeAll(async () => { + canRun = await isProviderAvailable("opencode"); + }); - const agent = await client.createAgent({ - provider: "opencode", - cwd, - title: "OpenCode initial prompt wait regression", - model: "opencode/big-pickle", - initialPrompt: "Reply with exactly: BIG_PICKLE_OK", - }); + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); - const finish = await client.waitForFinish(agent.id, 60_000); - expect(finish.status).toBe("idle"); - expect(finish.lastMessage).toContain("BIG_PICKLE_OK"); + test("waitForFinish does not resolve before an initial prompt using opencode/big-pickle actually completes", async () => { + const cwd = tmpCwd(); + const { client, daemon } = await createHarness(); - const snapshot = await client.fetchAgent(agent.id); - expect(snapshot.agent?.status).toBe("idle"); + try { + const models = await client.listProviderModels("opencode"); + expect(models.models.some((model) => model.id === "zai/glm-5.1")).toBe(true); - const timeline = await client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 0, - projection: "projected", - }); - const assistantMessages = timeline.entries.filter( - (entry) => entry.item.type === "assistant_message", - ); + const agent = await client.createAgent({ + provider: "opencode", + cwd, + title: "OpenCode initial prompt wait regression", + model: "opencode/big-pickle", + initialPrompt: "Reply with exactly: BIG_PICKLE_OK", + }); - expect(assistantMessages.length).toBeGreaterThan(0); - expect(assistantMessages.some((entry) => entry.item.text.includes("BIG_PICKLE_OK"))).toBe( - true, - ); - } finally { - await client.close().catch(() => undefined); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 90_000, - ); + const finish = await client.waitForFinish(agent.id, 60_000); + expect(finish.status).toBe("idle"); + expect(finish.lastMessage).toContain("BIG_PICKLE_OK"); - test.runIf(isProviderAvailable("opencode"))( - "waitForFinish surfaces a terminal error when zai/glm-5.1 enters a fatal retry loop", - async () => { - const cwd = tmpCwd(); - const { client, daemon } = await createHarness(); + const snapshot = await client.fetchAgent(agent.id); + expect(snapshot.agent?.status).toBe("idle"); - try { - const models = await client.listProviderModels("opencode"); - expect(models.models.some((model) => model.id === "zai/glm-5.1")).toBe(true); + const timeline = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "projected", + }); + const assistantMessages = timeline.entries.filter( + (entry) => entry.item.type === "assistant_message", + ); - const agent = await client.createAgent({ - provider: "opencode", - cwd, - title: "OpenCode zai fatal retry regression", - model: "zai/glm-5.1", - initialPrompt: "Reply with exactly: GLM_51_OK", - }); + expect(assistantMessages.length).toBeGreaterThan(0); + expect(assistantMessages.some((entry) => entry.item.text.includes("BIG_PICKLE_OK"))).toBe( + true, + ); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 90_000); - const finish = await client.waitForFinish(agent.id, 60_000); - expect(finish.status).toBe("error"); - expect((finish.error ?? "").toLowerCase()).toMatch( - /insufficient balance|resource package|recharge/, - ); + test("waitForFinish surfaces a terminal error when zai/glm-5.1 enters a fatal retry loop", async () => { + const cwd = tmpCwd(); + const { client, daemon } = await createHarness(); - const snapshot = await client.fetchAgent(agent.id); - expect(snapshot.agent?.status).toBe("error"); - } finally { - await client.close().catch(() => undefined); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 90_000, - ); + try { + const models = await client.listProviderModels("opencode"); + expect(models.models.some((model) => model.id === "zai/glm-5.1")).toBe(true); + + const agent = await client.createAgent({ + provider: "opencode", + cwd, + title: "OpenCode zai fatal retry regression", + model: "zai/glm-5.1", + initialPrompt: "Reply with exactly: GLM_51_OK", + }); + + const finish = await client.waitForFinish(agent.id, 60_000); + expect(finish.status).toBe("error"); + expect((finish.error ?? "").toLowerCase()).toMatch( + /insufficient balance|resource package|recharge/, + ); + + const snapshot = await client.fetchAgent(agent.id); + expect(snapshot.agent?.status).toBe("error"); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 90_000); }); diff --git a/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts index 903665674..6d0af170c 100644 --- a/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -29,33 +29,41 @@ async function createHarness(): Promise<{ } describe("daemon E2E (real opencode) - invalid model handling", () => { - test.runIf(isProviderAvailable("opencode"))( - "initial prompt with a nonexistent OpenCode model fails instead of hanging forever", - async () => { - const cwd = tmpCwd(); - const { client, daemon } = await createHarness(); + let canRun = false; - try { - const agent = await client.createAgent({ - provider: "opencode", - cwd, - title: "OpenCode invalid model regression", - model: "opencode/adklasldkdas", - initialPrompt: "hello", - }); + beforeAll(async () => { + canRun = await isProviderAvailable("opencode"); + }); - const finish = await client.waitForFinish(agent.id, 30_000); - expect(finish.status).toBe("error"); - expect(finish.error).toBeTruthy(); + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); - const snapshot = await client.fetchAgent(agent.id); - expect(snapshot.agent?.status).toBe("error"); - } finally { - await client.close().catch(() => undefined); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 60_000, - ); + test("initial prompt with a nonexistent OpenCode model fails instead of hanging forever", async () => { + const cwd = tmpCwd(); + const { client, daemon } = await createHarness(); + + try { + const agent = await client.createAgent({ + provider: "opencode", + cwd, + title: "OpenCode invalid model regression", + model: "opencode/adklasldkdas", + initialPrompt: "hello", + }); + + const finish = await client.waitForFinish(agent.id, 30_000); + expect(finish.status).toBe("error"); + expect(finish.error).toBeTruthy(); + + const snapshot = await client.fetchAgent(agent.id); + expect(snapshot.agent?.status).toBe("error"); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 60_000); }); diff --git a/packages/server/src/server/daemon-e2e/opencode-plan-and-questions.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-plan-and-questions.real.e2e.test.ts index 1f7f47ba9..d12bb8d19 100644 --- a/packages/server/src/server/daemon-e2e/opencode-plan-and-questions.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-plan-and-questions.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect } from "vitest"; +import { beforeAll, beforeEach, describe, test, expect } from "vitest"; import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -39,102 +39,106 @@ async function createHarness(): Promise<{ } describe("daemon E2E (real opencode) - plan mode and clarifying questions", () => { - test.runIf(isProviderAvailable("opencode"))( - "surfaces clarifying questions as pending permissions", - async () => { - const cwd = tmpCwd(); - const { client, daemon } = await createHarness(); + let canRun = false; - try { - const modelList = await client.listProviderModels("opencode"); - expect(modelList.models.length).toBeGreaterThan(0); + beforeAll(async () => { + canRun = await isProviderAvailable("opencode"); + }); - const agent = await client.createAgent({ - provider: "opencode", - cwd, - title: "OpenCode question regression", - model: pickOpenCodeModel(modelList.models, ["minimax-m2.5-free", "minimax", "free"]), - modeId: "plan", - }); + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); - await client.sendMessage( - agent.id, - [ - "Use the question tool/feature to ask me exactly one clarifying question.", - "Ask this exact question: What kind of project should the plan cover?", - "Wait for my answer before doing anything else.", - ].join(" "), - ); + test("surfaces clarifying questions as pending permissions", async () => { + const cwd = tmpCwd(); + const { client, daemon } = await createHarness(); - const snapshotWithQuestion = await client.waitForAgentUpsert( - agent.id, - (snapshot) => (snapshot.pendingPermissions?.[0]?.kind ?? null) === "question", - 30_000, - ); + try { + const modelList = await client.listProviderModels("opencode"); + expect(modelList.models.length).toBeGreaterThan(0); - expect(snapshotWithQuestion.pendingPermissions?.length).toBeGreaterThan(0); + const agent = await client.createAgent({ + provider: "opencode", + cwd, + title: "OpenCode question regression", + model: pickOpenCodeModel(modelList.models, ["minimax-m2.5-free", "minimax", "free"]), + modeId: "plan", + }); - const permission = snapshotWithQuestion.pendingPermissions?.[0]; - expect(permission).toBeTruthy(); - expect(permission?.kind).toBe("question"); - expect(Array.isArray(permission?.input?.questions)).toBe(true); + await client.sendMessage( + agent.id, + [ + "Use the question tool/feature to ask me exactly one clarifying question.", + "Ask this exact question: What kind of project should the plan cover?", + "Wait for my answer before doing anything else.", + ].join(" "), + ); - const firstQuestion = permission?.input?.questions?.[0] as { header?: string } | undefined; - expect(firstQuestion?.header).toBeTruthy(); - } finally { - await client.close().catch(() => undefined); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 180_000, - ); + const snapshotWithQuestion = await client.waitForAgentUpsert( + agent.id, + (snapshot) => (snapshot.pendingPermissions?.[0]?.kind ?? null) === "question", + 30_000, + ); - test.runIf(isProviderAvailable("opencode"))( - "plan mode stays read-only through the daemon path", - async () => { - const cwd = tmpCwd(); - const filePath = path.join(cwd, "plan-mode-output.txt"); - const { client, daemon } = await createHarness(); + expect(snapshotWithQuestion.pendingPermissions?.length).toBeGreaterThan(0); - try { - const modelList = await client.listProviderModels("opencode"); - expect(modelList.models.length).toBeGreaterThan(0); + const permission = snapshotWithQuestion.pendingPermissions?.[0]; + expect(permission).toBeTruthy(); + expect(permission?.kind).toBe("question"); + expect(Array.isArray(permission?.input?.questions)).toBe(true); - const agent = await client.createAgent({ - provider: "opencode", - cwd, - title: "OpenCode plan mode regression", - model: pickOpenCodeModel(modelList.models), - modeId: "plan", - }); + const firstQuestion = permission?.input?.questions?.[0] as { header?: string } | undefined; + expect(firstQuestion?.header).toBeTruthy(); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 180_000); - await client.sendMessage( - agent.id, - "Create a file named plan-mode-output.txt in the current directory containing exactly hello.", - ); + test("plan mode stays read-only through the daemon path", async () => { + const cwd = tmpCwd(); + const filePath = path.join(cwd, "plan-mode-output.txt"); + const { client, daemon } = await createHarness(); - const state = await client.waitForFinish(agent.id, 180_000); - expect(state.status).toBe("idle"); - expect(existsSync(filePath)).toBe(false); + try { + const modelList = await client.listProviderModels("opencode"); + expect(modelList.models.length).toBeGreaterThan(0); - const timeline = await client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 0, - projection: "projected", - }); - const assistantText = timeline.entries - .filter((entry) => entry.item.type === "assistant_message") - .map((entry) => entry.item.text) - .join(" "); + const agent = await client.createAgent({ + provider: "opencode", + cwd, + title: "OpenCode plan mode regression", + model: pickOpenCodeModel(modelList.models), + modeId: "plan", + }); - expect(assistantText.toLowerCase()).toContain("plan"); - } finally { - await client.close().catch(() => undefined); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 240_000, - ); + await client.sendMessage( + agent.id, + "Create a file named plan-mode-output.txt in the current directory containing exactly hello.", + ); + + const state = await client.waitForFinish(agent.id, 180_000); + expect(state.status).toBe("idle"); + expect(existsSync(filePath)).toBe(false); + + const timeline = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "projected", + }); + const assistantText = timeline.entries + .filter((entry) => entry.item.type === "assistant_message") + .map((entry) => entry.item.text) + .join(" "); + + expect(assistantText.toLowerCase()).toContain("plan"); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 240_000); }); diff --git a/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts index f0539ba1a..01bbbe7bd 100644 --- a/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -281,142 +281,146 @@ async function createHarness(): Promise<{ } describe("daemon E2E (real opencode) - send while working and interrupt", () => { - test.runIf(isProviderAvailable("opencode"))( - "send_message while sleep tool call is running starts a clean replacement turn", - async () => { - const cwd = tmpCwd(); - const { client, daemon } = await createHarness(); - const collector = createMessageCollector(client); - const followUpToken = "OPENCODE_SEND_WHILE_WORKING_OK"; + let canRun = false; - try { - const modelList = await client.listProviderModels("opencode"); - expect(modelList.models.length).toBeGreaterThan(0); + beforeAll(async () => { + canRun = await isProviderAvailable("opencode"); + }); - const agent = await client.createAgent({ - provider: "opencode", - cwd, - title: "OpenCode send while working", - model: pickOpenCodeModel(modelList.models), - modeId: "default", - }); + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); - await client.sendMessage( - agent.id, - [ - "Use the Bash tool.", - "Run exactly: sleep 60", - "Do not run it in the background.", - "Do not do anything after starting the command.", - ].join(" "), - ); + test("send_message while sleep tool call is running starts a clean replacement turn", async () => { + const cwd = tmpCwd(); + const { client, daemon } = await createHarness(); + const collector = createMessageCollector(client); + const followUpToken = "OPENCODE_SEND_WHILE_WORKING_OK"; - await client.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 90_000, - ); - await waitForRunningBashToolCall(client, collector, agent.id); + try { + const modelList = await client.listProviderModels("opencode"); + expect(modelList.models.length).toBeGreaterThan(0); - collector.clear(); - await client.sendMessage(agent.id, `Reply with exactly: ${followUpToken}`); + const agent = await client.createAgent({ + provider: "opencode", + cwd, + title: "OpenCode send while working", + model: pickOpenCodeModel(modelList.models), + modeId: "default", + }); - const finish = await waitForIdleResolvingPermissions(client, agent.id, 240_000); - expect(finish.status).toBe("idle"); + await client.sendMessage( + agent.id, + [ + "Use the Bash tool.", + "Run exactly: sleep 60", + "Do not run it in the background.", + "Do not do anything after starting the command.", + ].join(" "), + ); - const postSendAssistantTexts = getAssistantTexts(collector.messages, agent.id); - expect(postSendAssistantTexts.some((text) => text.includes("[System Error]"))).toBe(false); - expect(postSendAssistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET))).toBe( - false, - ); + await client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 90_000, + ); + await waitForRunningBashToolCall(client, collector, agent.id); - const timeline = await client.fetchAgentTimeline(agent.id, { limit: 160 }); - const assistantTexts = getTimelineAssistantTexts(timeline); - expect(assistantTexts.some((text) => text.includes(followUpToken))).toBe(true); - expect(assistantTexts.some((text) => text.includes("[System Error]"))).toBe(false); - expect(assistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET))).toBe(false); - } finally { - collector.unsubscribe(); - await client.close().catch(() => undefined); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 360_000, - ); + collector.clear(); + await client.sendMessage(agent.id, `Reply with exactly: ${followUpToken}`); - test.runIf(isProviderAvailable("opencode"))( - "explicit interrupt during sleep tool call still allows the next turn to complete", - async () => { - const cwd = tmpCwd(); - const { client, daemon } = await createHarness(); - const collector = createMessageCollector(client); - const followUpToken = "OPENCODE_INTERRUPT_FOLLOWUP_OK"; + const finish = await waitForIdleResolvingPermissions(client, agent.id, 240_000); + expect(finish.status).toBe("idle"); - try { - const modelList = await client.listProviderModels("opencode"); - expect(modelList.models.length).toBeGreaterThan(0); + const postSendAssistantTexts = getAssistantTexts(collector.messages, agent.id); + expect(postSendAssistantTexts.some((text) => text.includes("[System Error]"))).toBe(false); + expect(postSendAssistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET))).toBe( + false, + ); - const agent = await client.createAgent({ - provider: "opencode", - cwd, - title: "OpenCode explicit interrupt", - model: pickOpenCodeModel(modelList.models), - modeId: "default", - }); + const timeline = await client.fetchAgentTimeline(agent.id, { limit: 160 }); + const assistantTexts = getTimelineAssistantTexts(timeline); + expect(assistantTexts.some((text) => text.includes(followUpToken))).toBe(true); + expect(assistantTexts.some((text) => text.includes("[System Error]"))).toBe(false); + expect(assistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET))).toBe(false); + } finally { + collector.unsubscribe(); + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 360_000); - await client.sendMessage( - agent.id, - [ - "Use the Bash tool.", - "Run exactly: sleep 60", - "Do not run it in the background.", - "Do not do anything after starting the command.", - ].join(" "), - ); + test("explicit interrupt during sleep tool call still allows the next turn to complete", async () => { + const cwd = tmpCwd(); + const { client, daemon } = await createHarness(); + const collector = createMessageCollector(client); + const followUpToken = "OPENCODE_INTERRUPT_FOLLOWUP_OK"; - await client.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 90_000, - ); - await waitForRunningBashToolCall(client, collector, agent.id); + try { + const modelList = await client.listProviderModels("opencode"); + expect(modelList.models.length).toBeGreaterThan(0); - await client.cancelAgent(agent.id); - await client.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "idle" || snapshot.status === "error", - 90_000, - ); - const interruptedToolCall = await waitForSleepToolCallTerminal(client, agent.id, 45_000); - expect(interruptedToolCall.status).toBe("failed"); + const agent = await client.createAgent({ + provider: "opencode", + cwd, + title: "OpenCode explicit interrupt", + model: pickOpenCodeModel(modelList.models), + modeId: "default", + }); - collector.clear(); - await client.sendMessage(agent.id, `Reply with exactly: ${followUpToken}`); + await client.sendMessage( + agent.id, + [ + "Use the Bash tool.", + "Run exactly: sleep 60", + "Do not run it in the background.", + "Do not do anything after starting the command.", + ].join(" "), + ); - const finish = await waitForIdleResolvingPermissions(client, agent.id, 240_000); - expect(finish.status).toBe("idle"); + await client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 90_000, + ); + await waitForRunningBashToolCall(client, collector, agent.id); - const postInterruptAssistantTexts = getAssistantTexts(collector.messages, agent.id); - expect(postInterruptAssistantTexts.some((text) => text.includes("[System Error]"))).toBe( - false, - ); - expect( - postInterruptAssistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET)), - ).toBe(false); + await client.cancelAgent(agent.id); + await client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "idle" || snapshot.status === "error", + 90_000, + ); + const interruptedToolCall = await waitForSleepToolCallTerminal(client, agent.id, 45_000); + expect(interruptedToolCall.status).toBe("failed"); - const timeline = await client.fetchAgentTimeline(agent.id, { limit: 200 }); - const assistantTexts = getTimelineAssistantTexts(timeline); - expect(assistantTexts.some((text) => text.includes(followUpToken))).toBe(true); - expect(assistantTexts.some((text) => text.includes("[System Error]"))).toBe(false); - expect(assistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET))).toBe(false); - } finally { - collector.unsubscribe(); - await client.close().catch(() => undefined); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 360_000, - ); + collector.clear(); + await client.sendMessage(agent.id, `Reply with exactly: ${followUpToken}`); + + const finish = await waitForIdleResolvingPermissions(client, agent.id, 240_000); + expect(finish.status).toBe("idle"); + + const postInterruptAssistantTexts = getAssistantTexts(collector.messages, agent.id); + expect(postInterruptAssistantTexts.some((text) => text.includes("[System Error]"))).toBe( + false, + ); + expect(postInterruptAssistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET))).toBe( + false, + ); + + const timeline = await client.fetchAgentTimeline(agent.id, { limit: 200 }); + const assistantTexts = getTimelineAssistantTexts(timeline); + expect(assistantTexts.some((text) => text.includes(followUpToken))).toBe(true); + expect(assistantTexts.some((text) => text.includes("[System Error]"))).toBe(false); + expect(assistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET))).toBe(false); + } finally { + collector.unsubscribe(); + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 360_000); }); diff --git a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts index a0f5ac8b1..236a7c0c0 100644 --- a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -16,61 +16,69 @@ function tmpCwd(): string { } describe("daemon E2E (real pi)", () => { - test.runIf(isProviderAvailable("pi"))( - "smoke test with thinking option configured separately from modes", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { pi: new PiACPAgentClient({ logger }) }, - logger, + let canRun = false; + + beforeAll(async () => { + canRun = await isProviderAvailable("pi"); + }); + + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); + + test("smoke test with thinking option configured separately from modes", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { pi: new PiACPAgentClient({ logger }) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: "pi-real-smoke" }, }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: "pi-real-smoke" }, - }); + const agent = await client.createAgent({ + cwd, + title: "pi-real-smoke", + provider: "pi", + thinkingOptionId: "medium", + }); - const agent = await client.createAgent({ - cwd, - title: "pi-real-smoke", - provider: "pi", - thinkingOptionId: "medium", - }); + await client.sendMessage(agent.id, "Reply with exactly: PINEAPPLE"); - await client.sendMessage(agent.id, "Reply with exactly: PINEAPPLE"); + const finish = await client.waitForFinish(agent.id, 240_000); + expect(finish.status).toBe("idle"); + expect(finish.final?.persistence).toBeTruthy(); + expect(finish.final?.persistence?.provider).toBe("pi"); + expect(finish.final?.persistence?.sessionId).toBeTruthy(); - const finish = await client.waitForFinish(agent.id, 240_000); - expect(finish.status).toBe("idle"); - expect(finish.final?.persistence).toBeTruthy(); - expect(finish.final?.persistence?.provider).toBe("pi"); - expect(finish.final?.persistence?.sessionId).toBeTruthy(); + const timeline = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + const assistantText = timeline.entries + .filter( + ( + entry, + ): entry is typeof entry & { + item: { type: "assistant_message"; text: string }; + } => entry.item.type === "assistant_message", + ) + .map((entry) => entry.item.text) + .join("\n"); - const timeline = await client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 0, - projection: "canonical", - }); - const assistantText = timeline.entries - .filter( - ( - entry, - ): entry is typeof entry & { - item: { type: "assistant_message"; text: string }; - } => entry.item.type === "assistant_message", - ) - .map((entry) => entry.item.text) - .join("\n"); - - expect(assistantText.replace(/\s+/g, "")).toContain("PINEAPPLE"); - } finally { - await client.close().catch(() => undefined); - await daemon.close().catch(() => undefined); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 420_000, - ); + expect(assistantText.replace(/\s+/g, "")).toContain("PINEAPPLE"); + } finally { + await client.close().catch(() => undefined); + await daemon.close().catch(() => undefined); + rmSync(cwd, { recursive: true, force: true }); + } + }, 420_000); }); diff --git a/packages/server/src/server/daemon-e2e/rewind-user-message-dedupe-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/rewind-user-message-dedupe-claude.real.e2e.test.ts index 7c5627db1..02aa75e14 100644 --- a/packages/server/src/server/daemon-e2e/rewind-user-message-dedupe-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/rewind-user-message-dedupe-claude.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -14,54 +14,62 @@ function tmpCwd(): string { } describe("daemon E2E (real claude) - rewind user message dedupe", () => { - test.runIf(isProviderAvailable("claude"))( - "emits /rewind user message once in persisted timeline", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { claude: new ClaudeAgentClient({ logger }) }, - logger, + let canRun = false; + + beforeAll(async () => { + canRun = await isProviderAvailable("claude"); + }); + + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); + + test("emits /rewind user message once in persisted timeline", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: "rewind-user-message-dedupe" }, }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: "rewind-user-message-dedupe" }, - }); + const agent = await client.createAgent({ + cwd, + title: "rewind-user-message-dedupe-real-claude", + ...getFullAccessConfig("claude"), + }); - const agent = await client.createAgent({ - cwd, - title: "rewind-user-message-dedupe-real-claude", - ...getFullAccessConfig("claude"), - }); + await client.sendMessage(agent.id, "Reply with exactly: READY"); + const initialResult = await client.waitForFinish(agent.id, 180_000); + expect(initialResult.status).toBe("idle"); - await client.sendMessage(agent.id, "Reply with exactly: READY"); - const initialResult = await client.waitForFinish(agent.id, 180_000); - expect(initialResult.status).toBe("idle"); + await client.sendMessage(agent.id, "/rewind"); + const rewindResult = await client.waitForFinish(agent.id, 180_000); + expect(rewindResult.status).not.toBe("timeout"); - await client.sendMessage(agent.id, "/rewind"); - const rewindResult = await client.waitForFinish(agent.id, 180_000); - expect(rewindResult.status).not.toBe("timeout"); + const timeline = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); - const timeline = await client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 0, - projection: "canonical", - }); + const rewindUserMessages = timeline.entries.filter( + (entry) => entry.item.type === "user_message" && entry.item.text.trim() === "/rewind", + ); - const rewindUserMessages = timeline.entries.filter( - (entry) => entry.item.type === "user_message" && entry.item.text.trim() === "/rewind", - ); - - expect(rewindUserMessages).toHaveLength(1); - } finally { - await client.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 300_000, - ); + expect(rewindUserMessages).toHaveLength(1); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 300_000); }); diff --git a/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts index 5b427c590..4f66d02f9 100644 --- a/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect } from "vitest"; +import { beforeAll, beforeEach, describe, test, expect } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -168,111 +168,119 @@ async function waitForRunningToolCall( } describe("daemon E2E (real claude) - send message during tool call", () => { - test.runIf(isProviderAvailable("claude"))( - "sending a message while a tool call is running replaces the turn without error, idle flash, or autonomous fallback", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { claude: new ClaudeAgentClient({ logger }) }, - logger, + let canRun = false; + + beforeAll(async () => { + canRun = await isProviderAvailable("claude"); + }); + + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); + + test("sending a message while a tool call is running replaces the turn without error, idle flash, or autonomous fallback", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "primary" } }); + + const agent = await client.createAgent({ + cwd, + title: "tool-interrupt-repro", + ...getFullAccessConfig("claude"), }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const collector = createMessageCollector(client); - try { - await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "primary" } }); + // Step 1: Ask Claude to run sleep 60 in the foreground + await client.sendMessage( + agent.id, + [ + "Use the Bash tool.", + "Run exactly: sleep 60", + "Do not use a background task.", + "Do not do anything after starting the command.", + ].join(" "), + ); - const agent = await client.createAgent({ - cwd, - title: "tool-interrupt-repro", - ...getFullAccessConfig("claude"), + // Step 2: Wait for the agent to be running + await client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 60_000, + ); + + // Step 3: Wait for a tool call to appear as "running" in the stream + await waitForRunningToolCall(client, collector, agent.id); + + collector.clear(); + + // Step 4: Send a second message while the tool call is still running + await client.sendMessage(agent.id, "Reply with exactly: INTERRUPT_RECEIVED"); + + // Step 5: Wait for the agent to finish — this is the critical assertion. + // If the bug is present, the agent will stop and never start a new turn. + const finish = await client.waitForFinish(agent.id, 120_000); + const postSendMessages = [...collector.messages]; + const postSendAssistantTexts = getAssistantTexts(postSendMessages, agent.id); + const postSendStatuses = getAgentStatuses(postSendMessages, agent.id); + const statusesBeforeFirstAssistant = getStatusesBeforeFirstAssistant( + postSendMessages, + agent.id, + ); + const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 }); + + if (finish.status !== "idle") { + const snapshot = await client.fetchAgent(agent.id); + throw new Error( + `Expected idle after replacement, got ${finish.status}. postSendStatuses=${JSON.stringify(postSendStatuses)} statusesBeforeFirstAssistant=${JSON.stringify(statusesBeforeFirstAssistant)} postSendAssistantTexts=${JSON.stringify(postSendAssistantTexts)} turnStarted=${countTurnStarted(postSendMessages, agent.id)} agentStatus=${snapshot?.agent.status ?? null} recentTimeline=${JSON.stringify(summarizeTimelineItems(timeline))}`, + ); + } + + // Replacement should create exactly one new turn. A second turn_started here + // means the reply got displaced onto a later autonomous wake. + expect(countTurnStarted(postSendMessages, agent.id)).toBe(1); + + // The replacement path should not surface as agent error state. + expect(postSendStatuses).not.toContain("error"); + + // The agent should not flash idle before the replacement produces visible output. + expect(statusesBeforeFirstAssistant).not.toContain("idle"); + expect(statusesBeforeFirstAssistant).not.toContain("error"); + + // Step 6: Verify the agent actually responded to our second message + const assistantTexts = timeline.entries + .filter((entry) => entry.item.type === "assistant_message") + .map((entry) => { + const item = entry.item as Extract; + return item.text; }); - const collector = createMessageCollector(client); + // No system error messages should leak into the timeline + const hasSystemError = assistantTexts.some((text) => text.includes("[System Error]")); + expect(hasSystemError).toBe(false); + expect(postSendAssistantTexts.some((text) => text.includes("[System Error]"))).toBe(false); - // Step 1: Ask Claude to run sleep 60 in the foreground - await client.sendMessage( - agent.id, - [ - "Use the Bash tool.", - "Run exactly: sleep 60", - "Do not use a background task.", - "Do not do anything after starting the command.", - ].join(" "), - ); + const responded = assistantTexts.some((text) => + text.toUpperCase().includes("INTERRUPT_RECEIVED"), + ); + expect(responded).toBe(true); - // Step 2: Wait for the agent to be running - await client.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 60_000, - ); - - // Step 3: Wait for a tool call to appear as "running" in the stream - await waitForRunningToolCall(client, collector, agent.id); - - collector.clear(); - - // Step 4: Send a second message while the tool call is still running - await client.sendMessage(agent.id, "Reply with exactly: INTERRUPT_RECEIVED"); - - // Step 5: Wait for the agent to finish — this is the critical assertion. - // If the bug is present, the agent will stop and never start a new turn. - const finish = await client.waitForFinish(agent.id, 120_000); - const postSendMessages = [...collector.messages]; - const postSendAssistantTexts = getAssistantTexts(postSendMessages, agent.id); - const postSendStatuses = getAgentStatuses(postSendMessages, agent.id); - const statusesBeforeFirstAssistant = getStatusesBeforeFirstAssistant( - postSendMessages, - agent.id, - ); - const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 }); - - if (finish.status !== "idle") { - const snapshot = await client.fetchAgent(agent.id); - throw new Error( - `Expected idle after replacement, got ${finish.status}. postSendStatuses=${JSON.stringify(postSendStatuses)} statusesBeforeFirstAssistant=${JSON.stringify(statusesBeforeFirstAssistant)} postSendAssistantTexts=${JSON.stringify(postSendAssistantTexts)} turnStarted=${countTurnStarted(postSendMessages, agent.id)} agentStatus=${snapshot?.agent.status ?? null} recentTimeline=${JSON.stringify(summarizeTimelineItems(timeline))}`, - ); - } - - // Replacement should create exactly one new turn. A second turn_started here - // means the reply got displaced onto a later autonomous wake. - expect(countTurnStarted(postSendMessages, agent.id)).toBe(1); - - // The replacement path should not surface as agent error state. - expect(postSendStatuses).not.toContain("error"); - - // The agent should not flash idle before the replacement produces visible output. - expect(statusesBeforeFirstAssistant).not.toContain("idle"); - expect(statusesBeforeFirstAssistant).not.toContain("error"); - - // Step 6: Verify the agent actually responded to our second message - const assistantTexts = timeline.entries - .filter((entry) => entry.item.type === "assistant_message") - .map((entry) => { - const item = entry.item as Extract; - return item.text; - }); - - // No system error messages should leak into the timeline - const hasSystemError = assistantTexts.some((text) => text.includes("[System Error]")); - expect(hasSystemError).toBe(false); - expect(postSendAssistantTexts.some((text) => text.includes("[System Error]"))).toBe(false); - - const responded = assistantTexts.some((text) => - text.toUpperCase().includes("INTERRUPT_RECEIVED"), - ); - expect(responded).toBe(true); - - collector.unsubscribe(); - } finally { - await client.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 300_000, - ); + collector.unsubscribe(); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 300_000); }); diff --git a/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts index 2ed6ff1f1..ced32c2b3 100644 --- a/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect } from "vitest"; +import { beforeAll, beforeEach, describe, test, expect } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -15,109 +15,117 @@ function tmpCwd(): string { } describe("daemon E2E (real claude) - send while running recovery", () => { - test.runIf(isProviderAvailable("claude"))( - "clears input processing when the interrupt transition is missed", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { claude: new ClaudeAgentClient({ logger }) }, - logger, + let canRun = false; + + beforeAll(async () => { + canRun = await isProviderAvailable("claude"); + }); + + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); + + test("clears input processing when the interrupt transition is missed", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + + const primary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const secondary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await primary.connect(); + await secondary.connect(); + await primary.fetchAgents({ subscribe: { subscriptionId: "primary" } }); + await secondary.fetchAgents({ subscribe: { subscriptionId: "secondary" } }); + + const agent = await primary.createAgent({ + cwd, + title: "stuck-repro-real-claude", + ...getFullAccessConfig("claude"), }); - const primary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - const secondary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + await primary.sendMessage( + agent.id, + "Run bash command sleep 30, wait for completion, then reply done.", + ); + await primary.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 60_000, + ); + let isProcessing = true; + let previousIsRunning = true; + let latestUpdatedAt = Date.now(); + + await primary.close(); + + await secondary.sendMessage(agent.id, "Reply with exactly: state saved"); + await secondary.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 60_000, + ); + + const reconnected = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); try { - await primary.connect(); - await secondary.connect(); - await primary.fetchAgents({ subscribe: { subscriptionId: "primary" } }); - await secondary.fetchAgents({ subscribe: { subscriptionId: "secondary" } }); + await reconnected.connect(); + const applySnapshot = ( + snapshot: Parameters[0]["snapshot"], + ) => { + const next = applyAgentInputProcessingTransition({ + snapshot, + currentIsProcessing: isProcessing, + previousIsRunning, + latestUpdatedAt, + }); + isProcessing = next.isProcessing; + previousIsRunning = next.previousIsRunning; + latestUpdatedAt = next.latestUpdatedAt; + }; - const agent = await primary.createAgent({ - cwd, - title: "stuck-repro-real-claude", - ...getFullAccessConfig("claude"), + reconnected.on("agent_update", (message) => { + if (message.type !== "agent_update" || message.payload.kind !== "upsert") { + return; + } + if (message.payload.agent.id !== agent.id) { + return; + } + applySnapshot(message.payload.agent); }); - await primary.sendMessage( - agent.id, - "Run bash command sleep 30, wait for completion, then reply done.", - ); - await primary.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 60_000, - ); - - let isProcessing = true; - let previousIsRunning = true; - let latestUpdatedAt = Date.now(); - - await primary.close(); - - await secondary.sendMessage(agent.id, "Reply with exactly: state saved"); - await secondary.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 60_000, - ); - - const reconnected = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - try { - await reconnected.connect(); - const applySnapshot = ( - snapshot: Parameters[0]["snapshot"], - ) => { - const next = applyAgentInputProcessingTransition({ - snapshot, - currentIsProcessing: isProcessing, - previousIsRunning, - latestUpdatedAt, - }); - isProcessing = next.isProcessing; - previousIsRunning = next.previousIsRunning; - latestUpdatedAt = next.latestUpdatedAt; - }; - - reconnected.on("agent_update", (message) => { - if (message.type !== "agent_update" || message.payload.kind !== "upsert") { - return; - } - if (message.payload.agent.id !== agent.id) { - return; - } - applySnapshot(message.payload.agent); - }); - - const initial = await reconnected.fetchAgents({ - subscribe: { subscriptionId: "reconnected" }, - }); - const hydratedSnapshot = initial.entries.find( - (candidate) => candidate.agent.id === agent.id, - )?.agent; - if (hydratedSnapshot) { - applySnapshot(hydratedSnapshot); - } - - await secondary.waitForFinish(agent.id, 180_000); - const finalResult = await secondary.fetchAgent(agent.id); - if (finalResult) { - applySnapshot(finalResult.agent); - } - - // Sending while running should clear processing even if reconnect misses the - // not-running -> running transition. - expect(isProcessing).toBe(false); - } finally { - await reconnected.close(); + const initial = await reconnected.fetchAgents({ + subscribe: { subscriptionId: "reconnected" }, + }); + const hydratedSnapshot = initial.entries.find( + (candidate) => candidate.agent.id === agent.id, + )?.agent; + if (hydratedSnapshot) { + applySnapshot(hydratedSnapshot); } + + await secondary.waitForFinish(agent.id, 180_000); + const finalResult = await secondary.fetchAgent(agent.id); + if (finalResult) { + applySnapshot(finalResult.agent); + } + + // Sending while running should clear processing even if reconnect misses the + // not-running -> running transition. + expect(isProcessing).toBe(false); } finally { - await secondary.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); + await reconnected.close(); } - }, - 300_000, - ); + } finally { + await secondary.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 300_000); }); diff --git a/packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts index fb5470b6a..bc40fab46 100644 --- a/packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect } from "vitest"; +import { beforeAll, beforeEach, describe, test, expect } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -15,106 +15,114 @@ function tmpCwd(): string { } describe("daemon E2E (real codex) - send while running recovery", () => { - test.runIf(isProviderAvailable("codex"))( - "clears input processing when the interrupt transition is missed", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { codex: new CodexAppServerAgentClient(logger) }, - logger, + let canRun = false; + + beforeAll(async () => { + canRun = await isProviderAvailable("codex"); + }); + + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); + + test("clears input processing when the interrupt transition is missed", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { codex: new CodexAppServerAgentClient(logger) }, + logger, + }); + + const primary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const secondary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await primary.connect(); + await secondary.connect(); + await primary.fetchAgents({ subscribe: { subscriptionId: "primary" } }); + await secondary.fetchAgents({ subscribe: { subscriptionId: "secondary" } }); + + const agent = await primary.createAgent({ + cwd, + title: "stuck-repro-real-codex", + ...getFullAccessConfig("codex"), }); - const primary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - const secondary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + await primary.sendMessage(agent.id, "Run: sleep 5"); + await primary.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 30_000, + ); + let isProcessing = true; + let previousIsRunning = true; + let latestUpdatedAt = Date.now(); + + await primary.close(); + + await secondary.sendMessage(agent.id, "Reply with exactly: state saved"); + await secondary.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 30_000, + ); + + const reconnected = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); try { - await primary.connect(); - await secondary.connect(); - await primary.fetchAgents({ subscribe: { subscriptionId: "primary" } }); - await secondary.fetchAgents({ subscribe: { subscriptionId: "secondary" } }); + await reconnected.connect(); + const applySnapshot = ( + snapshot: Parameters[0]["snapshot"], + ) => { + const next = applyAgentInputProcessingTransition({ + snapshot, + currentIsProcessing: isProcessing, + previousIsRunning, + latestUpdatedAt, + }); + isProcessing = next.isProcessing; + previousIsRunning = next.previousIsRunning; + latestUpdatedAt = next.latestUpdatedAt; + }; - const agent = await primary.createAgent({ - cwd, - title: "stuck-repro-real-codex", - ...getFullAccessConfig("codex"), + reconnected.on("agent_update", (message) => { + if (message.type !== "agent_update" || message.payload.kind !== "upsert") { + return; + } + if (message.payload.agent.id !== agent.id) { + return; + } + applySnapshot(message.payload.agent); }); - await primary.sendMessage(agent.id, "Run: sleep 5"); - await primary.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 30_000, - ); - - let isProcessing = true; - let previousIsRunning = true; - let latestUpdatedAt = Date.now(); - - await primary.close(); - - await secondary.sendMessage(agent.id, "Reply with exactly: state saved"); - await secondary.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 30_000, - ); - - const reconnected = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - try { - await reconnected.connect(); - const applySnapshot = ( - snapshot: Parameters[0]["snapshot"], - ) => { - const next = applyAgentInputProcessingTransition({ - snapshot, - currentIsProcessing: isProcessing, - previousIsRunning, - latestUpdatedAt, - }); - isProcessing = next.isProcessing; - previousIsRunning = next.previousIsRunning; - latestUpdatedAt = next.latestUpdatedAt; - }; - - reconnected.on("agent_update", (message) => { - if (message.type !== "agent_update" || message.payload.kind !== "upsert") { - return; - } - if (message.payload.agent.id !== agent.id) { - return; - } - applySnapshot(message.payload.agent); - }); - - const initial = await reconnected.fetchAgents({ - subscribe: { subscriptionId: "reconnected" }, - }); - const hydratedSnapshot = initial.entries.find( - (candidate) => candidate.agent.id === agent.id, - )?.agent; - if (hydratedSnapshot) { - applySnapshot(hydratedSnapshot); - } - - await secondary.waitForFinish(agent.id, 120_000); - const finalResult = await secondary.fetchAgent(agent.id); - if (finalResult) { - applySnapshot(finalResult.agent); - } - - // Sending while running should clear processing even if reconnect misses the - // not-running -> running transition. - expect(isProcessing).toBe(false); - } finally { - await reconnected.close(); + const initial = await reconnected.fetchAgents({ + subscribe: { subscriptionId: "reconnected" }, + }); + const hydratedSnapshot = initial.entries.find( + (candidate) => candidate.agent.id === agent.id, + )?.agent; + if (hydratedSnapshot) { + applySnapshot(hydratedSnapshot); } + + await secondary.waitForFinish(agent.id, 120_000); + const finalResult = await secondary.fetchAgent(agent.id); + if (finalResult) { + applySnapshot(finalResult.agent); + } + + // Sending while running should clear processing even if reconnect misses the + // not-running -> running transition. + expect(isProcessing).toBe(false); } finally { - await secondary.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); + await reconnected.close(); } - }, - 240_000, - ); + } finally { + await secondary.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 240_000); }); diff --git a/packages/server/src/server/daemon-e2e/ui-action-stress.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/ui-action-stress.real.e2e.test.ts index f07aa0900..4b3e99098 100644 --- a/packages/server/src/server/daemon-e2e/ui-action-stress.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/ui-action-stress.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -456,92 +456,94 @@ function createRealAgentClient(provider: AgentProvider, logger: pino.Logger): Ag } describe.each(allProviders)("daemon E2E (real %s) - UI action stress", (provider) => { - const shouldRun = isProviderAvailable(provider); + let shouldRun = false; - test.runIf(shouldRun)( - "normal UI submit path (idle sends) stays correct", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { - [provider]: createRealAgentClient(provider, logger), - } as Partial>, - logger, + beforeAll(async () => { + shouldRun = await isProviderAvailable(provider); + }); + + beforeEach((context) => { + if (!shouldRun) { + context.skip(); + } + }); + + test("normal UI submit path (idle sends) stays correct", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { + [provider]: createRealAgentClient(provider, logger), + } as Partial>, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: `ui-stress-normal-${provider}` }, + }); + const agent = await client.createAgent({ + cwd, + title: `uist-n-${provider}`, + ...getFullAccessConfig(provider), }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: `ui-stress-normal-${provider}` }, - }); + await runUiScenario({ + client, + agentId: agent.id, + scenario: buildNormalUsageScenario(provider, 7), + }); + + await client.deleteAgent(agent.id); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 420_000); + + test("queued-send-now path is stable under overlap", async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { + [provider]: createRealAgentClient(provider, logger), + } as Partial>, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const scenarios = [ + buildOverlapScenario(provider, 17, "last"), + buildOverlapScenario(provider, 31, "first"), + ]; + + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: `ui-stress-overlap-${provider}` }, + }); + + for (const scenario of scenarios) { const agent = await client.createAgent({ cwd, - title: `uist-n-${provider}`, + title: `uist-o-${provider}`, ...getFullAccessConfig(provider), }); await runUiScenario({ client, agentId: agent.id, - scenario: buildNormalUsageScenario(provider, 7), + scenario, }); await client.deleteAgent(agent.id); - } finally { - await client.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); } - }, - 420_000, - ); - - test.runIf(shouldRun)( - "queued-send-now path is stable under overlap", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { - [provider]: createRealAgentClient(provider, logger), - } as Partial>, - logger, - }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - const scenarios = [ - buildOverlapScenario(provider, 17, "last"), - buildOverlapScenario(provider, 31, "first"), - ]; - - try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: `ui-stress-overlap-${provider}` }, - }); - - for (const scenario of scenarios) { - const agent = await client.createAgent({ - cwd, - title: `uist-o-${provider}`, - ...getFullAccessConfig(provider), - }); - - await runUiScenario({ - client, - agentId: agent.id, - scenario, - }); - - await client.deleteAgent(agent.id); - } - } finally { - await client.close(); - await daemon.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 600_000, - ); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 600_000); }); diff --git a/packages/server/src/server/exports.ts b/packages/server/src/server/exports.ts index 828c60f45..002478abc 100644 --- a/packages/server/src/server/exports.ts +++ b/packages/server/src/server/exports.ts @@ -33,11 +33,11 @@ export { export { applyProviderEnv } from "./agent/provider-launch-config.js"; export { findExecutable, - findExecutableSync, + executableExists, quoteWindowsArgument, quoteWindowsCommand, } from "../utils/executable.js"; -export { spawnProcess } from "../utils/spawn.js"; +export { execCommand, spawnProcess } from "../utils/spawn.js"; // Provider manifest (source of truth for provider definitions) export { diff --git a/packages/server/src/server/loop-service.ts b/packages/server/src/server/loop-service.ts index c3a2d4ab9..b049c04f1 100644 --- a/packages/server/src/server/loop-service.ts +++ b/packages/server/src/server/loop-service.ts @@ -1,8 +1,6 @@ import { randomUUID } from "node:crypto"; -import { execFile } from "node:child_process"; import { promises as fs } from "node:fs"; import path from "node:path"; -import { promisify } from "node:util"; import { z } from "zod"; import type { Logger } from "pino"; import { curateAgentActivity } from "./agent/activity-curator.js"; @@ -15,8 +13,8 @@ import type { AgentTimelineItem, AgentProvider, } from "./agent/agent-sdk-types.js"; +import { execCommand, platformShell } from "../utils/spawn.js"; -const execFileAsync = promisify(execFile); const LOOP_ID_LENGTH = 8; const DEFAULT_LOOP_PROVIDER: AgentProvider = "claude"; const MAX_VERIFY_OUTPUT_BYTES = 64 * 1024; @@ -256,7 +254,8 @@ async function runVerifyCheck(options: { }): Promise { const startedAt = nowIso(); try { - const result = await execFileAsync("/bin/sh", ["-lc", options.command], { + const shell = platformShell(); + const result = await execCommand(shell.command, [...shell.flag, options.command], { cwd: options.cwd, maxBuffer: MAX_VERIFY_OUTPUT_BYTES, }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index bcbe0e340..95e2505bb 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,7 +1,7 @@ import equal from "fast-deep-equal"; import { v4 as uuidv4 } from "uuid"; import { stat } from "fs/promises"; -import { exec, execFile } from "node:child_process"; +import { exec } from "node:child_process"; import { promisify } from "util"; import { resolve, sep } from "path"; import { homedir } from "node:os"; @@ -170,6 +170,7 @@ import { ChatServiceError, FileBackedChatService } from "./chat/chat-service.js" import { notifyChatMentions } from "./chat/chat-mentions.js"; import { LoopService } from "./loop-service.js"; import { ScheduleService } from "./schedule/service.js"; +import { execCommand } from "../utils/spawn.js"; import { assertSafeGitRef as assertWorktreeSafeGitRef, buildAgentSessionConfig as buildWorktreeAgentSessionConfig, @@ -182,7 +183,6 @@ import { } from "./worktree-session.js"; const execAsync = promisify(exec); -const execFileAsync = promisify(execFile); const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS); const pendingAgentInitializations = new Map>(); const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0]; @@ -3531,7 +3531,7 @@ export class Session { private async checkoutExistingBranch(cwd: string, branch: string): Promise { this.assertSafeGitRef(branch, "branch"); try { - await execFileAsync("git", ["rev-parse", "--verify", branch], { cwd }); + await execCommand("git", ["rev-parse", "--verify", branch], { cwd }); } catch (error) { throw new Error(`Branch not found: ${branch}`); } @@ -3545,7 +3545,7 @@ export class Session { } await this.ensureCleanWorkingTree(cwd); - await execFileAsync("git", ["checkout", branch], { cwd }); + await execCommand("git", ["checkout", branch], { cwd }); } private async createBranchFromBase(params: { @@ -3558,7 +3558,7 @@ export class Session { this.assertSafeGitRef(newBranchName, "new branch"); try { - await execFileAsync("git", ["rev-parse", "--verify", baseBranch], { cwd }); + await execCommand("git", ["rev-parse", "--verify", baseBranch], { cwd }); } catch (error) { throw new Error(`Base branch not found: ${baseBranch}`); } @@ -3569,7 +3569,7 @@ export class Session { } await this.ensureCleanWorkingTree(cwd); - await execFileAsync("git", ["checkout", "-b", newBranchName, baseBranch], { + await execCommand("git", ["checkout", "-b", newBranchName, baseBranch], { cwd, }); } @@ -3577,7 +3577,7 @@ export class Session { private async doesLocalBranchExist(cwd: string, branch: string): Promise { this.assertSafeGitRef(branch, "branch"); try { - await execFileAsync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { + await execCommand("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { cwd, }); return true; @@ -4030,7 +4030,7 @@ export class Session { // Try local branch first try { - await execFileAsync("git", ["rev-parse", "--verify", branchName], { + await execCommand("git", ["rev-parse", "--verify", branchName], { cwd: resolvedCwd, env: READ_ONLY_GIT_ENV, }); @@ -4051,7 +4051,7 @@ export class Session { // Try remote branch (origin/{branchName}) try { - await execFileAsync("git", ["rev-parse", "--verify", `origin/${branchName}`], { + await execCommand("git", ["rev-parse", "--verify", `origin/${branchName}`], { cwd: resolvedCwd, env: READ_ONLY_GIT_ENV, }); @@ -4281,7 +4281,7 @@ export class Session { const message = branchLabel ? `${Session.PASEO_STASH_PREFIX} ${branchLabel}` : `${Session.PASEO_STASH_PREFIX} unnamed`; - await execFileAsync("git", ["stash", "push", "--include-untracked", "-m", message], { cwd }); + await execCommand("git", ["stash", "push", "--include-untracked", "-m", message], { cwd }); this.checkoutDiffManager.scheduleRefreshForCwd(cwd); this.emit({ type: "stash_save_response", @@ -4300,7 +4300,7 @@ export class Session { ): Promise { const { cwd, stashIndex, requestId } = msg; try { - await execFileAsync("git", ["stash", "pop", `stash@{${stashIndex}}`], { cwd }); + await execCommand("git", ["stash", "pop", `stash@{${stashIndex}}`], { cwd }); this.checkoutDiffManager.scheduleRefreshForCwd(cwd); this.emit({ type: "stash_pop_response", diff --git a/packages/server/src/tasks/cli.ts b/packages/server/src/tasks/cli.ts index 52a43383f..cb10035c8 100644 --- a/packages/server/src/tasks/cli.ts +++ b/packages/server/src/tasks/cli.ts @@ -1,11 +1,11 @@ #!/usr/bin/env node import { Command } from "commander"; -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 { resolvePackageVersion } from "../server/package-version.js"; +import { spawnProcess } from "../utils/spawn.js"; import type { AgentType, ModelName, Task } from "./types.js"; const TASKS_DIR = resolve(process.cwd(), ".tasks"); @@ -638,11 +638,11 @@ function getAgentConfig(modelStr: string): AgentConfig { return { cli: "claude", model }; } -function runAgentWithModel( +async function runAgentWithModel( prompt: string, modelStr: string, logFile: string, -): { success: boolean; output: string } { +): Promise<{ success: boolean; output: string }> { const config = getAgentConfig(modelStr); let args: string[]; @@ -667,20 +667,34 @@ function runAgentWithModel( args.push(prompt); } - const result = spawnSync(config.cli, args, { - stdio: ["inherit", "pipe", "pipe"], - cwd: process.cwd(), - maxBuffer: 50 * 1024 * 1024, + const { stdout, stderr, exitCode } = await new Promise<{ + stdout: string; + stderr: string; + exitCode: number | null; + }>((resolve, reject) => { + const child = spawnProcess(config.cli, args, { + stdio: ["inherit", "pipe", "pipe"], + cwd: process.cwd(), + }); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + child.stdout?.on("data", (chunk: Buffer) => stdoutChunks.push(chunk)); + child.stderr?.on("data", (chunk: Buffer) => stderrChunks.push(chunk)); + child.on("error", reject); + child.on("close", (code) => { + resolve({ + stdout: Buffer.concat(stdoutChunks).toString("utf8"), + stderr: Buffer.concat(stderrChunks).toString("utf8"), + exitCode: code, + }); + }); }); - - const stdout = result.stdout?.toString() ?? ""; - const stderr = result.stderr?.toString() ?? ""; const output = stdout + stderr; // Append to log file for history appendFileSync(logFile, output); - return { success: result.status === 0, output }; + return { success: exitCode === 0, output }; } async function buildTaskContext(task: Task, scopeId?: string): Promise { @@ -1102,7 +1116,7 @@ program log(logFile, `[PLANNER] Running ${plannerModel} (${reason})...`); const context = await buildTaskContext(task, scopeId); const plannerPrompt = makePlannerPrompt(task, context, reason); - runAgentWithModel(plannerPrompt, plannerModel, logFile); + await runAgentWithModel(plannerPrompt, plannerModel, logFile); // Check if planner created subtasks for this task const children = await store.getChildren(task.id); @@ -1221,7 +1235,7 @@ program const freshContext = await buildTaskContext(freshTask, scopeId); const workerPrompt = makeWorkerPrompt(freshTask, freshContext, iteration); - runAgentWithModel(workerPrompt, workerModel, logFile); + await runAgentWithModel(workerPrompt, workerModel, logFile); // Step 3: Judge log(logFile, `[JUDGE] Verifying with ${judgeModel}...`); @@ -1229,7 +1243,7 @@ program if (!judgeTask) break; const judgePrompt = makeJudgePrompt(judgeTask); - const judgeResult = runAgentWithModel(judgePrompt, judgeModel, logFile); + const judgeResult = await runAgentWithModel(judgePrompt, judgeModel, logFile); const verdict = parseJudgeVerdict(judgeResult.output); log(logFile, `[JUDGE] Verdict: ${verdict || "UNKNOWN"}`); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 10d4a972a..45cd97f35 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1,5 +1,3 @@ -import { execFile } from "child_process"; -import { promisify } from "util"; import { resolve, dirname, basename } from "path"; import { existsSync, realpathSync } from "fs"; import { open as openFile, stat as statFile } from "fs/promises"; @@ -8,10 +6,9 @@ import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js"; import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js"; import { findExecutable } from "./executable.js"; import { runGitCommand } from "./run-git-command.js"; +import { execCommand } from "./spawn.js"; import { isPaseoOwnedWorktreeCwd } from "./worktree.js"; import { requirePaseoWorktreeBaseRefName } from "./worktree-metadata.js"; - -const execFileAsync = promisify(execFile); const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = { ...process.env, GIT_OPTIONAL_LOCKS: "0", @@ -1899,7 +1896,7 @@ export async function createPullRequest( if (options.body) { args.push("-f", `body=${options.body}`); } - const { stdout } = await execFileAsync(ghPath, args, { cwd, env: ghEnv }); + const { stdout } = await execCommand(ghPath, args, { cwd, env: ghEnv }); const parsed = JSON.parse(stdout.trim()); if (!parsed?.url || !parsed?.number) { throw new Error("GitHub CLI did not return PR url/number"); @@ -1951,7 +1948,7 @@ async function getPullRequestStatusUncached(cwd: string): Promise void; -type FindExecutableDependencies = NonNullable[1]>; +async function loadExecutableModule(params?: { + execFileImpl?: ( + command: string, + args: string[], + options: unknown, + callback: ExecFileCallback, + ) => void; +}) { + vi.resetModules(); -function createFindExecutableDependencies(): FindExecutableDependencies { + const execFileMock = vi.fn( + params?.execFileImpl ?? + ((_command: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callback(new Error("execFile not mocked"), "", ""); + }), + ); + Object.assign(execFileMock, { + [promisify.custom]: (command: string, args: string[], options: unknown) => + new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { + execFileMock( + command, + args, + options, + (error: Error | null, stdout: string, stderr: string) => { + if (error) { + reject(error); + return; + } + resolve({ stdout, stderr }); + }, + ); + }), + }); + + vi.doMock("node:child_process", () => ({ + execFile: execFileMock, + })); + const module = await import("./executable.js"); return { - execFileSync: vi.fn(), - existsSync: vi.fn(), - platform: vi.fn(() => "darwin"), + ...module, + execFileMock, }; } -let findExecutableDependencies: FindExecutableDependencies; +describe("findExecutable", () => { + const originalPlatform = process.platform; -beforeEach(() => { - findExecutableDependencies = createFindExecutableDependencies(); -}); + function setPlatform(value: string) { + Object.defineProperty(process, "platform", { value, writable: true }); + } -describe("findExecutableSync", () => { - test("on Windows, resolves executables using where.exe with inherited PATH", () => { - findExecutableDependencies.platform = vi.fn(() => "win32"); - findExecutableDependencies.execFileSync.mockReturnValue( - "C:\\Users\\boudr\\.local\\bin\\claude.exe\r\n", - ); + afterEach(() => { + setPlatform(originalPlatform); + }); - expect(findExecutableSync("claude", findExecutableDependencies)).toBe( + test("on Windows, resolves executables using where.exe with inherited PATH", async () => { + setPlatform("win32"); + const { execFileMock, findExecutable } = await loadExecutableModule({ + execFileImpl: (_command, _args, _options, callback) => { + callback(null, "C:\\Users\\boudr\\.local\\bin\\claude.exe\r\n", ""); + }, + }); + + await expect(findExecutable("claude")).resolves.toBe( "C:\\Users\\boudr\\.local\\bin\\claude.exe", ); - expect(findExecutableDependencies.execFileSync).toHaveBeenCalledOnce(); - const call = findExecutableDependencies.execFileSync.mock.calls[0]; + expect(execFileMock).toHaveBeenCalledOnce(); + const call = execFileMock.mock.calls[0]; expect(call?.[0]).toBe("where.exe"); expect(call?.[1]).toEqual(["claude"]); - expect(call?.[2]?.encoding).toBe("utf8"); - expect(call?.[2]?.windowsHide).toBe(true); - }); - - test("on Windows, preserves the first where.exe match", () => { - findExecutableDependencies.platform = vi.fn(() => "win32"); - findExecutableDependencies.execFileSync.mockReturnValue( - "C:\\nvm4w\\nodejs\\codex\r\nC:\\nvm4w\\nodejs\\codex.cmd\r\n", - ); - - expect(findExecutableSync("codex", findExecutableDependencies)).toBe( - "C:\\nvm4w\\nodejs\\codex", - ); - }); - - test("on Unix, uses the last line from which output", () => { - findExecutableDependencies.execFileSync.mockReturnValue("/usr/local/bin/codex\n"); - - expect(findExecutableSync("codex", findExecutableDependencies)).toBe("/usr/local/bin/codex"); - expect(findExecutableDependencies.execFileSync).toHaveBeenCalledWith("which", ["codex"], { + expect(call?.[2]).toMatchObject({ encoding: "utf8", + windowsHide: true, }); }); - test("warns and returns null when the final which line is not an absolute path", () => { - findExecutableDependencies.execFileSync.mockReturnValue("codex\n"); - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + test("on Windows, prefers an executable match from where.exe output", async () => { + setPlatform("win32"); + const { findExecutable } = await loadExecutableModule({ + execFileImpl: (_command, _args, _options, callback) => { + callback(null, "C:\\nvm4w\\nodejs\\codex\r\nC:\\nvm4w\\nodejs\\codex.cmd\r\n", ""); + }, + }); - expect(findExecutableSync("codex", findExecutableDependencies)).toBeNull(); + await expect(findExecutable("codex")).resolves.toBe("C:\\nvm4w\\nodejs\\codex.cmd"); + }); + + test("on Windows, prefers .exe over .cmd, .ps1, and extensionless candidates", async () => { + setPlatform("win32"); + const { findExecutable } = await loadExecutableModule({ + execFileImpl: (_command, _args, _options, callback) => { + callback( + null, + [ + "C:\\nvm4w\\nodejs\\codex", + "C:\\nvm4w\\nodejs\\codex.ps1", + "C:\\nvm4w\\nodejs\\codex.cmd", + "C:\\nvm4w\\nodejs\\codex.exe", + ].join("\r\n"), + "", + ); + }, + }); + + await expect(findExecutable("codex")).resolves.toBe("C:\\nvm4w\\nodejs\\codex.exe"); + }); + + test("on Windows, returns null when where.exe output is empty", async () => { + setPlatform("win32"); + const { findExecutable } = await loadExecutableModule({ + execFileImpl: (_command, _args, _options, callback) => { + callback(null, "\r\n", ""); + }, + }); + + await expect(findExecutable("codex")).resolves.toBeNull(); + }); + + test("on Windows, falls back to the first extensionless candidate when needed", async () => { + setPlatform("win32"); + const { findExecutable } = await loadExecutableModule({ + execFileImpl: (_command, _args, _options, callback) => { + callback(null, "C:\\nvm4w\\nodejs\\codex\r\n", ""); + }, + }); + + await expect(findExecutable("codex")).resolves.toBe("C:\\nvm4w\\nodejs\\codex"); + }); + + test("on Unix, uses the last line from which output", async () => { + const { execFileMock, findExecutable } = await loadExecutableModule({ + execFileImpl: (_command, _args, _options, callback) => { + callback(null, "/usr/local/bin/codex\n", ""); + }, + }); + + await expect(findExecutable("codex")).resolves.toBe("/usr/local/bin/codex"); + expect(execFileMock).toHaveBeenCalledWith( + "which", + ["codex"], + { encoding: "utf8" }, + expect.any(Function), + ); + }); + + test("warns and returns null when the final which line is not an absolute path", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { findExecutable } = await loadExecutableModule({ + execFileImpl: (_command, _args, _options, callback) => { + callback(null, "codex\n", ""); + }, + }); + + await expect(findExecutable("codex")).resolves.toBeNull(); expect(warnSpy).toHaveBeenCalledOnce(); warnSpy.mockRestore(); }); - test("returns direct paths when they exist", () => { - findExecutableDependencies.existsSync.mockReturnValue(true); + test("returns null when which lookup fails", async () => { + const { findExecutable } = await loadExecutableModule({ + execFileImpl: (_command, _args, _options, callback) => { + callback(new Error("which failed"), "", ""); + }, + }); - expect(findExecutableSync("/usr/local/bin/codex", findExecutableDependencies)).toBe( - "/usr/local/bin/codex", - ); - expect(findExecutableDependencies.existsSync).toHaveBeenCalledWith("/usr/local/bin/codex"); + await expect(findExecutable("codex")).resolves.toBeNull(); + }); +}); + +describe("executableExists", () => { + const originalPlatform = process.platform; + + function setPlatform(value: string) { + Object.defineProperty(process, "platform", { value, writable: true }); + } + + afterEach(() => { + setPlatform(originalPlatform); + }); + + test("returns the path when it already exists", async () => { + const { executableExists } = await loadExecutableModule(); + const exists = vi.fn((candidate: string) => candidate === "/usr/local/bin/codex"); + + expect(executableExists("/usr/local/bin/codex", exists)).toBe("/usr/local/bin/codex"); + }); + + test("on Windows, falls back to .exe, .cmd, then .ps1 for extensionless paths", async () => { + setPlatform("win32"); + const { executableExists } = await loadExecutableModule(); + const exists = vi.fn((candidate: string) => candidate === "C:\\tools\\codex.cmd"); + + expect(executableExists("C:\\tools\\codex", exists)).toBe("C:\\tools\\codex.cmd"); + }); + + test("returns null when no matching path exists", async () => { + const { executableExists } = await loadExecutableModule(); + const exists = vi.fn(() => false); + + expect(executableExists("/missing/codex", exists)).toBeNull(); }); }); @@ -87,27 +218,31 @@ describe("quoteWindowsCommand", () => { setPlatform(originalPlatform); }); - test("quotes a Windows path with spaces", () => { + test("quotes a Windows path with spaces", async () => { setPlatform("win32"); + const { quoteWindowsCommand } = await loadExecutableModule(); expect(quoteWindowsCommand("C:\\Program Files\\Anthropic\\claude.exe")).toBe( '"C:\\Program Files\\Anthropic\\claude.exe"', ); }); - test("does not double-quote an already-quoted path", () => { + test("does not double-quote an already-quoted path", async () => { setPlatform("win32"); + const { quoteWindowsCommand } = await loadExecutableModule(); expect(quoteWindowsCommand('"C:\\Program Files\\Anthropic\\claude.exe"')).toBe( '"C:\\Program Files\\Anthropic\\claude.exe"', ); }); - test("returns the command unchanged when there are no spaces", () => { + test("returns the command unchanged when there are no spaces", async () => { setPlatform("win32"); + const { quoteWindowsCommand } = await loadExecutableModule(); expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex"); }); - test("returns the command unchanged on non-Windows platforms", () => { + test("returns the command unchanged on non-Windows platforms", async () => { setPlatform("darwin"); + const { quoteWindowsCommand } = await loadExecutableModule(); expect(quoteWindowsCommand("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code"); }); }); @@ -123,27 +258,31 @@ describe("quoteWindowsArgument", () => { setPlatform(originalPlatform); }); - test("quotes a Windows argument with spaces", () => { + test("quotes a Windows argument with spaces", async () => { setPlatform("win32"); + const { quoteWindowsArgument } = await loadExecutableModule(); expect(quoteWindowsArgument("C:\\Program Files\\Anthropic\\cli.js")).toBe( '"C:\\Program Files\\Anthropic\\cli.js"', ); }); - test("does not double-quote an already-quoted argument", () => { + test("does not double-quote an already-quoted argument", async () => { setPlatform("win32"); + const { quoteWindowsArgument } = await loadExecutableModule(); expect(quoteWindowsArgument('"C:\\Program Files\\Anthropic\\cli.js"')).toBe( '"C:\\Program Files\\Anthropic\\cli.js"', ); }); - test("returns the argument unchanged when there are no spaces", () => { + test("returns the argument unchanged when there are no spaces", async () => { setPlatform("win32"); + const { quoteWindowsArgument } = await loadExecutableModule(); expect(quoteWindowsArgument("--version")).toBe("--version"); }); - test("returns the argument unchanged on non-Windows platforms", () => { + test("returns the argument unchanged on non-Windows platforms", async () => { setPlatform("darwin"); + const { quoteWindowsArgument } = await loadExecutableModule(); expect(quoteWindowsArgument("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code"); }); }); diff --git a/packages/server/src/utils/executable.ts b/packages/server/src/utils/executable.ts index 25a91acab..0c71db88e 100644 --- a/packages/server/src/utils/executable.ts +++ b/packages/server/src/utils/executable.ts @@ -1,15 +1,21 @@ -import { execFile, execFileSync } from "node:child_process"; +import { execFile } from "node:child_process"; import { existsSync } from "node:fs"; -import { platform } from "node:os"; -import path from "node:path"; +import path, { extname } from "node:path"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); -export interface FindExecutableDependencies { - execFileSync: typeof execFileSync; - existsSync: typeof existsSync; - platform: typeof platform; +function pickBestWindowsCandidate(lines: string[]): string | null { + const candidates = lines.filter((line) => line.length > 0); + if (candidates.length === 0) return null; + + const extPriority = [".exe", ".cmd", ".ps1"]; + for (const ext of extPriority) { + const match = candidates.find((candidate) => candidate.toLowerCase().endsWith(ext)); + if (match) return match; + } + + return candidates[0] ?? null; } function resolveExecutableFromWhichOutput( @@ -44,58 +50,18 @@ function resolveExecutableFromWhichOutput( * enriches it at startup via inheritLoginShellEnv(); on Windows, Electron * inherits the full user environment from Explorer. */ -export function findExecutableSync( - name: string, - dependencies?: FindExecutableDependencies, +export function executableExists( + executablePath: string, + exists: typeof existsSync = existsSync, ): string | null { - const trimmed = name.trim(); - if (!trimmed) { - return null; - } - - const deps: FindExecutableDependencies = { - execFileSync, - existsSync, - platform, - ...dependencies, - }; - - if (trimmed.includes("/") || trimmed.includes("\\")) { - return deps.existsSync(trimmed) ? trimmed : null; - } - - if (deps.platform() === "win32") { - try { - const out = deps - .execFileSync("where.exe", [trimmed], { - encoding: "utf8", - windowsHide: true, - }) - .trim(); - return ( - out - .split(/\r?\n/) - .map((line) => line.trim()) - .find((line) => line.length > 0) ?? null - ); - } catch { - return null; + if (exists(executablePath)) return executablePath; + if (process.platform === "win32" && !extname(executablePath)) { + for (const ext of [".exe", ".cmd", ".ps1"]) { + const candidate = executablePath + ext; + if (exists(candidate)) return candidate; } } - - try { - return resolveExecutableFromWhichOutput( - trimmed, - deps.execFileSync("which", [trimmed], { encoding: "utf8" }).trim(), - "which", - ); - } catch { - return null; - } -} - -export function isCommandAvailableSync(command: string): boolean { - return findExecutableSync(command) !== null; + return null; } export async function findExecutable(name: string): Promise { @@ -105,21 +71,22 @@ export async function findExecutable(name: string): Promise { } if (trimmed.includes("/") || trimmed.includes("\\")) { - return existsSync(trimmed) ? trimmed : null; + return executableExists(trimmed); } - if (platform() === "win32") { + if (process.platform === "win32") { try { const { stdout } = await execFileAsync("where.exe", [trimmed], { encoding: "utf8", windowsHide: true, }); return ( - stdout - .trim() - .split(/\r?\n/) - .map((line) => line.trim()) - .find((line) => line.length > 0) ?? null + pickBestWindowsCandidate( + stdout + .trim() + .split(/\r?\n/) + .map((line) => line.trim()), + ) ?? null ); } catch { return null; diff --git a/packages/server/src/utils/spawn.test.ts b/packages/server/src/utils/spawn.test.ts new file mode 100644 index 000000000..648d01d22 --- /dev/null +++ b/packages/server/src/utils/spawn.test.ts @@ -0,0 +1,54 @@ +import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; + +import { execCommand } from "./spawn.js"; + +describe("execCommand", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const tempDir of tempDirs) { + rmSync(tempDir, { recursive: true, force: true }); + } + tempDirs.length = 0; + }); + + test("returns stdout and stderr for a successful command", async () => { + await expect(execCommand("echo", ["hello"])).resolves.toEqual({ + stdout: "hello\n", + stderr: "", + }); + }); + + test("rejects when the command times out", async () => { + const command = + process.platform === "win32" + ? { + command: process.execPath, + args: ["-e", "setTimeout(() => {}, 10_000)"], + } + : { command: "sleep", args: ["10"] }; + + await expect(execCommand(command.command, command.args, { timeout: 100 })).rejects.toThrow(); + }); + + test("runs the command in the provided cwd", async () => { + const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "spawn-test-"))); + tempDirs.push(cwd); + + const command = + process.platform === "win32" + ? { + command: process.execPath, + args: ["-e", "console.log(process.cwd())"], + } + : { command: "pwd", args: [] }; + + await expect(execCommand(command.command, command.args, { cwd })).resolves.toEqual({ + stdout: `${cwd}\n`, + stderr: "", + }); + }); +}); diff --git a/packages/server/src/utils/spawn.ts b/packages/server/src/utils/spawn.ts index 32c1d911d..1168a59e6 100644 --- a/packages/server/src/utils/spawn.ts +++ b/packages/server/src/utils/spawn.ts @@ -1,15 +1,27 @@ -import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process"; +import { execFile, spawn, type ChildProcess, type SpawnOptions } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +interface ExecCommandOptions { + cwd?: string; + env?: NodeJS.ProcessEnv; + encoding?: BufferEncoding; + timeout?: number; + maxBuffer?: number; +} + +interface ExecCommandResult { + stdout: string; + stderr: string; +} + +function quoteForCmd(value: string): string { + if (!value.includes(" ")) return value; + if (value.startsWith('"') && value.endsWith('"')) return value; + return `"${value}"`; +} -/** - * Platform-aware spawn that centralizes Windows shell and quoting concerns. - * - * On Windows: - * - Enables `shell: true` (routes through cmd.exe) unless the caller explicitly sets `shell` - * - Quotes the command and arguments so paths with spaces survive cmd.exe parsing - * - Always sets `windowsHide: true` to prevent console window flashes - * - * On other platforms the call is passed through to `spawn` unchanged (with `windowsHide: true`). - */ export function spawnProcess( command: string, args: string[], @@ -27,12 +39,38 @@ export function spawnProcess( }); } -/** - * Quote a string for cmd.exe if it contains spaces and isn't already quoted. - * No-op for strings without spaces or strings that are already double-quoted. - */ -function quoteForCmd(value: string): string { - if (!value.includes(" ")) return value; - if (value.startsWith('"') && value.endsWith('"')) return value; - return `"${value}"`; +export async function execCommand( + command: string, + args: string[], + options?: ExecCommandOptions, +): Promise { + const isWindows = process.platform === "win32"; + const resolvedCommand = isWindows ? quoteForCmd(command) : command; + const resolvedArgs = isWindows ? args.map(quoteForCmd) : args; + + return execFileAsync(resolvedCommand, resolvedArgs, { + cwd: options?.cwd, + env: options?.env, + encoding: options?.encoding ?? "utf8", + timeout: options?.timeout, + maxBuffer: options?.maxBuffer, + shell: isWindows, + windowsHide: true, + }) as Promise; +} + +export function platformShell(): { command: string; flag: string[] } { + if (process.platform === "win32") { + return { command: "cmd.exe", flag: ["/c"] }; + } + + return { command: "/bin/sh", flag: ["-lc"] }; +} + +export function platformBash(): { command: string; flag: string[] } { + if (process.platform === "win32") { + return { command: "cmd.exe", flag: ["/c"] }; + } + + return { command: "/bin/bash", flag: ["-lc"] }; } diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 38d74268c..e114dce9d 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -1,4 +1,4 @@ -import { exec, spawn } from "child_process"; +import { exec } from "child_process"; import { promisify } from "util"; import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "fs"; import { join, basename, dirname, resolve, sep } from "path"; @@ -13,6 +13,7 @@ import { writePaseoWorktreeRuntimeMetadata, } from "./worktree-metadata.js"; import { runGitCommand } from "./run-git-command.js"; +import { platformBash, spawnProcess } from "./spawn.js"; import { resolvePaseoHome } from "../server/paseo-home.js"; interface PaseoConfig { @@ -204,7 +205,7 @@ async function execSetupCommand( const { stdout, stderr } = await execAsync(command, { cwd: options.cwd, env: options.env, - shell: "/bin/bash", + ...(process.platform === "win32" ? {} : { shell: "/bin/bash" }), }); return { command, @@ -275,7 +276,8 @@ async function execSetupCommandStreamed(options: { cwd: options.cwd, }); - const child = spawn("/bin/bash", ["-lc", options.command], { + const shell = platformBash(); + const child = spawnProcess(shell.command, [...shell.flag, options.command], { cwd: options.cwd, env: options.env, stdio: ["ignore", "pipe", "pipe"],