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.
This commit is contained in:
Mohamed Boudra
2026-03-25 16:00:24 +07:00
parent 81ee887d03
commit c4782ec71c
14 changed files with 321 additions and 45 deletions

View File

@@ -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<void>();
// 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<void> {
this.interrupted = true;
// No events produced — the terminal event was suppressed
}
}
class StaleForegroundClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
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);
});

View File

@@ -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) {

View File

@@ -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", () => {

View File

@@ -125,16 +125,31 @@ export function resolveShellEnv(): Record<string, string> {
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<string, string | undefined>,
runtimeSettings?: ProviderRuntimeSettings,
shellEnv?: Record<string, string>,
): Record<string, string | undefined> {
return {
const merged: Record<string, string | undefined> = {
...baseEnv,
...(shellEnv ?? resolveShellEnv()),
...(runtimeSettings?.env ?? {}),
};
for (const key of PARENT_SESSION_ENV_VARS) {
delete merged[key];
}
return merged;
}
/**

View File

@@ -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");
}
/**

View File

@@ -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" });

View File

@@ -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" });

View File

@@ -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" });

View File

@@ -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" });

View File

@@ -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" });

View File

@@ -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<void>((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<AgentTimelineItem, { type: "assistant_message" }>;
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,
);
});

View File

@@ -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" });

View File

@@ -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" });

View File

@@ -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",