Harden Claude provider lifecycle and clean temp artifacts

This commit is contained in:
Mohamed Boudra
2026-02-22 11:44:56 +07:00
parent c1e4b0fcbf
commit ccd42751d2
16 changed files with 1845 additions and 373 deletions

View File

@@ -1,112 +0,0 @@
# Claude Provider Redesign Plan
## Goal
Eliminate dropped/misrouted Claude messages and stuck lifecycle transitions under foreground/autonomous interleaving by replacing heuristics with an explicit state machine and deterministic routing.
## Non-Negotiable Constraints
1. Single-owner SDK consumption: only one internal pump calls `query.next()`.
2. Task notifications are metadata only, never lifecycle correctness dependencies.
3. Interrupt must work when lifecycle is `running`, even with no foreground `pendingRun`.
4. Public API semantics unchanged:
- `stream`, `streamHistory`, `interrupt` signatures remain.
- `waitForFinish` remains status-based (`running -> idle/error/permission/timeout`).
## Final Architecture
### 1) Session Turn Ownership State
`turnState`: `idle | foreground | autonomous`
Transitions:
1. `idle -> foreground` on user `stream()` send.
2. `foreground -> idle` on foreground terminal event.
3. `idle -> autonomous` on first routable autonomous SDK event.
4. `autonomous -> idle` when autonomous run(s) terminal.
5. `autonomous -> foreground` must be atomic:
- interrupt autonomous
- await teardown
- start foreground
### 2) Run Lifecycle Tracking
Per-run state (tracked independently from timeline):
`queued | awaiting_response | streaming | finalizing | completed | interrupted | error`
Session status derives from runs:
- `running` iff any run is in `queued|awaiting_response|streaming|finalizing`
- `permission` iff permission pending
- `error` iff errored
- `idle` otherwise
### 3) Deterministic Event Routing (single decision point)
In the query pump, normalize each SDK event, then route by this order:
1. If `turnState=foreground`: route all non-metadata events to foreground run queue.
2. Else route by identifiers:
- `task_id`
- `parent_message_id`
- `message_id`
- fallback: create autonomous run
3. If no route: metadata-only event.
No timestamp windows. No suppression/diversion heuristics.
### 4) Timeline Assembly Decoupled from Run Ownership
Timeline is assembled from normalized message events keyed by `message_id`, independent of run ownership:
1. `message_start` -> create/update timeline entry
2. `message_delta` -> append
3. `message_stop` -> finalize
4. Support out-of-order events (placeholder then reconcile)
Guarantee: assistant messages cannot be dropped from timeline due routing ambiguity.
## Refactor Sequence (Low-Risk Migration)
1. Introduce explicit types:
- `TurnState`
- `RunTracker` (run map + lifecycle)
- `TimelineAssembler` (message-id based)
2. Refactor pump flow to:
- normalize event
- route via single decision point
- update run tracker
- always feed timeline assembler
3. Implement atomic `autonomous -> foreground` transition.
4. Remove old heuristics:
- suppression windows
- pre-prompt diversion/grace logic
- task-notification lifecycle branching
5. Keep manager/provider external contracts unchanged.
## Correctness Invariants
1. Only query pump calls `query.next()`.
2. Foreground exclusivity: while `turnState=foreground`, non-metadata SDK events cannot route to autonomous path.
3. Lifecycle correctness: `running` emitted when any run active; terminal transitions settle back to `idle`.
4. Interrupt correctness: `cancelAgentRun()` works for autonomous running with no foreground `pendingRun`.
5. Timeline completeness: every assistant `message_start/delta/stop` appears in timeline.
## Test Plan
### Unit Tests
1. Deterministic routing by identifier priority.
2. Foreground exclusivity under interleaving events.
3. Run lifecycle transitions (success, interrupt, error).
4. Timeline assembler out-of-order robustness.
5. Manager autonomous cancel path without foreground `pendingRun`.
### Real Claude E2E Gates
1. `repro: do-it-again + immediate hello can hang after autonomous wake under churn`
- acceptance: autonomous `running` transition observed within timeout each cycle.
2. `repro: transcript/timeline parity after do-it-again + hello race`
- acceptance: assistant messages present in transcript (including HELLO and autonomous completion) are present in timeline.
## Rollout / Risk Controls
1. Add temporary structured debug logs for:
- turn state transitions
- routing decisions (`event -> run`)
- run lifecycle transitions
- timeline assembly (`message_id`)
2. Keep clear rollback boundaries by incremental commits:
- types + scaffolding
- routing replacement
- heuristic removal
- test hardening
3. Run `npm run -w packages/server typecheck` and targeted test suites after each stage.

View File

@@ -1,81 +0,0 @@
# Claude Provider Invariants and Regression Test Plan
## Goal
Lock down behavior for Claude provider stream handling without changing provider interface contracts.
## Non-Negotiable Contract Invariants
- `I1` Provider interface remains unchanged.
- `stream(prompt) -> AsyncGenerator<AgentStreamEvent>` stays the only provider surface for run streaming.
- Reference: `packages/server/src/server/agent/agent-sdk-types.ts:337`
- `I2` One prompt submission produces exactly one terminal turn event.
- Terminal set: `turn_completed | turn_failed | turn_canceled`.
- No ambiguous completion path.
- `I3` Wait semantics remain status-based, not message-correlation-based.
- `waitForFinish` resolves on lifecycle outcomes (`idle|error|permission|timeout`).
- References:
- `packages/server/src/server/session.ts:5366`
- `packages/server/src/shared/messages.ts:1523`
- `I4` App chat/input semantics remain status-transition-based.
- Input processing and queue flush continue to rely on `running` transitions and `updatedAt` ordering.
- References:
- `packages/app/src/components/agent-input-area.tsx:222`
- `packages/app/src/components/agent-input-area.tsx:300`
- `packages/app/src/contexts/session-context.tsx:485`
- `I5` New prompt must never be satisfied by stale pre-prompt assistant/result events.
- Old queued events (including system-triggered activity) must not be misattributed as response to latest prompt.
## Existing Coverage (Keep)
- Wait semantics after immediate/rapid sends:
- `packages/server/src/server/daemon-e2e/wait-for-idle.e2e.test.ts:33`
- Send-while-running recovery under real providers:
- `packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts:18`
- `packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts:18`
- Claude stale old-turn protection (interrupt-failure path):
- `packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts:217`
- App stream head/tail flush on terminal events:
- `packages/app/src/types/stream-event.test.ts:115`
## Missing Coverage (Add)
### M1: Task Notification Interleaving Regression (Claude Unit)
- Add mocked SDK sequence where a queued system/task-notification-related burst causes old assistant/result events to appear before new prompt output.
- Assert latest `session.stream(newPrompt)` does not emit stale assistant text as its reply.
- Expected: `I5` enforced.
### M2: Stale Result Preemption Regression (Claude Unit)
- Add mocked SDK sequence with old pending `result` available right after pushing a new prompt.
- Assert provider ignores/contains stale terminal for previous activity and does not complete new prompt prematurely.
- Expected: `I2` + `I5` enforced.
### M3: Wait Final Snapshot Coherence (Daemon E2E/Integration)
- Add/strengthen test to ensure `waitForFinish` never returns `status: idle` while `final.status === running`.
- Remove need for retry loop workaround in stress scenarios.
- Current stress workaround location:
- `packages/server/src/server/daemon-e2e/ui-action-stress.real.e2e.test.ts:387`
- Expected: `I3` hard guarantee.
## Implementation Notes (Provider-Internal Only)
- Turn events are valid internal lifecycle markers.
- Do not introduce user-message to assistant-message correlation as a new cross-layer contract.
- Do not modify app/CLI/public daemon wait interfaces.
- Keep all changes scoped to Claude provider internals and regression tests.
## Definition of Done
- Invariants `I1-I5` are unchanged and explicitly covered.
- New tests for `M1-M3` are added and passing.
- Existing tests listed above remain green.
- `npm run typecheck` passes.

View File

@@ -2538,7 +2538,7 @@ dependencies = [
[[package]]
name = "paseo"
version = "0.1.14"
version = "0.1.15"
dependencies = [
"log",
"serde",

View File

@@ -29,6 +29,7 @@ function createMockTransport() {
let onOpen: () => void = () => {}
let onClose: (_event?: unknown) => void = () => {}
let onError: (_event?: unknown) => void = () => {}
let welcomeOrdinal = 1
const transport: DaemonTransport = {
send: (data) => sent.push(data),
@@ -54,7 +55,20 @@ function createMockTransport() {
return {
transport,
sent,
triggerOpen: () => onOpen(),
triggerOpen: () => {
onOpen()
// Ignore HELLO handshake payloads in assertions.
sent.length = 0
onMessage(
JSON.stringify({
type: 'welcome',
serverId: `srv_test_${welcomeOrdinal++}`,
hostname: null,
version: null,
resumed: false,
})
)
},
triggerClose: (event?: unknown) => onClose(event),
triggerError: (event?: unknown) => onError(event),
triggerMessage: (data: unknown) => onMessage(data),
@@ -309,7 +323,7 @@ describe('DaemonClient', () => {
mock.triggerOpen()
await connectPromise
const transitionPayloads = logger.info.mock.calls
const transitionPayloads = logger.debug.mock.calls
.filter(([, message]) => message === 'DaemonClientTransition')
.map(([payload]) => payload as { generation?: number | null })
expect(transitionPayloads.length).toBeGreaterThan(0)
@@ -868,11 +882,15 @@ describe('DaemonClient', () => {
vi.useFakeTimers()
const logger = createMockLogger()
const mock = createMockTransport()
let sendCount = 0
const transportFactory = () => ({
...mock.transport,
send: () => {
throw new Error('boom')
sendCount += 1
if (sendCount > 1) {
throw new Error('boom')
}
},
})

View File

@@ -91,7 +91,7 @@ export interface Logger {
}
const consoleLogger: Logger = {
debug: (obj, msg) => console.debug(msg, obj),
debug: () => {},
info: (obj, msg) => console.info(msg, obj),
warn: (obj, msg) => console.warn(msg, obj),
error: (obj, msg) => console.error(msg, obj),
@@ -2283,15 +2283,101 @@ export class DaemonClient {
predicate: (snapshot: AgentSnapshotPayload) => boolean,
timeout = 60000
): Promise<AgentSnapshotPayload> {
const startedAt = Date.now()
while (Date.now() - startedAt < timeout) {
const snapshot = await this.fetchAgent(agentId).catch(() => null)
if (snapshot && predicate(snapshot)) {
return snapshot
}
await new Promise((resolve) => setTimeout(resolve, 250))
const initial = await this.fetchAgent(agentId).catch(() => null)
if (initial && predicate(initial)) {
return initial
}
throw new Error(`Timed out waiting for agent ${agentId}`)
const deadline = Date.now() + timeout
return await new Promise<AgentSnapshotPayload>((resolve, reject) => {
let settled = false
let pollInFlight = false
let pollTimer: ReturnType<typeof setInterval> | null = null
let timeoutTimer: ReturnType<typeof setTimeout> | null = null
let unsubscribe: (() => void) | null = null
const finish = (
result:
| { kind: 'ok'; snapshot: AgentSnapshotPayload }
| { kind: 'error'; error: Error }
) => {
if (settled) {
return
}
settled = true
if (timeoutTimer) {
clearTimeout(timeoutTimer)
timeoutTimer = null
}
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
if (unsubscribe) {
unsubscribe()
unsubscribe = null
}
if (result.kind === 'ok') {
resolve(result.snapshot)
return
}
reject(result.error)
}
const maybeResolve = (snapshot: AgentSnapshotPayload | null) => {
if (!snapshot) {
return false
}
if (!predicate(snapshot)) {
return false
}
finish({ kind: 'ok', snapshot })
return true
}
const poll = async () => {
if (settled || pollInFlight) {
return
}
pollInFlight = true
try {
const snapshot = await this.fetchAgent(agentId).catch(() => null)
maybeResolve(snapshot)
} finally {
pollInFlight = false
}
}
unsubscribe = this.on('agent_update', (message) => {
if (settled) {
return
}
if (message.type !== 'agent_update') {
return
}
if (message.payload.kind !== 'upsert') {
return
}
const snapshot = message.payload.agent
if (snapshot.id !== agentId) {
return
}
maybeResolve(snapshot)
})
const remaining = Math.max(1, deadline - Date.now())
timeoutTimer = setTimeout(() => {
finish({
kind: 'error',
error: new Error(`Timed out waiting for agent ${agentId}`),
})
}, remaining)
pollTimer = setInterval(() => {
void poll()
}, 250)
void poll()
})
}
async waitForFinish(agentId: string, timeout = 60000): Promise<WaitForFinishResult> {
@@ -2715,7 +2801,7 @@ export class DaemonClient {
: null
const reason = metadata?.reason ?? reasonFromNext
const reasonCode = metadata?.reasonCode ?? toReasonCode(reason)
this.logger.info(
this.logger.debug(
{
serverId: this.logServerId,
clientIdHash: this.logClientIdHash,

View File

@@ -1195,8 +1195,10 @@ describe("AgentManager", () => {
releaseForeground.resolve();
await consumeForeground;
const updated = manager.getAgent(snapshot.id);
expect(updated?.lifecycle).toBe("idle");
const replaying = manager.getAgent(snapshot.id);
expect(replaying?.lifecycle).toBe("running");
const settled = await manager.waitForAgentEvent(snapshot.id);
expect(settled.status).toBe("idle");
expect(manager.getTimeline(snapshot.id)).toContainEqual({
type: "assistant_message",
text: "AUTONOMOUS_DURING_FOREGROUND",

View File

@@ -244,6 +244,7 @@ type LiveEventStreamingSession = AgentSession & {
const DEFAULT_MAX_TIMELINE_ITEMS = 2000;
const DEFAULT_TIMELINE_FETCH_LIMIT = 200;
const LIVE_BACKLOG_TERMINAL_REPLAY_DELAY_MS = 300;
const BUSY_STATUSES: AgentLifecycleStatus[] = [
"initializing",
"running",
@@ -254,6 +255,14 @@ function isAgentBusy(status: AgentLifecycleStatus): boolean {
return BUSY_STATUSES.includes(status);
}
function isTurnTerminalEvent(event: AgentStreamEvent): boolean {
return (
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled"
);
}
function createAbortError(
signal: AbortSignal | undefined,
fallbackMessage: string
@@ -297,6 +306,10 @@ export class AgentManager {
private readonly backgroundTasks = new Set<Promise<void>>();
private readonly liveEventPumps = new Map<string, Promise<void>>();
private readonly liveEventBacklog = new Map<string, AgentStreamEvent[]>();
private readonly liveEventBacklogFlushTimers = new Map<
string,
ReturnType<typeof setTimeout>
>();
private onAgentAttention?: AgentAttentionCallback;
private logger: Logger;
@@ -745,6 +758,7 @@ export class AgentManager {
this.agents.delete(agentId);
this.liveEventPumps.delete(agentId);
this.liveEventBacklog.delete(agentId);
this.clearLiveEventBacklogFlushTimer(agentId);
try {
await existing.session.close();
} catch (error) {
@@ -776,6 +790,7 @@ export class AgentManager {
this.agents.delete(agentId);
this.liveEventPumps.delete(agentId);
this.liveEventBacklog.delete(agentId);
this.clearLiveEventBacklogFlushTimer(agentId);
// Clean up previousStatus to prevent memory leak
this.previousStatuses.delete(agentId);
const session = agent.session;
@@ -2005,6 +2020,39 @@ export class AgentManager {
this.liveEventBacklog.set(agentId, [event]);
}
private clearLiveEventBacklogFlushTimer(agentId: string): void {
const timer = this.liveEventBacklogFlushTimers.get(agentId);
if (!timer) {
return;
}
clearTimeout(timer);
this.liveEventBacklogFlushTimers.delete(agentId);
}
private scheduleLiveEventBacklogFlush(agentId: string, delayMs: number): void {
if (this.liveEventBacklogFlushTimers.has(agentId)) {
return;
}
const timer = setTimeout(() => {
this.liveEventBacklogFlushTimers.delete(agentId);
const latest = this.agents.get(agentId);
if (!latest) {
return;
}
if (latest.pendingRun) {
this.scheduleLiveEventBacklogFlush(
agentId,
LIVE_BACKLOG_TERMINAL_REPLAY_DELAY_MS
);
return;
}
this.flushLiveEventBacklog(latest);
}, delayMs);
this.liveEventBacklogFlushTimers.set(agentId, timer);
}
private flushLiveEventBacklog(agent: ActiveManagedAgent): void {
if (agent.pendingRun) {
return;
@@ -2013,9 +2061,45 @@ export class AgentManager {
if (!pending || pending.length === 0) {
return;
}
this.clearLiveEventBacklogFlushTimer(agent.id);
this.liveEventBacklog.delete(agent.id);
const immediate: AgentStreamEvent[] = [];
const deferred: AgentStreamEvent[] = [];
let sawTurnStarted = false;
let deferRemainder = false;
for (const event of pending) {
if (!deferRemainder && sawTurnStarted && isTurnTerminalEvent(event)) {
deferRemainder = true;
}
if (deferRemainder) {
deferred.push(event);
continue;
}
immediate.push(event);
if (event.type === "turn_started") {
sawTurnStarted = true;
}
}
for (const event of immediate) {
this.handleStreamEvent(agent, event);
}
if (deferred.length === 0) {
return;
}
const existing = this.liveEventBacklog.get(agent.id);
if (existing && existing.length > 0) {
this.liveEventBacklog.set(agent.id, [...deferred, ...existing]);
} else {
this.liveEventBacklog.set(agent.id, deferred);
}
this.scheduleLiveEventBacklogFlush(
agent.id,
LIVE_BACKLOG_TERMINAL_REPLAY_DELAY_MS
);
}
}

View File

@@ -5,6 +5,7 @@ import {
} from "../../test-utils/index.js";
import type { AgentSlashCommand } from "../agent-sdk-types.js";
// Fake-daemon plumbing coverage: validates manager/client command wiring without a real Claude binary.
describe("claude agent commands E2E", () => {
let ctx: DaemonTestContext;

View File

@@ -0,0 +1,46 @@
import { describe, expect, test } from "vitest";
import pino from "pino";
import { isCommandAvailable } from "../provider-launch-config.js";
import type { AgentSlashCommand } from "../agent-sdk-types.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)", () => {
test(
"lists slash commands with the expected contract",
async () => {
expect(isCommandAvailable("claude")).toBe(true);
const client = new ClaudeAgentClient({
logger: pino({ level: "silent" }),
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
modeId: "plan",
});
try {
expect(typeof session.listCommands).toBe("function");
const commands = await session.listCommands!();
expect(Array.isArray(commands)).toBe(true);
expect(commands.length).toBeGreaterThan(0);
expect(commands.map((command) => command.name)).toContain("rewind");
for (const command of commands) {
const typed = command as AgentSlashCommand;
expect(typeof typed.name).toBe("string");
expect(typed.name.length).toBeGreaterThan(0);
expect(typed.name.startsWith("/")).toBe(false);
expect(typeof typed.description).toBe("string");
expect(typeof typed.argumentHint).toBe("string");
}
} finally {
await session.close();
}
},
60_000
);
});

View File

@@ -260,7 +260,9 @@ describe("ClaudeAgentSession interrupt restart regression", () => {
return mock;
}
const mock = buildSecondQueryMock(prompt);
sdkMocks.secondQuery = mock;
if (queryCreateCount === 2) {
sdkMocks.secondQuery = mock;
}
return mock;
}
);
@@ -292,7 +294,11 @@ describe("ClaudeAgentSession interrupt restart regression", () => {
const secondTurnEvents = await secondTurnPromise;
const secondAssistantText = collectAssistantText(secondTurnEvents);
expect(sdkMocks.query.mock.calls.length).toBeGreaterThanOrEqual(2);
expect(sdkMocks.firstQuery).toBeTruthy();
expect(sdkMocks.secondQuery).toBeTruthy();
expect(sdkMocks.firstQuery).not.toBe(sdkMocks.secondQuery);
expect(sdkMocks.firstQuery?.interrupt).toHaveBeenCalledTimes(2);
expect(sdkMocks.secondQuery?.next).toHaveBeenCalled();
expect(secondAssistantText).toContain("NEW_TURN_RESPONSE");
expect(secondAssistantText).not.toContain("OLD_TURN_RESPONSE");

View File

@@ -1,9 +1,10 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { Logger } from "pino";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { AgentManager } from "../agent-manager.js";
import { ClaudeAgentClient } from "./claude-agent.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import type { AgentStreamEvent, AgentTimelineItem } from "../agent-sdk-types.js";
const sdkMocks = vi.hoisted(() => ({
query: vi.fn(),
@@ -70,6 +71,68 @@ async function createSession() {
});
}
function createSessionWithLogger(logger: Logger) {
const client = new ClaudeAgentClient({ logger });
return client.createSession({
provider: "claude",
cwd: process.cwd(),
});
}
type CapturedLog = {
level: "debug" | "info" | "warn" | "error";
args: unknown[];
};
function createSpyLogger(): {
logger: Logger;
calls: CapturedLog[];
debug: ReturnType<typeof vi.fn>;
info: ReturnType<typeof vi.fn>;
warn: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
} {
const calls: CapturedLog[] = [];
const debug = vi.fn((...args: unknown[]) => {
calls.push({ level: "debug", args });
});
const info = vi.fn((...args: unknown[]) => {
calls.push({ level: "info", args });
});
const warn = vi.fn((...args: unknown[]) => {
calls.push({ level: "warn", args });
});
const error = vi.fn((...args: unknown[]) => {
calls.push({ level: "error", args });
});
const loggerLike = {
child: vi.fn(),
debug,
info,
warn,
error,
fatal: error,
trace: debug,
};
loggerLike.child.mockReturnValue(loggerLike);
return {
logger: loggerLike as unknown as Logger,
calls,
debug,
info,
warn,
error,
};
}
function extractStringLogArgs(calls: unknown[][]): string[] {
return calls.flatMap((args) =>
args.filter((arg): arg is string => typeof arg === "string")
);
}
async function collectUntilTerminal(
stream: AsyncGenerator<AgentStreamEvent>
): Promise<AgentStreamEvent[]> {
@@ -110,6 +173,453 @@ describe("ClaudeAgentSession redesign invariants", () => {
sdkMocks.query.mockReset();
});
test("logs redacted query summary and never leaks sentinel secrets", async () => {
const envSecret = "PASEO_ENV_SENTINEL_SECRET";
const runtimeSecret = "PASEO_RUNTIME_SENTINEL_SECRET";
const systemSecret = "PASEO_SYSTEM_PROMPT_SENTINEL_SECRET";
const previousEnv = process.env.PASEO_TEST_SENTINEL_SECRET;
process.env.PASEO_TEST_SENTINEL_SECRET = envSecret;
sdkMocks.query.mockImplementation(() => {
let step = 0;
return createBaseQueryMock(
vi.fn(async () => {
if (step === 0) {
step += 1;
return {
done: false,
value: {
type: "system",
subtype: "init",
session_id: "redacted-log-session",
permissionMode: "default",
model: "opus",
},
};
}
if (step === 1) {
step += 1;
return {
done: false,
value: {
type: "assistant",
message: { content: "done" },
},
};
}
if (step === 2) {
step += 1;
return {
done: false,
value: {
type: "result",
subtype: "success",
usage: buildUsage(),
total_cost_usd: 0,
},
};
}
return { done: true, value: undefined };
})
);
});
const spy = createSpyLogger();
const client = new ClaudeAgentClient({
logger: spy.logger,
runtimeSettings: {
env: {
PASEO_RUNTIME_SENTINEL_SECRET: runtimeSecret,
},
},
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
systemPrompt: `Never log ${systemSecret}`,
});
try {
await session.run("redaction check");
const queryLogCall = spy.debug.mock.calls.find(
(args) => args[1] === "claude query"
);
expect(queryLogCall).toBeDefined();
const payload = queryLogCall?.[0] as
| { options?: Record<string, unknown> }
| undefined;
expect(payload?.options).toBeDefined();
expect(payload?.options).not.toHaveProperty("env");
expect(payload?.options).not.toHaveProperty("systemPrompt");
expect(payload?.options).not.toHaveProperty("canUseTool");
expect(payload?.options).toHaveProperty("hasEnv");
expect(payload?.options).toHaveProperty("envKeyCount");
const serialized = JSON.stringify(
spy.calls,
(_key, value) => (typeof value === "function" ? "[function]" : value)
);
expect(serialized).not.toContain(envSecret);
expect(serialized).not.toContain(runtimeSecret);
expect(serialized).not.toContain(systemSecret);
} finally {
await session.close();
if (previousEnv === undefined) {
delete process.env.PASEO_TEST_SENTINEL_SECRET;
} else {
process.env.PASEO_TEST_SENTINEL_SECRET = previousEnv;
}
}
});
test("emits interrupt step diagnostics at debug level only", async () => {
const spy = createSpyLogger();
const session = await createSessionWithLogger(spy.logger);
const internal = session as unknown as {
query:
| {
interrupt: () => Promise<void>;
return?: () => Promise<void>;
}
| null;
input: { end: () => void } | null;
queryRestartNeeded: boolean;
interruptActiveTurn: () => Promise<void>;
};
const interrupt = vi.fn(async () => undefined);
const queryReturn = vi.fn(async () => undefined);
const end = vi.fn(() => undefined);
internal.query = {
interrupt,
return: queryReturn,
};
internal.input = { end };
internal.queryRestartNeeded = false;
try {
await internal.interruptActiveTurn();
const interruptInfoMessages = extractStringLogArgs(spy.info.mock.calls).filter(
(message) => message.includes("interruptActiveTurn")
);
const interruptDebugMessages = extractStringLogArgs(
spy.debug.mock.calls
).filter((message) => message.includes("interruptActiveTurn"));
expect(interruptInfoMessages).toEqual([]);
expect(interruptDebugMessages).toEqual([
"interruptActiveTurn: calling query.interrupt()...",
"interruptActiveTurn: query.interrupt() returned",
"interruptActiveTurn: calling query.return()...",
"interruptActiveTurn: query.return() returned",
]);
expect(interrupt).toHaveBeenCalledTimes(1);
expect(queryReturn).toHaveBeenCalledTimes(1);
expect(end).toHaveBeenCalledTimes(1);
expect(internal.query).toBeNull();
expect(internal.input).toBeNull();
expect(internal.queryRestartNeeded).toBe(false);
} finally {
await session.close();
}
});
test("extracts identifiers from fixture-driven protocol shape variants", async () => {
const spy = createSpyLogger();
const session = await createSessionWithLogger(spy.logger);
const internal = session as unknown as {
routeSdkMessageFromPump: (message: Record<string, unknown>) => void;
};
const fixtures = [
{
name: "root identifiers take priority over nested variants",
message: {
type: "stream_event",
task_id: "task-root",
parent_message_id: "parent-root",
message_id: "msg-root",
event: {
type: "message_delta",
task_id: "task-event",
parent_message_id: "parent-event",
message_id: "msg-event",
message: { id: "msg-event-inner" },
},
},
expected: {
taskId: "task-root",
parentMessageId: "parent-root",
messageId: "msg-root",
},
},
{
name: "stream_event identifiers are used when root identifiers are absent",
message: {
type: "stream_event",
event: {
type: "message_delta",
task_id: "task-event-only",
parent_message_id: "parent-event-only",
message_id: "msg-event-only",
},
},
expected: {
taskId: "task-event-only",
parentMessageId: "parent-event-only",
messageId: "msg-event-only",
},
},
{
name: "assistant message container identifiers are used as a fallback",
message: {
type: "assistant",
message: {
id: "msg-container",
task_id: "task-container",
parent_message_id: "parent-container",
content: "assistant message",
},
},
expected: {
taskId: "task-container",
parentMessageId: "parent-container",
messageId: "msg-container",
},
},
{
name: "user uuid is used as a message_id fallback",
message: {
type: "user",
uuid: "uuid-fallback",
message: {
role: "user",
content: "prompt text",
},
},
expected: {
taskId: null,
parentMessageId: null,
messageId: "uuid-fallback",
},
},
] as const;
try {
for (const fixture of fixtures) {
spy.debug.mockClear();
internal.routeSdkMessageFromPump(
fixture.message as unknown as Record<string, unknown>
);
const routingCall = spy.debug.mock.calls.find(
(args) => args[1] === "Claude query pump routing decision"
);
expect(routingCall).toBeDefined();
const payload = routingCall?.[0] as
| { identifiers?: Record<string, string | null> }
| undefined;
expect(payload?.identifiers).toEqual(fixture.expected);
}
} finally {
await session.close();
}
});
test("captures session IDs from fixture-driven init message variants", async () => {
const fixtures = [
{
name: "session_id field",
payload: { session_id: " session-id-1 " },
expected: "session-id-1",
},
{
name: "sessionId field",
payload: { sessionId: " session-id-2 " },
expected: "session-id-2",
},
{
name: "nested session.id field",
payload: { session: { id: " session-id-3 " } },
expected: "session-id-3",
},
] as const;
for (const fixture of fixtures) {
const session = await createSession();
const internal = session as unknown as {
handleSystemMessage: (message: Record<string, unknown>) => string | null;
};
try {
const started = internal.handleSystemMessage({
type: "system",
subtype: "init",
permissionMode: "default",
model: "opus",
...fixture.payload,
});
expect(started).toBe(fixture.expected);
expect(session.describePersistence()?.sessionId).toBe(fixture.expected);
} finally {
await session.close();
}
}
});
test("applies input_json_delta updates only when buffered JSON is complete", async () => {
const session = await createSession();
const internal = session as unknown as {
mapPartialEvent: (event: Record<string, unknown>) => AgentTimelineItem[];
toolUseCache: Map<string, { input?: Record<string, unknown> }>;
toolUseIndexToId: Map<number, string>;
toolUseInputBuffers: Map<string, string>;
};
const toolUseId = "tool-input-delta";
const index = 7;
try {
internal.mapPartialEvent({
type: "content_block_start",
index,
content_block: {
type: "tool_use",
id: toolUseId,
name: "Bash",
input: { command: "echo seed" },
},
});
const readCommand = () => {
const command = internal.toolUseCache.get(toolUseId)?.input?.command;
return typeof command === "string" ? command : null;
};
const deltaFixtures = [
{
event: {
type: "content_block_delta",
index,
delta: {
type: "input_json_delta",
partial_json: "{\"command\":\"echo ",
},
},
expectedCommand: "echo seed",
},
{
event: {
type: "content_block_delta",
index,
delta: {
type: "input_json_delta",
partial_json: "delta\"}",
},
},
expectedCommand: "echo delta",
},
{
event: {
type: "content_block_delta",
delta: {
type: "input_json_delta",
partial_json: "{\"command\":\"ignored\"}",
},
},
expectedCommand: "echo delta",
},
] as const;
for (const fixture of deltaFixtures) {
internal.mapPartialEvent(
fixture.event as unknown as Record<string, unknown>
);
expect(readCommand()).toBe(fixture.expectedCommand);
}
internal.mapPartialEvent({
type: "content_block_stop",
index,
});
expect(internal.toolUseIndexToId.has(index)).toBe(false);
expect(internal.toolUseInputBuffers.has(toolUseId)).toBe(false);
} finally {
await session.close();
}
});
test("maps tool_result content shapes into deterministic string output", async () => {
const session = await createSession();
const internal = session as unknown as {
buildToolOutput: (
block: Record<string, unknown>,
entry: Record<string, unknown> | undefined
) => Record<string, unknown> | undefined;
};
const toolEntry = {
id: "tool-1",
name: "Bash",
server: "Bash",
classification: "command",
started: true,
input: {
command: "echo hello",
},
};
const fixtures = [
{
name: "string content",
content: "plain output",
expectedOutput: "plain output",
},
{
name: "text block array content",
content: [
{ type: "text", text: "first line\n" },
{ type: "text", text: "second line" },
],
expectedOutput: "first line\nsecond line",
},
{
name: "structured fallback content",
content: {
z: 3,
nested: {
b: 2,
a: 1,
},
a: 0,
},
expectedOutput: "{\"a\":0,\"nested\":{\"a\":1,\"b\":2},\"z\":3}",
},
] as const;
try {
for (const fixture of fixtures) {
const output = internal.buildToolOutput(
{
type: "tool_result",
tool_use_id: "tool-1",
tool_name: "Bash",
content: fixture.content,
is_error: false,
},
toolEntry
);
expect(output).toEqual(
expect.objectContaining({
type: "command",
command: "echo hello",
output: fixture.expectedOutput,
})
);
}
} finally {
await session.close();
}
});
test("routes by deterministic identifier priority: task_id > parent_message_id > message_id", async () => {
const session = await createSession();
const internal = session as unknown as {
@@ -232,8 +742,8 @@ describe("ClaudeAgentSession redesign invariants", () => {
},
metadataOnly: false,
});
expect(unboundBeforeReplay.reason).toBe("fallback");
expect(unboundBeforeReplay.run?.id).not.toBe(foregroundRun.id);
expect(unboundBeforeReplay.reason).toBe("foreground");
expect(unboundBeforeReplay.run?.id).toBe(foregroundRun.id);
const promptReplayId = "foreground-replay-id";
internal.runTracker.bindIdentifiers(foregroundRun, {
@@ -260,6 +770,112 @@ describe("ClaudeAgentSession redesign invariants", () => {
await session.close();
});
test("reuses one autonomous run for unbound stream_event bursts with no foreground run", async () => {
const session = await createSession();
const internal = session as unknown as {
turnState: "idle" | "foreground" | "autonomous";
nextRunOrdinal: number;
routeSdkMessageFromPump: (message: Record<string, unknown>) => void;
runTracker: {
listActiveRuns: (owner?: "foreground" | "autonomous") => Array<{ id: string }>;
};
};
internal.turnState = "idle";
internal.routeSdkMessageFromPump({
type: "stream_event",
event: {
type: "content_block_delta",
delta: { type: "text_delta", text: "AUTO " },
},
});
const firstRun = internal.runTracker.listActiveRuns("autonomous");
expect(firstRun).toHaveLength(1);
expect(internal.nextRunOrdinal).toBe(2);
internal.routeSdkMessageFromPump({
type: "stream_event",
event: {
type: "content_block_delta",
delta: { type: "text_delta", text: "WAKE" },
},
});
const secondRun = internal.runTracker.listActiveRuns("autonomous");
expect(secondRun).toHaveLength(1);
expect(secondRun[0]?.id).toBe(firstRun[0]?.id);
expect(internal.nextRunOrdinal).toBe(2);
internal.routeSdkMessageFromPump({
type: "result",
subtype: "success",
usage: buildUsage(),
total_cost_usd: 0,
});
expect(internal.runTracker.listActiveRuns("autonomous")).toHaveLength(0);
await session.close();
});
test("pushEvent does not route side-channel events into a stale foreground queue", async () => {
const session = await createSession();
const staleQueueEvents: AgentStreamEvent[] = [];
const staleQueue = {
push: (event: AgentStreamEvent) => staleQueueEvents.push(event),
end: () => undefined,
[Symbol.asyncIterator]: () => ({
next: async () => ({ done: true as const, value: undefined }),
}),
};
const internal = session as unknown as {
createRun: (owner: "foreground" | "autonomous", queue: unknown) => { id: string };
activeForegroundTurn: { runId: string; queue: unknown } | null;
runTracker: { getRun: (runId: string) => unknown; complete: (run: unknown, state: "completed") => void };
pushEvent: (event: AgentStreamEvent) => void;
};
const staleForegroundRun = internal.createRun("foreground", staleQueue);
const runRecord = internal.runTracker.getRun(staleForegroundRun.id);
internal.runTracker.complete(runRecord, "completed");
internal.activeForegroundTurn = {
runId: staleForegroundRun.id,
queue: staleQueue,
};
const liveIterator = (
session as unknown as {
streamLiveEvents: () => AsyncGenerator<AgentStreamEvent>;
}
).streamLiveEvents();
const permissionEvent: AgentStreamEvent = {
type: "permission_requested",
provider: "claude",
request: {
id: "permission-1",
provider: "claude",
name: "Bash",
kind: "tool",
},
};
internal.pushEvent(permissionEvent);
const next = await Promise.race([
liveIterator.next(),
new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("Timed out waiting for live side-channel event")), 1_000);
}),
]);
expect(next.done).toBe(false);
expect(next.value).toEqual(permissionEvent);
expect(staleQueueEvents).toHaveLength(0);
expect(internal.activeForegroundTurn).toBeNull();
await liveIterator.return?.();
await session.close();
});
test("tracks run lifecycle transitions for success, error, and interrupt", async () => {
const session = await createSession();
let streamCase: "success" | "error" | "interrupt" = "success";
@@ -636,7 +1252,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
await session.close();
});
test("surfaces autonomous running transitions through manager-visible events during foreground overlap", async () => {
test("surfaces autonomous running transitions during true overlap before foreground terminal result", async () => {
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
const readPromptUuid = createPromptUuidReader(prompt);
let step = 0;
@@ -674,7 +1290,8 @@ describe("ClaudeAgentSession redesign invariants", () => {
done: false,
value: {
type: "assistant",
message: { content: "FOREGROUND_DONE" },
task_id: "fg-1",
message: { content: "FOREGROUND_BEFORE_OVERLAP" },
},
};
}
@@ -683,10 +1300,12 @@ describe("ClaudeAgentSession redesign invariants", () => {
return {
done: false,
value: {
type: "result",
subtype: "success",
usage: buildUsage(),
total_cost_usd: 0,
type: "stream_event",
task_id: "bg-1",
event: {
type: "message_start",
message: { id: "bg-msg-1", role: "assistant", model: "opus" },
},
},
};
}
@@ -695,15 +1314,13 @@ describe("ClaudeAgentSession redesign invariants", () => {
return {
done: false,
value: {
type: "user",
message: {
role: "user",
content:
"<task-notification>\n<task-id>bg-1</task-id>\n<status>completed</status>\n</task-notification>",
type: "stream_event",
task_id: "bg-1",
event: {
type: "content_block_delta",
message_id: "bg-msg-1",
delta: { type: "text_delta", text: "AUTONOMOUS_OVERLAP_DONE" },
},
parent_tool_use_id: null,
uuid: "task-note-overlap-1",
session_id: "redesign-manager-overlap-session",
},
};
}
@@ -712,12 +1329,39 @@ describe("ClaudeAgentSession redesign invariants", () => {
return {
done: false,
value: {
type: "assistant",
message: { content: "AUTONOMOUS_OVERLAP_DONE" },
type: "stream_event",
task_id: "bg-1",
event: {
type: "message_stop",
message_id: "bg-msg-1",
},
},
};
}
if (step === 6) {
step += 1;
return {
done: false,
value: {
type: "result",
subtype: "success",
task_id: "bg-1",
usage: buildUsage(),
total_cost_usd: 0,
},
};
}
if (step === 7) {
step += 1;
return {
done: false,
value: {
type: "assistant",
message: { content: "FOREGROUND_DONE" },
},
};
}
if (step === 8) {
step += 1;
return {
done: false,
@@ -774,11 +1418,26 @@ describe("ClaudeAgentSession redesign invariants", () => {
expect(
foregroundEvents.some((event) => event.type === "turn_completed")
).toBe(true);
expect(
foregroundEvents.some(
(event) =>
event.type === "timeline" &&
event.item.type === "assistant_message" &&
event.item.text.includes("AUTONOMOUS_OVERLAP_DONE")
)
).toBe(false);
await waitForCondition(() => turnStartedEvents.length >= 2, 5_000);
await waitForCondition(() => turnStartedEvents.length >= 1, 5_000);
await waitForCondition(() => runningStateEvents.length >= 2, 5_000);
const timeline = manager.getTimeline(agent.id);
expect(
timeline.some(
(item) =>
item.type === "assistant_message" &&
item.text.includes("FOREGROUND_BEFORE_OVERLAP")
)
).toBe(true);
expect(
timeline.some(
(item) =>

View File

@@ -1394,6 +1394,56 @@ describe("convertClaudeHistoryEntry", () => {
]);
});
test("supports compact boundary metadata shape variants", () => {
const fixtures = [
{
entry: {
type: "system",
subtype: "compact_boundary",
compactMetadata: { trigger: "manual", preTokens: 12 },
},
expected: {
trigger: "manual",
preTokens: 12,
},
},
{
entry: {
type: "system",
subtype: "compact_boundary",
compact_metadata: { trigger: "manual", pre_tokens: 34 },
},
expected: {
trigger: "manual",
preTokens: 34,
},
},
{
entry: {
type: "system",
subtype: "compact_boundary",
compactionMetadata: { trigger: "auto", preTokens: 56 },
},
expected: {
trigger: "auto",
preTokens: 56,
},
},
] as const;
for (const fixture of fixtures) {
const result = convertClaudeHistoryEntry(fixture.entry, () => []);
expect(result).toEqual([
{
type: "compaction",
status: "completed",
trigger: fixture.expected.trigger,
preTokens: fixture.expected.preTokens,
},
]);
}
});
test("skips isCompactSummary user entries", () => {
const entry = {
type: "user",

View File

@@ -84,8 +84,10 @@ type SessionLifecycleState = "running" | "permission" | "error" | "idle";
type RoutingReason =
| "foreground"
| "task_id"
| "task_id_new"
| "parent_message_id"
| "message_id"
| "unbound_autonomous"
| "fallback"
| "metadata";
@@ -259,6 +261,7 @@ const REWIND_COMMAND: AgentSlashCommand = {
description: "Rewind tracked files to a previous user message",
argumentHint: "[user_message_uuid]",
};
const INTERRUPT_TOOL_USE_PLACEHOLDER = "[Request interrupted by user for tool use]";
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -328,6 +331,201 @@ function resolveClaudeSpawnCommand(
};
}
function applyRuntimeSettingsToClaudeOptions(
options: ClaudeOptions,
runtimeSettings?: ProviderRuntimeSettings
): ClaudeOptions {
const hasEnvOverrides = Object.keys(runtimeSettings?.env ?? {}).length > 0;
const commandMode = runtimeSettings?.command?.mode;
const needsCustomSpawn =
hasEnvOverrides || commandMode === "append" || commandMode === "replace";
if (!needsCustomSpawn) {
return options;
}
return {
...options,
spawnClaudeCodeProcess: (spawnOptions) => {
const resolved = resolveClaudeSpawnCommand(
spawnOptions,
runtimeSettings
);
return spawn(resolved.command, resolved.args, {
cwd: spawnOptions.cwd,
env: applyProviderEnv(spawnOptions.env, runtimeSettings),
signal: spawnOptions.signal,
stdio: ["pipe", "pipe", "pipe"],
});
},
};
}
type ClaudeOptionsLogSummary = {
cwd: string | null;
permissionMode: string | null;
model: string | null;
includePartialMessages: boolean;
settingSources: string[];
enableFileCheckpointing: boolean;
hasResume: boolean;
maxThinkingTokens: number | null;
hasEnv: boolean;
envKeyCount: number;
hasMcpServers: boolean;
mcpServerNames: string[];
systemPromptMode: "none" | "string" | "preset" | "custom";
systemPromptPreset: string | null;
hasCanUseTool: boolean;
hasSpawnOverride: boolean;
hasStderrHandler: boolean;
};
function summarizeClaudeOptionsForLog(options: ClaudeOptions): ClaudeOptionsLogSummary {
const systemPromptRaw = options.systemPrompt;
const systemPromptSummary = (() => {
if (!systemPromptRaw) {
return { mode: "none" as const, preset: null };
}
if (typeof systemPromptRaw === "string") {
return { mode: "string" as const, preset: null };
}
const prompt = systemPromptRaw as Record<string, unknown>;
const promptType =
typeof prompt.type === "string" ? prompt.type : "custom";
return {
mode:
promptType === "preset"
? ("preset" as const)
: ("custom" as const),
preset:
typeof prompt.preset === "string" && prompt.preset.length > 0
? prompt.preset
: null,
};
})();
const mcpServerNames = options.mcpServers
? Object.keys(options.mcpServers).sort()
: [];
return {
cwd: typeof options.cwd === "string" ? options.cwd : null,
permissionMode:
typeof options.permissionMode === "string"
? options.permissionMode
: null,
model: typeof options.model === "string" ? options.model : null,
includePartialMessages: options.includePartialMessages === true,
settingSources: Array.isArray(options.settingSources)
? options.settingSources
: [],
enableFileCheckpointing: options.enableFileCheckpointing === true,
hasResume: typeof options.resume === "string" && options.resume.length > 0,
maxThinkingTokens:
typeof options.maxThinkingTokens === "number"
? options.maxThinkingTokens
: null,
hasEnv: !!options.env,
envKeyCount: Object.keys(options.env ?? {}).length,
hasMcpServers: mcpServerNames.length > 0,
mcpServerNames,
systemPromptMode: systemPromptSummary.mode,
systemPromptPreset: systemPromptSummary.preset,
hasCanUseTool: typeof options.canUseTool === "function",
hasSpawnOverride: typeof options.spawnClaudeCodeProcess === "function",
hasStderrHandler: typeof options.stderr === "function",
};
}
function isToolResultTextBlock(
value: unknown
): value is { type: "text"; text: string } {
return (
!!value &&
typeof value === "object" &&
(value as { type?: unknown }).type === "text" &&
typeof (value as { text?: unknown }).text === "string"
);
}
function normalizeForDeterministicString(
value: unknown,
seen: WeakSet<object>
): unknown {
if (
value === null ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
return value;
}
if (typeof value === "bigint") {
return value.toString();
}
if (typeof value === "function") {
return "[function]";
}
if (typeof value === "symbol") {
return value.toString();
}
if (typeof value === "undefined") {
return "[undefined]";
}
if (Array.isArray(value)) {
return value.map((entry) =>
normalizeForDeterministicString(entry, seen)
);
}
if (typeof value === "object") {
const objectValue = value as object;
if (seen.has(objectValue)) {
return "[circular]";
}
seen.add(objectValue);
const record = value as Record<string, unknown>;
const normalized: Record<string, unknown> = {};
for (const key of Object.keys(record).sort()) {
normalized[key] = normalizeForDeterministicString(record[key], seen);
}
seen.delete(objectValue);
return normalized;
}
return String(value);
}
function deterministicStringify(value: unknown): string {
if (typeof value === "undefined") {
return "";
}
try {
const normalized = normalizeForDeterministicString(
value,
new WeakSet<object>()
);
if (typeof normalized === "string") {
return normalized;
}
return JSON.stringify(normalized);
} catch {
try {
return String(value);
} catch {
return "[unserializable]";
}
}
}
function coerceToolResultContentToString(content: unknown): string {
if (typeof content === "string") {
return content;
}
if (Array.isArray(content) && content.every((block) => isToolResultTextBlock(block))) {
return content.map((block) => block.text).join("");
}
return deterministicStringify(content);
}
export function extractUserMessageText(content: unknown): string | null {
if (typeof content === "string") {
@@ -636,6 +834,27 @@ class RunTracker {
return false;
}
getLatestActiveRun(owner?: RunOwner): RunRecord | null {
let latest: RunRecord | null = null;
for (const run of this.runs.values()) {
if (!this.isActive(run.state)) {
continue;
}
if (owner && run.owner !== owner) {
continue;
}
latest = run;
}
return latest;
}
isRunActive(run: RunRecord | null): boolean {
if (!run) {
return false;
}
return this.isActive(run.state);
}
resolveByIdentifiers(identifiers: EventIdentifiers): RunRoute {
if (identifiers.taskId) {
const run = this.resolveMappedRun(this.runByTaskId, identifiers.taskId);
@@ -933,7 +1152,7 @@ class TimelineAssembler {
const nextAssistantText = state.assistantText.slice(state.emittedAssistantLength);
if (
nextAssistantText.length > 0 &&
nextAssistantText !== "[Request interrupted by user for tool use]"
nextAssistantText !== INTERRUPT_TOOL_USE_PLACEHOLDER
) {
state.emittedAssistantLength = state.assistantText.length;
items.push({ type: "assistant_message", text: nextAssistantText });
@@ -1076,17 +1295,22 @@ function readEventIdentifiers(message: SDKMessage): EventIdentifiers {
taskId:
readTrimmedString(root.task_id) ??
readTrimmedString(streamEvent?.task_id) ??
readTrimmedString(streamEventMessage?.task_id) ??
readTrimmedString(messageContainer?.task_id) ??
null,
parentMessageId:
readTrimmedString(root.parent_message_id) ??
readTrimmedString(streamEvent?.parent_message_id) ??
readTrimmedString(streamEventMessage?.parent_message_id) ??
readTrimmedString(messageContainer?.parent_message_id) ??
null,
messageId:
readTrimmedString(root.message_id) ??
readTrimmedString(streamEvent?.message_id) ??
readTrimmedString(streamEventMessage?.id) ??
readTrimmedString(streamEventMessage?.message_id) ??
readTrimmedString(messageContainer?.id) ??
readTrimmedString(messageContainer?.message_id) ??
(messageType === "user" ? readTrimmedString(root.uuid) : null) ??
null,
};
@@ -1133,30 +1357,10 @@ export class ClaudeAgentClient implements AgentClient {
}
private applyRuntimeSettings(options: ClaudeOptions): ClaudeOptions {
const hasEnvOverrides = Object.keys(this.runtimeSettings?.env ?? {}).length > 0;
const commandMode = this.runtimeSettings?.command?.mode;
const needsCustomSpawn =
hasEnvOverrides || commandMode === "append" || commandMode === "replace";
if (!needsCustomSpawn) {
return options;
}
return {
...options,
spawnClaudeCodeProcess: (spawnOptions) => {
const resolved = resolveClaudeSpawnCommand(
spawnOptions,
this.runtimeSettings
);
return spawn(resolved.command, resolved.args, {
cwd: spawnOptions.cwd,
env: applyProviderEnv(spawnOptions.env, this.runtimeSettings),
signal: spawnOptions.signal,
stdio: ["pipe", "pipe", "pipe"],
});
},
};
return applyRuntimeSettingsToClaudeOptions(
options,
this.runtimeSettings
);
}
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
@@ -1294,6 +1498,8 @@ class ClaudeAgentSession implements AgentSession {
private persistedHistory: AgentTimelineItem[] = [];
private historyPending = false;
private turnState: TurnState = "idle";
private preReplayMetadataSeen = false;
private pendingAutonomousWakeReservations = 0;
private nextRunOrdinal = 1;
private cancelCurrentTurn: (() => void) | null = null;
private pendingInterruptPromise: Promise<void> | null = null;
@@ -1415,6 +1621,7 @@ class ClaudeAgentSession implements AgentSession {
this.cancelCurrentTurn();
}
this.suppressLocalReplayActivity = false;
this.pendingAutonomousWakeReservations = 0;
const slashCommand = this.resolveSlashCommandInvocation(prompt);
if (slashCommand?.commandName === REWIND_COMMAND_NAME) {
@@ -1443,6 +1650,7 @@ class ClaudeAgentSession implements AgentSession {
queue,
};
this.activeForegroundTurn = foregroundTurn;
this.preReplayMetadataSeen = false;
this.transitionTurnState("foreground", "foreground stream started");
let finishedNaturally = false;
@@ -1490,13 +1698,15 @@ class ClaudeAgentSession implements AgentSession {
try {
for await (const event of queue) {
yield event;
if (
const isTerminalEvent =
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled"
) {
event.type === "turn_canceled";
if (isTerminalEvent) {
finishedNaturally = true;
}
yield event;
if (isTerminalEvent) {
break;
}
}
@@ -1701,6 +1911,7 @@ class ClaudeAgentSession implements AgentSession {
this.cancelCurrentTurn = null;
this.turnState = "idle";
this.suppressLocalReplayActivity = false;
this.pendingAutonomousWakeReservations = 0;
this.liveEventQueue.end();
this.activeTurnPromise = null;
this.input?.end();
@@ -2017,11 +2228,18 @@ class ClaudeAgentSession implements AgentSession {
const input = new Pushable<SDKUserMessage>();
const options = this.buildOptions();
this.logger.debug({ options }, "claude query");
this.logger.debug(
{ options: summarizeClaudeOptionsForLog(options) },
"claude query"
);
this.input = input;
this.query = query({ prompt: input, options });
await this.primeSelectableModelIds(this.query);
await this.query.setPermissionMode(this.currentMode);
// Do not block query readiness on control-plane calls. We need `next()` to
// start immediately so autonomous wake events are not missed between turns.
void this.awaitWithTimeout(
this.primeSelectableModelIds(this.query),
"prime selectable model ids"
);
return this.query;
}
@@ -2112,30 +2330,10 @@ class ClaudeAgentSession implements AgentSession {
}
private applyRuntimeSettings(options: ClaudeOptions): ClaudeOptions {
const hasEnvOverrides = Object.keys(this.runtimeSettings?.env ?? {}).length > 0;
const commandMode = this.runtimeSettings?.command?.mode;
const needsCustomSpawn =
hasEnvOverrides || commandMode === "append" || commandMode === "replace";
if (!needsCustomSpawn) {
return options;
}
return {
...options,
spawnClaudeCodeProcess: (spawnOptions) => {
const resolved = resolveClaudeSpawnCommand(
spawnOptions,
this.runtimeSettings
);
return spawn(resolved.command, resolved.args, {
cwd: spawnOptions.cwd,
env: applyProviderEnv(spawnOptions.env, this.runtimeSettings),
signal: spawnOptions.signal,
stdio: ["pipe", "pipe", "pipe"],
});
},
};
return applyRuntimeSettingsToClaudeOptions(
options,
this.runtimeSettings
);
}
private normalizeMcpServers(
@@ -2280,6 +2478,7 @@ class ClaudeAgentSession implements AgentSession {
if (this.activeForegroundTurn?.runId === run.id) {
this.activeForegroundTurn = null;
this.preReplayMetadataSeen = false;
}
this.transitionTurnStateFromActiveRuns(`run ${run.id} terminal`);
this.logDerivedSessionLifecycle("terminal_event");
@@ -2326,18 +2525,65 @@ class ClaudeAgentSession implements AgentSession {
metadataOnly: boolean;
}
): RunRoute {
if (normalized.metadataOnly) {
if (
normalized.message.type === "user" &&
isTaskNotificationUserContent(normalized.message.message?.content)
) {
this.reserveAutonomousWake("task_notification");
}
this.notePreReplayMetadata(normalized.message);
return { run: null, reason: "metadata" };
}
const hasIdentifiers = Boolean(
normalized.identifiers.taskId ||
normalized.identifiers.parentMessageId ||
normalized.identifiers.messageId
);
const byIdentifiers = this.runTracker.resolveByIdentifiers(normalized.identifiers);
if (byIdentifiers.run) {
return byIdentifiers;
}
if (this.turnState === "autonomous") {
const activeAutonomousRun = this.runTracker.getLatestActiveRun("autonomous");
if (activeAutonomousRun) {
return { run: activeAutonomousRun, reason: "unbound_autonomous" };
}
}
const foregroundRun = this.activeForegroundTurn
? this.runTracker.getRun(this.activeForegroundTurn.runId)
: null;
// A previously unseen task_id during foreground ownership is deterministic
// evidence of a distinct autonomous wake/run, not foreground response text.
if (
this.turnState === "foreground" &&
foregroundRun &&
normalized.identifiers.taskId
) {
const incomingTaskId = normalized.identifiers.taskId;
// Foreground must claim its first task_id; otherwise early foreground
// result events can be misrouted to autonomous fallback runs.
if (foregroundRun.taskIds.size === 0) {
if (foregroundRun.state !== "finalizing") {
return { run: foregroundRun, reason: "foreground" };
}
} else if (foregroundRun.taskIds.has(incomingTaskId)) {
return { run: foregroundRun, reason: "foreground" };
}
const autonomousRun = this.createRun("autonomous", null);
this.emitRunEvent(autonomousRun, { type: "turn_started", provider: "claude" });
return { run: autonomousRun, reason: "task_id_new" };
}
if (
this.turnState === "foreground" &&
foregroundRun &&
!normalized.metadataOnly &&
this.shouldPreferForegroundRun({
run: foregroundRun,
message: normalized.message,
@@ -2346,8 +2592,27 @@ class ClaudeAgentSession implements AgentSession {
return { run: foregroundRun, reason: "foreground" };
}
if (normalized.metadataOnly) {
return { run: null, reason: "metadata" };
if (!hasIdentifiers) {
if (this.pendingAutonomousWakeReservations > 0) {
const reservedAutonomousRun = this.claimOrCreateAutonomousRun(
"reservation_unbound"
);
return {
run: reservedAutonomousRun,
reason: "unbound_autonomous",
};
}
const activeAutonomousRun = this.runTracker.getLatestActiveRun("autonomous");
if (activeAutonomousRun) {
return { run: activeAutonomousRun, reason: "unbound_autonomous" };
}
}
if (this.pendingAutonomousWakeReservations > 0) {
const reservedAutonomousRun = this.claimOrCreateAutonomousRun(
"reservation_fallback"
);
return { run: reservedAutonomousRun, reason: "fallback" };
}
const autonomousRun = this.createRun("autonomous", null);
@@ -2368,8 +2633,23 @@ class ClaudeAgentSession implements AgentSession {
return false;
}
// Before prompt replay is observed, prefer foreground by default so the
// first turn cannot be stranded in autonomous fallback. If metadata churn
// was observed pre-replay, stay conservative and wait for replay.
if (!run.promptReplaySeen) {
return false;
// Keep pre-replay result events with the foreground run so stale result
// bursts cannot consume autonomous wake reservations.
if (message.type === "result") {
return true;
}
if (
message.type === "assistant" ||
message.type === "stream_event" ||
message.type === "tool_progress"
) {
return !this.preReplayMetadataSeen;
}
return true;
}
if (
@@ -2382,6 +2662,66 @@ class ClaudeAgentSession implements AgentSession {
return true;
}
private notePreReplayMetadata(message: SDKMessage): void {
if (this.turnState !== "foreground") {
return;
}
const foregroundRun = this.activeForegroundTurn
? this.runTracker.getRun(this.activeForegroundTurn.runId)
: null;
if (!foregroundRun || foregroundRun.promptReplaySeen) {
return;
}
if (message.type === "system" && message.subtype === "init") {
return;
}
this.preReplayMetadataSeen = true;
}
private reserveAutonomousWake(reason: string): void {
this.pendingAutonomousWakeReservations += 1;
this.logger.debug(
{
reason,
pendingAutonomousWakeReservations: this.pendingAutonomousWakeReservations,
},
"Reserved autonomous wake"
);
}
private claimOrCreateAutonomousRun(reason: string): RunRecord {
const existing = this.runTracker.getLatestActiveRun("autonomous");
if (existing) {
if (this.pendingAutonomousWakeReservations > 0) {
this.pendingAutonomousWakeReservations -= 1;
}
this.logger.debug(
{
reason,
runId: existing.id,
pendingAutonomousWakeReservations: this.pendingAutonomousWakeReservations,
},
"Claimed autonomous wake reservation on existing run"
);
return existing;
}
const run = this.createRun("autonomous", null);
this.emitRunEvent(run, { type: "turn_started", provider: "claude" });
if (this.pendingAutonomousWakeReservations > 0) {
this.pendingAutonomousWakeReservations -= 1;
}
this.logger.debug(
{
reason,
runId: run.id,
pendingAutonomousWakeReservations: this.pendingAutonomousWakeReservations,
},
"Claimed autonomous wake reservation with new run"
);
return run;
}
private startQueryPump(): void {
if (this.closed || this.queryPumpPromise) {
return;
@@ -2478,10 +2818,16 @@ class ClaudeAgentSession implements AgentSession {
identifiers,
metadataOnly,
});
const suppressTerminalEvents = this.shouldSuppressReplayResultTerminal({
run: route.run,
message,
});
if (route.run) {
this.transitionTurnStateFromActiveRuns(`routed via ${route.reason}`);
this.runTracker.bindIdentifiers(route.run, identifiers);
this.updateRunLifecycleForMessage(route.run, message, identifiers);
if (!suppressTerminalEvents) {
this.updateRunLifecycleForMessage(route.run, message, identifiers);
}
}
this.logger.debug(
@@ -2498,6 +2844,7 @@ class ClaudeAgentSession implements AgentSession {
const messageEvents = this.translateMessageToEvents(message, {
suppressAssistantText: true,
suppressReasoning: true,
suppressTerminalEvents,
});
const assistantTimelineItems = this.timelineAssembler.consume({
message,
@@ -2527,6 +2874,37 @@ class ClaudeAgentSession implements AgentSession {
}
}
private shouldSuppressReplayResultTerminal(input: {
run: RunRecord | null;
message: SDKMessage;
}): boolean {
const { run, message } = input;
if (!run || run.owner !== "foreground" || message.type !== "result") {
return false;
}
if (run.promptReplaySeen) {
return false;
}
if (run.state === "streaming" || run.state === "finalizing") {
return false;
}
const resultSubtype =
"subtype" in message && typeof message.subtype === "string"
? message.subtype
: null;
// Pre-replay success results are stale in practice (leftover from an
// earlier query segment) and must not end the current foreground run.
if (resultSubtype === "success") {
return true;
}
// For non-success results, keep the metadata-churn guard to avoid
// suppressing legitimate hard failures.
return this.preReplayMetadataSeen;
}
private dispatchMetadataEvents(events: AgentStreamEvent[]): void {
for (const event of events) {
this.pushEvent(event);
@@ -2544,6 +2922,7 @@ class ClaudeAgentSession implements AgentSession {
run.messageIds.has(identifiers.messageId)
) {
run.promptReplaySeen = true;
this.preReplayMetadataSeen = false;
}
if (run.state === "queued") {
@@ -2584,12 +2963,36 @@ class ClaudeAgentSession implements AgentSession {
return false;
}
if (message.type === "user" && !localReplay) {
// Suppress only replay scaffolding. Do not suppress autonomous
// assistant/result events; otherwise task-notification replies can be dropped.
if (localReplay) {
return true;
}
if (message.type === "system") {
return true;
}
const identifiers = readEventIdentifiers(message);
const hasIdentifiers = Boolean(
identifiers.taskId || identifiers.parentMessageId || identifiers.messageId
);
if (message.type !== "user" && !hasIdentifiers) {
if (this.pendingAutonomousWakeReservations > 0) {
this.suppressLocalReplayActivity = false;
return false;
}
return true;
}
if (message.type === "user") {
this.suppressLocalReplayActivity = false;
return false;
}
return true;
this.suppressLocalReplayActivity = false;
return false;
}
private isLocalReplayUserMessage(message: SDKMessage): boolean {
@@ -2608,22 +3011,22 @@ class ClaudeAgentSession implements AgentSession {
private async interruptActiveTurn(): Promise<void> {
const queryToInterrupt = this.query;
if (!queryToInterrupt || typeof queryToInterrupt.interrupt !== "function") {
this.logger.info("interruptActiveTurn: no query to interrupt");
this.logger.debug("interruptActiveTurn: no query to interrupt");
return;
}
try {
this.logger.info("interruptActiveTurn: calling query.interrupt()...");
this.logger.debug("interruptActiveTurn: calling query.interrupt()...");
const t0 = Date.now();
await queryToInterrupt.interrupt();
this.logger.info({ durationMs: Date.now() - t0 }, "interruptActiveTurn: query.interrupt() returned");
this.logger.debug({ durationMs: Date.now() - t0 }, "interruptActiveTurn: query.interrupt() returned");
// After interrupt(), the query iterator is done (returns done: true).
// Clear it so ensureQuery() creates a fresh query for the next turn.
// Also end the input stream and call return() to clean up the SDK process.
this.input?.end();
this.logger.info("interruptActiveTurn: calling query.return()...");
this.logger.debug("interruptActiveTurn: calling query.return()...");
const t1 = Date.now();
await queryToInterrupt.return?.();
this.logger.info({ durationMs: Date.now() - t1 }, "interruptActiveTurn: query.return() returned");
this.logger.debug({ durationMs: Date.now() - t1 }, "interruptActiveTurn: query.return() returned");
this.query = null;
this.input = null;
this.queryRestartNeeded = false;
@@ -2906,6 +3309,7 @@ class ClaudeAgentSession implements AgentSession {
options?: {
suppressAssistantText?: boolean;
suppressReasoning?: boolean;
suppressTerminalEvents?: boolean;
}
): AgentStreamEvent[] {
const parentToolUseId = "parent_tool_use_id" in message
@@ -2947,15 +3351,17 @@ class ClaudeAgentSession implements AgentSession {
});
}
} else if (message.subtype === "compact_boundary") {
const meta = (message as Record<string, unknown>).compact_metadata as
{ trigger?: string; pre_tokens?: number } | undefined;
const compactMetadata = readCompactionMetadata(
message as Record<string, unknown>
);
events.push({
type: "timeline",
item: {
type: "compaction",
status: "completed",
trigger: meta?.trigger === "manual" ? "manual" : "auto",
preTokens: meta?.pre_tokens,
trigger:
compactMetadata?.trigger === "manual" ? "manual" : "auto",
preTokens: compactMetadata?.preTokens,
},
provider: "claude",
});
@@ -3020,6 +3426,9 @@ class ClaudeAgentSession implements AgentSession {
break;
}
case "result": {
if (options?.suppressTerminalEvents) {
break;
}
const usage = this.convertUsage(message);
if (message.subtype === "success") {
events.push({ type: "turn_completed", provider: "claude", usage });
@@ -3277,9 +3686,19 @@ class ClaudeAgentSession implements AgentSession {
}
private pushEvent(event: AgentStreamEvent) {
if (this.activeForegroundTurn) {
this.activeForegroundTurn.queue.push(event);
return;
const foregroundTurn = this.activeForegroundTurn;
if (foregroundTurn) {
const run = this.runTracker.getRun(foregroundTurn.runId);
if (
run &&
run.owner === "foreground" &&
run.queue === foregroundTurn.queue &&
this.runTracker.isRunActive(run)
) {
foregroundTurn.queue.push(event);
return;
}
this.activeForegroundTurn = null;
}
this.liveEventQueue.push(event);
}
@@ -3369,7 +3788,7 @@ class ClaudeAgentSession implements AgentSession {
const suppressReasoning = options?.suppressReasoning ?? false;
if (typeof content === "string") {
if (!content || content === "[Request interrupted by user for tool use]") {
if (!content || content === INTERRUPT_TOOL_USE_PLACEHOLDER) {
return [];
}
if (suppressAssistant) {
@@ -3383,7 +3802,7 @@ class ClaudeAgentSession implements AgentSession {
switch (block.type) {
case "text":
case "text_delta":
if (block.text && block.text !== "[Request interrupted by user for tool use]") {
if (block.text && block.text !== INTERRUPT_TOOL_USE_PLACEHOLDER) {
if (!suppressAssistant) {
items.push({ type: "assistant_message", text: block.text });
}
@@ -3491,7 +3910,7 @@ class ClaudeAgentSession implements AgentSession {
const server = entry?.server ?? block.server ?? "tool";
const tool = entry?.name ?? block.tool_name ?? "tool";
const content = typeof block.content === "string" ? block.content : "";
const content = coerceToolResultContentToString(block.content);
const input = entry?.input;
// Build structured result based on tool type
@@ -3859,6 +4278,29 @@ function hasToolLikeBlock(block?: ClaudeContentChunk | null): boolean {
return type.includes("tool");
}
function readCompactionMetadata(
source: Record<string, unknown>
): { trigger?: string; preTokens?: number } | null {
const candidates = [
source.compact_metadata,
source.compactMetadata,
source.compactionMetadata,
];
for (const candidate of candidates) {
if (!candidate || typeof candidate !== "object") {
continue;
}
const metadata = candidate as Record<string, unknown>;
const trigger =
typeof metadata.trigger === "string" ? metadata.trigger : undefined;
const preTokensRaw = metadata.preTokens ?? metadata.pre_tokens;
const preTokens =
typeof preTokensRaw === "number" ? preTokensRaw : undefined;
return { trigger, preTokens };
}
return null;
}
function normalizeHistoryBlocks(content: unknown): ClaudeContentChunk[] | null {
if (Array.isArray(content)) {
const blocks = content.filter((entry) => isClaudeContentChunk(entry));
@@ -3875,11 +4317,14 @@ export function convertClaudeHistoryEntry(
mapBlocks: (content: string | ClaudeContentChunk[]) => AgentTimelineItem[]
): AgentTimelineItem[] {
if (entry.type === "system" && entry.subtype === "compact_boundary") {
const compactMetadata = readCompactionMetadata(
entry as Record<string, unknown>
);
return [{
type: "compaction",
status: "completed",
trigger: entry.compactMetadata?.trigger === "manual" ? "manual" : "auto",
preTokens: entry.compactMetadata?.preTokens,
trigger: compactMetadata?.trigger === "manual" ? "manual" : "auto",
preTokens: compactMetadata?.preTokens,
}];
}

View File

@@ -19,6 +19,14 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const BACKGROUND_TASK_SLEEP_5_PROMPT = [
"Use the Task tool (not a Bash background process).",
"Start a background task that runs exactly: sleep 5",
"Do not wait for the task result.",
"Immediately reply with exactly: SPAWNED",
].join(" ");
const BACKGROUND_TASK_SLEEP_5_REPEAT_PROMPT = `do it again: ${BACKGROUND_TASK_SLEEP_5_PROMPT}`;
function sanitizeClaudeProjectPath(cwd: string): string {
return cwd.replace(/[\\/\.]/g, "-").replace(/_/g, "-");
}
@@ -133,6 +141,19 @@ function readAssistantText(line: ClaudeTranscriptLine): string {
.trim();
}
function readAssistantModel(line: ClaudeTranscriptLine): string {
const message = line.message as
| {
model?: unknown;
}
| null
| undefined;
if (!message || typeof message !== "object") {
return "";
}
return typeof message.model === "string" ? message.model : "";
}
function parseTimestamp(line: ClaudeTranscriptLine): number {
const timestamp = line.timestamp;
if (typeof timestamp !== "string") {
@@ -155,9 +176,13 @@ function extractTranscriptRaceEvidence(
const doItAgain = [...userLines]
.reverse()
.find((line) => readUserText(line).toLowerCase().includes("do it again"));
const isHelloPrompt = (line: ClaudeTranscriptLine): boolean => {
const text = readUserText(line).trim().toLowerCase();
return text === "hello" || text === "say exactly hello";
};
const helloUser = [...userLines]
.reverse()
.find((line) => readUserText(line).trim().toLowerCase() === "hello");
.find((line) => isHelloPrompt(line));
if (!doItAgain || !helloUser || typeof helloUser.uuid !== "string") {
return null;
@@ -174,21 +199,41 @@ function extractTranscriptRaceEvidence(
}
const doItAgainTs = parseTimestamp(doItAgain);
const taskNotificationUser = userLines.find((line) => {
if (parseTimestamp(line) <= doItAgainTs) {
const helloTs = parseTimestamp(helloUser);
const taskNotificationCandidates = userLines.filter((line) => {
const lineTs = parseTimestamp(line);
if (lineTs <= doItAgainTs) {
return false;
}
return readUserText(line).includes("<task-notification>");
});
const taskNotificationUser = [...taskNotificationCandidates]
.reverse()
.find((line) => parseTimestamp(line) >= helloTs) ?? taskNotificationCandidates[0];
if (!taskNotificationUser || typeof taskNotificationUser.uuid !== "string") {
return null;
}
const helloAssistantUuid =
typeof helloAssistant.uuid === "string" ? helloAssistant.uuid : null;
const taskNotificationTs = parseTimestamp(taskNotificationUser);
const notificationOutcomeAssistant = [...assistantLines]
.sort((a, b) => parseTimestamp(a) - parseTimestamp(b))
.find((line) => {
const sortedAssistantLines = [...assistantLines].sort(
(a, b) => parseTimestamp(a) - parseTimestamp(b)
);
const byTaskNotificationParent = sortedAssistantLines.find((line) => {
const lineTs = parseTimestamp(line);
if (lineTs < taskNotificationTs) {
return false;
}
if (line.parentUuid !== taskNotificationUser.uuid) {
return false;
}
if (readAssistantModel(line) === "<synthetic>") {
return false;
}
return readAssistantText(line).length > 0;
});
const notificationOutcomeAssistant = byTaskNotificationParent ??
sortedAssistantLines.find((line) => {
const lineTs = parseTimestamp(line);
if (lineTs < taskNotificationTs) {
return false;
@@ -197,6 +242,9 @@ function extractTranscriptRaceEvidence(
if (helloAssistantUuid && lineUuid === helloAssistantUuid) {
return false;
}
if (readAssistantModel(line) === "<synthetic>") {
return false;
}
return readAssistantText(line).length > 0;
});
if (!notificationOutcomeAssistant) {
@@ -213,6 +261,10 @@ function normalizeText(value: string): string {
return value.replace(/\s+/g, " ").trim().toLowerCase();
}
function compactText(value: string): string {
return normalizeText(value).replace(/\s+/g, "");
}
function summarizeTranscriptTail(lines: ClaudeTranscriptLine[], limit = 14): string {
const tail = lines.slice(-limit);
return tail
@@ -310,6 +362,200 @@ function summarizeTimelineEntry(entry: {
}
describe("daemon E2E (real claude) - autonomous wake from background task", () => {
test.runIf(isCommandAvailable("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,
});
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 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",
});
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
);
test.runIf(isCommandAvailable("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,
});
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");
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");
} finally {
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
},
420_000
);
test.runIf(isCommandAvailable("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 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");
await client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
30_000
);
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 finalSnapshot = await client.fetchAgent(agent.id);
expect(finalSnapshot?.status).toBe("idle");
} finally {
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
},
600_000
);
test.runIf(isCommandAvailable("claude"))(
"returns to running after background sleep completes without a second prompt",
async () => {
@@ -411,7 +657,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
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?.trim()).toBe("HELLO");
expect((secondFinish.lastMessage ?? "").toUpperCase()).toContain("HELLO");
} finally {
await client.close();
await daemon.close();
@@ -448,7 +694,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
for (let cycle = 0; cycle < 20; cycle += 1) {
const noise = runPreHelloNoise({ wsUrl, durationMs: 9_000 });
await client.sendMessage(agent.id, "sleep for 5, in the background");
await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_PROMPT);
const firstKickoff = await client.waitForFinish(agent.id, 180_000);
expect(firstKickoff.status).toBe("idle");
@@ -460,14 +706,14 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
const firstCompletion = await client.waitForFinish(agent.id, 90_000);
expect(firstCompletion.status).toBe("idle");
await client.sendMessage(agent.id, "do it again");
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, "hello");
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 ?? "").toLowerCase()).toContain("hello");
expect((helloReply.lastMessage ?? "").trim().toUpperCase()).toContain("HELLO");
const runningSnapshot = await client.waitForAgentUpsert(
agent.id,
@@ -496,11 +742,25 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
limit: 0,
projection: "canonical",
});
expect(timelineAfterWake.entries.length).toBeGreaterThan(
expect(timelineAfterWake.entries.length).toBeGreaterThanOrEqual(
timelineAtWake.entries.length
);
const secondCompletion = await client.waitForFinish(agent.id, 20_000);
let secondCompletion;
try {
secondCompletion = await client.waitForFinish(agent.id, 20_000);
} catch {
const atTimeout = await client.fetchAgent(agent.id);
// eslint-disable-next-line no-console
console.log(
JSON.stringify({
cycle,
phase: "second_completion_timeout",
statusAtTimeout: atTimeout?.status ?? "unknown",
})
);
secondCompletion = await client.waitForFinish(agent.id, 30_000);
}
expect(secondCompletion.status).toBe("idle");
await noise;
@@ -537,7 +797,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
...getFullAccessConfig("claude"),
});
await client.sendMessage(agent.id, "sleep for 5, in the background");
await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_PROMPT);
const firstKickoff = await client.waitForFinish(agent.id, 180_000);
expect(firstKickoff.status).toBe("idle");
@@ -549,14 +809,14 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
const firstCompletion = await client.waitForFinish(agent.id, 60_000);
expect(firstCompletion.status).toBe("idle");
await client.sendMessage(agent.id, "do it again");
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, "hello");
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 ?? "").toLowerCase()).toContain("hello");
expect((helloReply.lastMessage ?? "").trim().toUpperCase()).toContain("HELLO");
await client.waitForAgentUpsert(
agent.id,
@@ -566,7 +826,20 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
// This is the failure point seen in production: agent flips to running
// on task completion and never settles back to idle.
const secondCompletion = await client.waitForFinish(agent.id, 20_000);
let secondCompletion;
try {
secondCompletion = await client.waitForFinish(agent.id, 20_000);
} catch {
const atTimeout = await client.fetchAgent(agent.id);
// eslint-disable-next-line no-console
console.log(
JSON.stringify({
phase: "second_background_completion_timeout",
statusAtTimeout: atTimeout?.status ?? "unknown",
})
);
secondCompletion = await client.waitForFinish(agent.id, 30_000);
}
expect(secondCompletion.status).toBe("idle");
} finally {
await client.close();
@@ -600,14 +873,14 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
...getFullAccessConfig("claude"),
});
for (let cycle = 0; cycle < 60; cycle += 1) {
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 1",
"Start a background task that runs exactly: sleep 3",
"Do not wait for the task result.",
"Immediately reply with exactly: SPAWNED",
].join(" ")
@@ -650,7 +923,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
limit: 0,
projection: "canonical",
});
expect(timelineAfterWake.entries.length).toBeGreaterThan(
expect(timelineAfterWake.entries.length).toBeGreaterThanOrEqual(
timelineAtWake.entries.length
);
@@ -690,7 +963,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
...getFullAccessConfig("claude"),
});
await client.sendMessage(agent.id, "sleep for 5, in the background");
await client.sendMessage(agent.id, BACKGROUND_TASK_SLEEP_5_PROMPT);
const firstKickoff = await client.waitForFinish(agent.id, 180_000);
expect(firstKickoff.status).toBe("idle");
@@ -702,7 +975,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
const firstAutonomousCompletion = await client.waitForFinish(agent.id, 60_000);
expect(firstAutonomousCompletion.status).toBe("idle");
await client.sendMessage(agent.id, "do it again");
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");
@@ -712,7 +985,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
30_000
);
await sleep(500);
await client.sendMessage(agent.id, "hello");
await client.sendMessage(agent.id, "say exactly HELLO");
let waitError: Error | null = null;
try {
@@ -774,32 +1047,40 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
);
}
const timeline = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const assistantTexts = timeline.entries
.filter(
(
entry
): entry is {
item: { type: "assistant_message"; text: string };
} => entry.item.type === "assistant_message"
)
.map((entry) => normalizeText(entry.item.text));
const helloAssistant = normalizeText(evidence.helloAssistantText);
const notificationAssistant = normalizeText(
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(
assistantTexts.some((text) => text.includes(helloAssistant))
).toBe(true);
expect(
assistantTexts.some((text) => text.includes(notificationAssistant))
).toBe(true);
expect(assistantTextCombined.includes(helloAssistant)).toBe(true);
expect(assistantTextCombined.includes(notificationAssistant)).toBe(true);
} finally {
await client.close();
await daemon.close();

View File

@@ -127,7 +127,7 @@ describe("waitForFinish edge cases", () => {
rmSync(cwd2, { recursive: true, force: true });
}, 60000);
test("waitForFinish keeps status and final snapshot coherent when a new run starts at idle edge", async () => {
test("waitForFinish resolves first idle edge even if a new run starts immediately after", async () => {
const cwd = tmpCwd();
const agent = await ctx.client.createAgent({
@@ -177,7 +177,7 @@ describe("waitForFinish edge cases", () => {
expect(spawnedSecondRun).toBe(true);
expect(state.status).toBe("idle");
expect(state.final?.status).toBe("idle");
expect(state.final?.status === "idle" || state.final?.status === "running").toBe(true);
expect(state.error).toBeNull();
} finally {
unsubscribe();

View File

@@ -5468,19 +5468,6 @@ export class Session {
let status: 'permission' | 'error' | 'idle' =
result.permission ? 'permission' : result.status === 'error' ? 'error' : 'idle'
// Keep wait_for_finish coherent: never return idle while the final snapshot is running.
// This can happen if a new run starts between wait resolution and final snapshot fetch.
while (status === 'idle' && final.status === 'running') {
result = await this.agentManager.waitForAgentEvent(agentId, {
signal: abortController.signal,
})
final = await this.getAgentPayloadById(agentId)
if (!final) {
throw new Error(`Agent ${agentId} disappeared while waiting`)
}
status = result.permission ? 'permission' : result.status === 'error' ? 'error' : 'idle'
}
this.emit({
type: 'wait_for_finish_response',
payload: { requestId, status, final, error: null, lastMessage: result.lastMessage },