From c4782ec71ccb51b3b4b4a8b06cf08c1f98e2a4a7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 25 Mar 2026 16:00:24 +0700 Subject: [PATCH] Fix nested Claude Code session detection and centralize provider availability checks Strip parent Claude Code session env vars (CLAUDECODE, CLAUDE_CODE_ENTRYPOINT, etc.) from child agent environments so spawned agents don't fail with "cannot be launched inside another Claude Code session". Centralize isProviderAvailable to check both binary and credential availability. Add red e2e test for send-during-tool-call bug and force-cancel stale foreground turns in cancelAgentRun. --- .../src/server/agent/agent-manager.test.ts | 86 ++++++++++++ .../server/src/server/agent/agent-manager.ts | 22 +++ .../agent/provider-launch-config.test.ts | 20 +++ .../server/agent/provider-launch-config.ts | 17 ++- .../src/server/daemon-e2e/agent-configs.ts | 35 +++-- ...de-autonomous-wake-simple.real.e2e.test.ts | 5 +- .../claude-autonomous-wake.real.e2e.test.ts | 21 ++- .../model-resolution-on-init.real.e2e.test.ts | 4 +- ...-runtime-reconcile-claude.real.e2e.test.ts | 4 +- ...ser-message-dedupe-claude.real.e2e.test.ts | 8 +- ...d-during-tool-call-claude.real.e2e.test.ts | 130 ++++++++++++++++++ ...hile-running-stuck-claude.real.e2e.test.ts | 5 +- .../send-while-running-stuck.real.e2e.test.ts | 5 +- .../ui-action-stress.real.e2e.test.ts | 4 +- 14 files changed, 321 insertions(+), 45 deletions(-) create mode 100644 packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 149a14526..e22cabb44 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -3294,4 +3294,90 @@ describe("AgentManager", () => { "continuation prompt", ); }); + + test("replaceAgentRun succeeds when foreground turn terminal event is never delivered", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stale-fg-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const allowSecondRunToEnd = deferred(); + + // Session where the first foreground turn never emits a terminal event + // (simulates the claude-agent pendingInterruptAbort suppression bug), + // and interrupt() does not produce events either. + class StaleForegroundSession extends TestAgentSession { + override async startTurn(): Promise<{ turnId: string }> { + this.interrupted = false; + const turnId = `turn-${++this.turnIdCounter}`; + const turnNum = this.turnIdCounter; + + setTimeout(async () => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + if (turnNum === 1) { + // First turn: emit turn_started but NEVER emit a terminal event. + // This simulates the provider suppressing the result. + } else { + // Subsequent turns: complete normally + await allowSecondRunToEnd.promise; + this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); + } + }, 0); + return { turnId }; + } + + override async interrupt(): Promise { + this.interrupted = true; + // No events produced — the terminal event was suppressed + } + } + + class StaleForegroundClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + return new StaleForegroundSession(config); + } + } + + const manager = new AgentManager({ + clients: { codex: new StaleForegroundClient() }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000500", + }); + + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }); + + // Start first foreground run — it will hang (no terminal event) + const firstRun = manager.streamAgent(snapshot.id, "hanging prompt"); + const firstRunDrain = (async () => { + for await (const _event of firstRun) { + // Draining — will hang until force-cleaned + } + })(); + + await manager.waitForAgentRunStart(snapshot.id); + + const beforeReplace = manager.getAgent(snapshot.id); + expect(beforeReplace?.lifecycle).toBe("running"); + expect(beforeReplace?.activeForegroundTurnId).toBe("turn-1"); + + // Replace the hung run. cancelAgentRun will time out after 2s because + // no terminal event arrives. After the fix, it should force-clear the + // stale foreground state so streamAgent can proceed. + const secondRun = manager.replaceAgentRun(snapshot.id, "replacement prompt"); + const collectedEvents: AgentStreamEvent[] = []; + const secondRunDrain = (async () => { + for await (const event of secondRun) { + collectedEvents.push(event); + } + })(); + + await manager.waitForAgentRunStart(snapshot.id); + allowSecondRunToEnd.resolve(); + + await secondRunDrain; + await firstRunDrain; + + expect(collectedEvents.some((e) => e.type === "turn_completed")).toBe(true); + expect(manager.getAgent(snapshot.id)?.lifecycle).toBe("idle"); + expect(manager.getAgent(snapshot.id)?.activeForegroundTurnId).toBeNull(); + }, 10_000); }); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 4c72f5e75..5080cfb45 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1388,6 +1388,28 @@ export class AgentManager { await Promise.race([pendingRun.settledPromise, timeout]); } + // If the foreground turn is still stuck after the timeout, force-dispatch a + // synthetic turn_canceled so the normal event pipeline cleans up + // activeForegroundTurnId, settles waiters, and unblocks the streamForwarder. + if (foregroundTurnId && agent.activeForegroundTurnId === foregroundTurnId) { + this.logger.warn( + { agentId, foregroundTurnId }, + "cancelAgentRun: foreground turn still active after timeout, force-canceling", + ); + this.dispatchSessionEvent(agent, { + type: "turn_canceled", + provider: agent.provider, + reason: "interrupted", + turnId: foregroundTurnId, + }); + // The synthetic event unblocks the streamForwarder generator, whose finally + // block settles the pending foreground run asynchronously. Wait for it. + const staleRun = this.getPendingForegroundRun(agentId); + if (staleRun && !staleRun.settled) { + await staleRun.settledPromise; + } + } + // Clear any pending permissions that weren't cleaned up by handleStreamEvent. if (agent.pendingPermissions.size > 0) { for (const [requestId] of agent.pendingPermissions) { diff --git a/packages/server/src/server/agent/provider-launch-config.test.ts b/packages/server/src/server/agent/provider-launch-config.test.ts index 505556ce7..0fc5715af 100644 --- a/packages/server/src/server/agent/provider-launch-config.test.ts +++ b/packages/server/src/server/agent/provider-launch-config.test.ts @@ -111,6 +111,26 @@ describe("applyProviderEnv", () => { expect(env.PATH).toBe("/custom/path"); }); + + test("strips parent Claude Code session env vars", () => { + const base = { + PATH: "/usr/bin", + CLAUDECODE: "1", + CLAUDE_CODE_ENTRYPOINT: "sdk-ts", + CLAUDE_CODE_SSE_PORT: "11803", + CLAUDE_AGENT_SDK_VERSION: "0.2.71", + CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING: "true", + }; + + const env = applyProviderEnv(base, undefined, {}); + + expect(env.PATH).toBe("/usr/bin"); + expect(env.CLAUDECODE).toBeUndefined(); + expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined(); + expect(env.CLAUDE_CODE_SSE_PORT).toBeUndefined(); + expect(env.CLAUDE_AGENT_SDK_VERSION).toBeUndefined(); + expect(env.CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING).toBeUndefined(); + }); }); describe("findExecutable", () => { diff --git a/packages/server/src/server/agent/provider-launch-config.ts b/packages/server/src/server/agent/provider-launch-config.ts index a66df62ed..5da27bf1d 100644 --- a/packages/server/src/server/agent/provider-launch-config.ts +++ b/packages/server/src/server/agent/provider-launch-config.ts @@ -125,16 +125,31 @@ export function resolveShellEnv(): Record { return cachedShellEnv; } +// Env vars that indicate a running Claude Code session. If the daemon itself is +// launched from inside Claude Code (e.g. by a Paseo agent), these leak into +// child processes and cause "cannot be launched inside another session" errors. +const PARENT_SESSION_ENV_VARS = [ + "CLAUDECODE", + "CLAUDE_CODE_ENTRYPOINT", + "CLAUDE_CODE_SSE_PORT", + "CLAUDE_AGENT_SDK_VERSION", + "CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING", +]; + export function applyProviderEnv( baseEnv: Record, runtimeSettings?: ProviderRuntimeSettings, shellEnv?: Record, ): Record { - return { + const merged: Record = { ...baseEnv, ...(shellEnv ?? resolveShellEnv()), ...(runtimeSettings?.env ?? {}), }; + for (const key of PARENT_SESSION_ENV_VARS) { + delete merged[key]; + } + return merged; } /** diff --git a/packages/server/src/server/daemon-e2e/agent-configs.ts b/packages/server/src/server/daemon-e2e/agent-configs.ts index c164e2a58..d95e81875 100644 --- a/packages/server/src/server/daemon-e2e/agent-configs.ts +++ b/packages/server/src/server/daemon-e2e/agent-configs.ts @@ -2,6 +2,9 @@ * Shared agent configurations for e2e tests. * Enables running the same tests against Claude, Codex, and OpenCode providers. */ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; import { isCommandAvailable } from "../agent/provider-launch-config.js"; export interface AgentTestConfig { @@ -44,9 +47,6 @@ export const agentConfigs = { export type AgentProvider = keyof typeof agentConfigs; -const hasClaudeCredentials = - !!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY; - /** * Get test config for creating an agent with full permissions (no prompts). */ @@ -76,17 +76,28 @@ export function getAskModeConfig(provider: AgentProvider) { } /** - * Whether the real provider is executable in this environment. - * Claude additionally requires credentials. + * Whether a real provider can run in this environment. + * Checks binary availability AND credentials (env vars, OAuth tokens, auth files). + * + * Credentials are typically loaded from .env.test via the vitest setup file. + * 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 isRealProviderReady(provider: AgentProvider): boolean { - if (provider === "claude") { - return isCommandAvailable("claude") && hasClaudeCredentials; +export function isProviderAvailable(provider: AgentProvider): boolean { + switch (provider) { + case "claude": + return ( + isCommandAvailable("claude") && + (Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY)) + ); + case "codex": + return ( + isCommandAvailable("codex") && + (existsSync(join(homedir(), ".codex", "auth.json")) || Boolean(process.env.OPENAI_API_KEY)) + ); + case "opencode": + return isCommandAvailable("opencode"); } - if (provider === "codex") { - return isCommandAvailable("codex"); - } - return isCommandAvailable("opencode"); } /** 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 b6f69a403..6dd2fe622 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 @@ -5,10 +5,9 @@ import path from "node:path"; import pino from "pino"; import { ClaudeAgentClient } from "../agent/providers/claude-agent.js"; -import { isCommandAvailable } from "../agent/provider-launch-config.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; -import { getFullAccessConfig } from "./agent-configs.js"; +import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js"; function tmpCwd(): string { return mkdtempSync(path.join(tmpdir(), "daemon-real-claude-autonomous-simple-")); @@ -19,7 +18,7 @@ function compactText(value: string): string { } describe("daemon E2E (real claude) - autonomous wake simple", () => { - test.runIf(isCommandAvailable("claude"))( + test.runIf(isProviderAvailable("claude"))( "hello + background sleep returns idle, then wakes once on completion", async () => { const logger = pino({ level: "silent" }); 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 92dabe438..fccca9e86 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 @@ -8,8 +8,7 @@ import WebSocket from "ws"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { ClaudeAgentClient } from "../agent/providers/claude-agent.js"; -import { getFullAccessConfig } from "./agent-configs.js"; -import { isCommandAvailable } from "../agent/provider-launch-config.js"; +import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js"; function tmpCwd(): string { return mkdtempSync(path.join(tmpdir(), "daemon-real-claude-autonomous-wake-")); @@ -354,7 +353,7 @@ function summarizeTimelineEntry(entry: { } describe("daemon E2E (real claude) - autonomous wake from background task", () => { - test.runIf(isCommandAvailable("claude"))( + test.runIf(isProviderAvailable("claude"))( "A: background sleep returns idle, then wakes autonomously and appends timeline activity", async () => { const logger = pino({ level: "silent" }); @@ -432,7 +431,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = 420_000, ); - test.runIf(isCommandAvailable("claude"))( + test.runIf(isProviderAvailable("claude"))( "B: immediate HELLO before task notification returns promptly without deadlock", async () => { const logger = pino({ level: "silent" }); @@ -480,7 +479,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = 420_000, ); - test.runIf(isCommandAvailable("claude"))( + test.runIf(isProviderAvailable("claude"))( "C: interrupt during overlap returns quickly and does not leave agent stuck running", async () => { const logger = pino({ level: "silent" }); @@ -546,7 +545,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = 600_000, ); - test.runIf(isCommandAvailable("claude"))( + test.runIf(isProviderAvailable("claude"))( "returns to running after background sleep completes without a second prompt", async () => { const logger = pino({ level: "silent" }); @@ -608,7 +607,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = 420_000, ); - test.runIf(isCommandAvailable("claude"))( + test.runIf(isProviderAvailable("claude"))( "accepts a new prompt after background sleep finishes and replies HELLO", async () => { const logger = pino({ level: "silent" }); @@ -657,7 +656,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = 420_000, ); - test.runIf(isCommandAvailable("claude"))( + test.runIf(isProviderAvailable("claude"))( "repro: do-it-again + immediate hello can hang after autonomous wake under churn", async () => { const logger = pino({ level: "silent" }); @@ -764,7 +763,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = 900_000, ); - test.runIf(isCommandAvailable("claude"))( + test.runIf(isProviderAvailable("claude"))( "repro: second background sleep completion after HELLO should settle back to idle", async () => { const logger = pino({ level: "silent" }); @@ -840,7 +839,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = 600_000, ); - test.runIf(isCommandAvailable("claude"))( + test.runIf(isProviderAvailable("claude"))( "stress: immediate HELLO before task notification should not leave autonomous run stuck", async () => { const logger = pino({ level: "silent" }); @@ -930,7 +929,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = 900_000, ); - test.runIf(isCommandAvailable("claude"))( + test.runIf(isProviderAvailable("claude"))( "repro: transcript/timeline parity after do-it-again + hello race (hang + interrupt + drop)", async () => { const logger = pino({ level: "silent" }); 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 375034632..5668a904f 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 @@ -7,14 +7,14 @@ import pino from "pino"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { ClaudeAgentClient } from "../agent/providers/claude-agent.js"; -import { isCommandAvailable } from "../agent/provider-launch-config.js"; +import { isProviderAvailable } from "./agent-configs.js"; function tmpCwd(): string { return mkdtempSync(path.join(tmpdir(), "daemon-claude-model-init-")); } describe("daemon E2E (real claude) - model resolution on init", () => { - test.runIf(isCommandAvailable("claude"))( + 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" }); 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 346c3de5f..42cbcdcde 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 @@ -7,14 +7,14 @@ import pino from "pino"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { ClaudeAgentClient } from "../agent/providers/claude-agent.js"; -import { isCommandAvailable } from "../agent/provider-launch-config.js"; +import { isProviderAvailable } from "./agent-configs.js"; function tmpCwd(): string { return mkdtempSync(path.join(tmpdir(), "daemon-claude-runtime-model-reconcile-")); } describe("daemon E2E (real claude) - runtime model reconciliation", () => { - test.runIf(isCommandAvailable("claude"))( + test.runIf(isProviderAvailable("claude"))( "normalizes runtime model to a model ID exposed by the provider catalog", async () => { const logger = pino({ level: "silent" }); 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 b85c32d30..7c5627db1 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 @@ -7,18 +7,14 @@ import pino from "pino"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { ClaudeAgentClient } from "../agent/providers/claude-agent.js"; -import { getFullAccessConfig } from "./agent-configs.js"; -import { isCommandAvailable } from "../agent/provider-launch-config.js"; +import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js"; function tmpCwd(): string { return mkdtempSync(path.join(tmpdir(), "daemon-rewind-dedupe-real-claude-")); } -const hasClaudeCredentials = - !!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY; - describe("daemon E2E (real claude) - rewind user message dedupe", () => { - test.runIf(isCommandAvailable("claude") && hasClaudeCredentials)( + test.runIf(isProviderAvailable("claude"))( "emits /rewind user message once in persisted timeline", async () => { const logger = pino({ level: "silent" }); 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 new file mode 100644 index 000000000..401761f28 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts @@ -0,0 +1,130 @@ +import { describe, test, expect } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; + +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { ClaudeAgentClient } from "../agent/providers/claude-agent.js"; +import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js"; +import { createMessageCollector } from "../test-utils/message-collector.js"; +import type { AgentTimelineItem } from "../agent/agent-sdk-types.js"; +import type { SessionOutboundMessage } from "../messages.js"; + +function tmpCwd(): string { + return mkdtempSync(path.join(tmpdir(), "daemon-real-tool-interrupt-")); +} + +function hasRunningToolCall(messages: SessionOutboundMessage[], agentId: string): boolean { + for (const m of messages) { + if ( + m.type === "agent_stream" && + m.payload.agentId === agentId && + m.payload.event.type === "timeline" && + m.payload.event.item.type === "tool_call" && + m.payload.event.item.status === "running" + ) { + return true; + } + } + return false; +} + +describe("daemon E2E (real claude) - send message during tool call", () => { + test.runIf(isProviderAvailable("claude"))( + "sending a message while a tool call is running starts a new turn", + 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 collector = createMessageCollector(client); + + // Step 1: Ask Claude to run sleep 60 in the foreground + await client.sendMessage( + agent.id, + "Run the bash command `sleep 60` and wait for it to complete. Do not run it in the background.", + ); + + // 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 new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Timed out waiting for running tool call")); + }, 90_000); + + if (hasRunningToolCall(collector.messages, agent.id)) { + clearTimeout(timeout); + resolve(); + return; + } + + const unsub = client.subscribeRawMessages((message) => { + if ( + message.type === "agent_stream" && + message.payload.agentId === agent.id && + message.payload.event.type === "timeline" && + message.payload.event.item.type === "tool_call" && + message.payload.event.item.status === "running" + ) { + clearTimeout(timeout); + unsub(); + resolve(); + } + }); + }); + + // 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); + expect(finish.status).toBe("idle"); + + // Step 6: Verify the agent actually responded to our second message + const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 }); + const assistantTexts = timeline.entries + .filter((entry) => entry.item.type === "assistant_message") + .map((entry) => { + const item = entry.item as Extract; + return item.text; + }); + + 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, + ); +}); 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 dbbf4e76c..2ed6ff1f1 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 @@ -7,16 +7,15 @@ import pino from "pino"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { ClaudeAgentClient } from "../agent/providers/claude-agent.js"; -import { getFullAccessConfig } from "./agent-configs.js"; +import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js"; import { applyAgentInputProcessingTransition } from "./send-while-running-stuck-test-utils.js"; -import { isCommandAvailable } from "../agent/provider-launch-config.js"; function tmpCwd(): string { return mkdtempSync(path.join(tmpdir(), "daemon-real-stuck-claude-")); } describe("daemon E2E (real claude) - send while running recovery", () => { - test.runIf(isCommandAvailable("claude"))( + test.runIf(isProviderAvailable("claude"))( "clears input processing when the interrupt transition is missed", async () => { const logger = pino({ level: "silent" }); 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 41a0d36d0..fb5470b6a 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 @@ -7,16 +7,15 @@ import pino from "pino"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js"; -import { getFullAccessConfig } from "./agent-configs.js"; +import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js"; import { applyAgentInputProcessingTransition } from "./send-while-running-stuck-test-utils.js"; -import { isCommandAvailable } from "../agent/provider-launch-config.js"; function tmpCwd(): string { return mkdtempSync(path.join(tmpdir(), "daemon-real-stuck-")); } describe("daemon E2E (real codex) - send while running recovery", () => { - test.runIf(isCommandAvailable("codex"))( + test.runIf(isProviderAvailable("codex"))( "clears input processing when the interrupt transition is missed", async () => { const logger = pino({ level: "silent" }); 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 883b70c18..f07aa0900 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 @@ -13,7 +13,7 @@ import { DaemonClient } from "../test-utils/daemon-client.js"; import { allProviders, getFullAccessConfig, - isRealProviderReady, + isProviderAvailable, type AgentProvider, } from "./agent-configs.js"; @@ -456,7 +456,7 @@ function createRealAgentClient(provider: AgentProvider, logger: pino.Logger): Ag } describe.each(allProviders)("daemon E2E (real %s) - UI action stress", (provider) => { - const shouldRun = isRealProviderReady(provider); + const shouldRun = isProviderAvailable(provider); test.runIf(shouldRun)( "normal UI submit path (idle sends) stays correct",