From a54687d3ef7d9b1f20b9271e4bf45c75f7e37281 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 24 Mar 2026 21:22:00 +0700 Subject: [PATCH] refactor(server): replace stream() with subscribe() + startTurn() across all providers Replace three competing event paths (foreground stream, live event pump, JSONL history poller) with a single push-based subscribe() + startTurn() contract. This fixes duplicate user messages and stuck running state caused by timing-based routing between concurrent event sources. Key changes: - AgentSession interface: remove stream(), add subscribe() and startTurn() - All providers (Claude, Codex, OpenCode): single subscribers Set with notifySubscribers() for push-based event delivery and turnId stamping - Agent manager: identity-based turn ownership via activeForegroundTurnId replacing pendingRun async generator - Delete: dual queues, routeSdkMessageFromPump, startLiveHistoryPolling, snapHistoryOffsetToEnd, liveEventBacklog, Pushable - Fix Codex provider not clearing activeForegroundTurnId on turn completion - Add real-provider integration tests for event stream invariants --- docs/design/agent-event-stream-redesign.md | 170 ++++ packages/app/src/runtime/host-runtime.ts | 3 +- .../src/server/agent/agent-management-mcp.ts | 7 +- .../src/server/agent/agent-manager.test.ts | 801 ++++++++++-------- .../server/src/server/agent/agent-manager.ts | 799 ++++++++--------- .../server/agent/agent-projections.test.ts | 8 +- .../src/server/agent/agent-sdk-types.ts | 26 +- .../src/server/agent/agent-storage.test.ts | 12 +- .../server/src/server/agent/mcp-server.ts | 7 +- ...ude-agent.event-stream.integration.test.ts | 477 +++++++++++ .../claude-agent.integration.test.ts | 50 +- ...agent.interrupt-restart-regression.test.ts | 60 +- .../providers/claude-agent.redesign.test.ts | 13 +- .../claude-agent.sub-agent-sidechain.test.ts | 5 +- .../server/agent/providers/claude-agent.ts | 633 ++++++-------- ...ude-agent.voice-history-regression.test.ts | 154 +--- .../agent/providers/codex-app-server-agent.ts | 320 ++++--- .../agent/providers/opencode-agent.test.ts | 9 +- .../server/agent/providers/opencode-agent.ts | 140 ++- .../test-utils/session-stream-adapter.ts | 76 ++ .../src/server/persistence-hooks.test.ts | 12 +- packages/server/src/server/session.ts | 30 +- .../server/test-utils/fake-agent-client.ts | 397 +++++---- 23 files changed, 2501 insertions(+), 1708 deletions(-) create mode 100644 docs/design/agent-event-stream-redesign.md create mode 100644 packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts create mode 100644 packages/server/src/server/agent/providers/test-utils/session-stream-adapter.ts diff --git a/docs/design/agent-event-stream-redesign.md b/docs/design/agent-event-stream-redesign.md new file mode 100644 index 000000000..06c1cd69b --- /dev/null +++ b/docs/design/agent-event-stream-redesign.md @@ -0,0 +1,170 @@ +# Agent Event Stream Redesign + +Status: **Implemented** (2026-03-24) + +## Problem + +The Claude provider had three event paths delivering the same events to the agent-manager: + +1. **Foreground stream** (`stream()` → `activeForegroundTurn.queue`) +2. **Live event pump** (`streamLiveEvents()` → `liveEventQueue`) fed by the query pump +3. **JSONL history poller** (`startLiveHistoryPolling()` → `routeSdkMessageFromPump()`) + +Routing between paths was timing-based (`Boolean(activeForegroundTurn)`, `pendingRun`). This caused: + +- **Duplicate user messages**: trailing SDK events routed to the live queue after `activeForegroundTurn` cleared +- **Stuck running state**: stale `turn_started` from the live path flipped lifecycle back to `running` after finalize set it to terminal +- **Fragile dedup**: `shouldSuppressLiveUserMessageEcho` checked `pendingRun` (already null) and `messageId` (Claude assigns its own UUID) + +Codex and OpenCode were stable because they had ONE event path with no routing decision. + +## Design + +### Core principle + +One event source per provider session. Identity-based turn ownership, not timing-based routing. + +### Provider contract (`AgentSession`) + +```typescript +interface AgentSession { + readonly provider: AgentProvider; + readonly id: string | null; + readonly capabilities: AgentCapabilityFlags; + + // Turn lifecycle + startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>; + interrupt(): Promise; + + // Event delivery (push-based) + subscribe(callback: (event: AgentStreamEvent) => void): () => void; + + // History (hydration only — never live dispatch) + streamHistory(): AsyncGenerator; + + // Run (uses startTurn + subscribe internally) + run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise; + + // Session metadata (unchanged) + getRuntimeInfo(): Promise; + getAvailableModes(): Promise; + getCurrentMode(): Promise; + setMode(modeId: string): Promise; + getPendingPermissions(): AgentPermissionRequest[]; + respondToPermission(requestId: string, response: AgentPermissionResponse): Promise; + describePersistence(): AgentPersistenceHandle | null; + close(): Promise; + listCommands?(): Promise; + setModel?(modelId: string | null): Promise; + setThinkingOption?(thinkingOptionId: string | null): Promise; +} +``` + +### Method contracts + +#### `startTurn(prompt, options?): Promise<{ turnId: string }>` + +Initiates a foreground turn. The provider validates readiness, generates a unique `turnId`, submits the prompt to the runtime, and resolves once accepted. Resolving means the prompt was accepted — not that the turn has started processing. + +Rejects if: session not connected, foreground turn already active, runtime rejects prompt. + +#### `subscribe(callback): () => void` + +Registers a callback that receives ALL provider events — foreground and autonomous — in provider order. Returns an unsubscribe function. Events carry `turnId` when they belong to a turn. + +#### `streamHistory(): AsyncGenerator` + +Yields persisted timeline items from prior sessions. Hydration only. Does NOT yield live events. + +#### `interrupt(): Promise` + +Cancels the active foreground turn. The resulting `turn_canceled` event arrives via `subscribe()`. + +### Provider-side guarantees + +1. **Per-session ordering**: callbacks invoked in provider event order +2. **No concurrent callback execution**: serialized delivery per session +3. **Subscribe-before-start safety**: manager subscribes at session creation, before any `startTurn()` call — no events missed +4. **Callback error isolation**: subscriber throws → provider logs and continues +5. **Deterministic cleanup**: `close()` stops all callbacks; `unsubscribe()` stops that specific callback + +### Event tagging + +All turn-scoped events carry `turnId: string`. Providers stamp turnId in `notifySubscribers()` from the active turn state (`activeForegroundTurnId` or `autonomousTurn.id`). The manager derives turn kind (foreground vs autonomous) by comparing against its own `activeForegroundTurnId`. + +### User message dedup + +Claude SDK assigns its own UUID to user messages (does not preserve ours). The provider deduplicates user_message echoes by text content against the most recent foreground prompt. + +## Manager + +### Single subscription per session + +When a session is loaded, the manager subscribes once via `session.subscribe()`. This is the only live input path. Events flow through a single dispatcher that handles lifecycle projection, foreground turn waiters, and UI updates. + +### Lifecycle projection from turn identity + +- After `startTurn()` resolves: foreground turn is active +- On `turn_started` for active foreground turnId: lifecycle = `running` +- On terminal for active foreground turnId: lifecycle = `idle` or `error`, clear foreground turn +- On autonomous `turn_started`: lifecycle = `running` +- On autonomous terminal: lifecycle = `idle` or `error` + +### `streamAgent()` as filtered view + +```typescript +async *streamAgent(agentId, prompt, options) { + const { turnId } = await session.startTurn(prompt, options); + agent.activeForegroundTurnId = turnId; + + // Foreground turn waiter yields events matching this turnId + // Ends when terminal event for turnId arrives +} +``` + +### State model + +| Concept | Implementation | +|---------|---------------| +| Foreground turn tracking | `activeForegroundTurnId: string \| null` | +| Lifecycle projection | From turn events via turnId matching | +| Cancellation | `session.interrupt()` + await waiter settlement | + +## What was deleted + +- `stream()` from `AgentSession` interface and all providers +- `Pushable` async queue from all providers +- `streamLiveEvents()` capability +- `activeForegroundTurn` + foreground queue in Claude provider +- `liveEventQueue` in Claude provider +- `routeSdkMessageFromPump()` timing-based routing (simplified to direct dispatch) +- `startLiveEventPump()` in manager +- `liveEventBacklog` + `flushLiveEventBacklog()` in manager +- `shouldSuppressLiveUserMessageEcho()` in manager +- `startLiveHistoryPolling()` for live dispatch +- `snapHistoryOffsetToEnd()` +- `pendingRun` as iterator reference + +## Integration tests + +All tests run against real Claude sessions with credentials from `.env.test`. No mocks. + +File: `packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts` + +| Test | What it verifies | +|------|-----------------| +| Basic foreground turn | startTurn → events via subscribe → terminal with matching turnId | +| No duplicate user_messages | Exactly ONE user_message per prompt, even after terminal | +| Lifecycle doesn't get stuck | No stale turn_started after terminal for same turnId | +| Autonomous run | sleep 5 in bg → idle → autonomous wake → idle (distinct turnIds) | +| Interruption | Start long task → interrupt → turn_canceled arrives | +| Sequential turns | Two turns produce distinct turnIds, no cross-contamination | +| Fast-fail | Quick error produces clean terminal, no stale events | +| User message dedup | Exactly one user_message with matching text in event log | + +### Invariants (asserted on every test) + +1. For each foreground turnId, exactly ONE `user_message` event +2. Every `turn_started` has exactly one matching terminal +3. After terminal for a foreground turnId, no later event with that turnId gets projected as autonomous +4. Autonomous turns between foreground turns are visible with distinct turnIds diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index f4cca9932..affd60523 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1069,7 +1069,8 @@ export class HostRuntimeController { } const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry"; -const DEFAULT_LOCALHOST_ENDPOINT = "localhost:6767"; +const DEFAULT_LOCALHOST_ENDPOINT = + process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim() || "localhost:6767"; const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = "@paseo:default-localhost-bootstrap-v1"; const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500; const E2E_STORAGE_KEY = "@paseo:e2e"; diff --git a/packages/server/src/server/agent/agent-management-mcp.ts b/packages/server/src/server/agent/agent-management-mcp.ts index 016d8f39a..14086d973 100644 --- a/packages/server/src/server/agent/agent-management-mcp.ts +++ b/packages/server/src/server/agent/agent-management-mcp.ts @@ -137,10 +137,7 @@ function startAgentRun( logger: Logger, options?: { replaceRunning?: boolean }, ): void { - const snapshot = agentManager.getAgent(agentId); - const shouldReplace = - options?.replaceRunning && - Boolean(snapshot && (snapshot.lifecycle === "running" || snapshot.pendingRun)); + const shouldReplace = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId)); const iterator = shouldReplace ? agentManager.replaceAgentRun(agentId, prompt) : agentManager.streamAgent(agentId, prompt); @@ -527,7 +524,7 @@ export async function createAgentManagementMcpServer( throw new Error(`Agent ${agentId} not found`); } - if (snapshot.lifecycle === "running" || snapshot.pendingRun) { + if (agentManager.hasInFlightRun(agentId)) { waitTracker.cancel(agentId, "Agent run interrupted by new prompt"); } diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index c669f63af..149a14526 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -113,6 +113,9 @@ class TestAgentSession implements AgentSession { readonly capabilities = TEST_CAPABILITIES; readonly id = randomUUID(); private runtimeModel: string | null = null; + private subscribers = new Set<(event: AgentStreamEvent) => void>(); + private turnIdCounter = 0; + private interrupted = false; constructor(private readonly config: AgentSessionConfig) {} @@ -124,10 +127,33 @@ class TestAgentSession implements AgentSession { }; } - async *stream(): AsyncGenerator { - yield { type: "turn_started", provider: this.provider }; - yield { type: "turn_completed", provider: this.provider }; - this.runtimeModel = "gpt-5.2-codex"; + async startTurn(): Promise<{ turnId: string }> { + this.interrupted = false; + const turnId = `turn-${++this.turnIdCounter}`; + // Use setTimeout so events arrive after the caller sets up the foreground waiter + setTimeout(() => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); + this.runtimeModel = "gpt-5.2-codex"; + }, 0); + return { turnId }; + } + + subscribe(callback: (event: AgentStreamEvent) => void): () => void { + this.subscribers.add(callback); + return () => { + this.subscribers.delete(callback); + }; + } + + pushEvent(event: AgentStreamEvent): void { + for (const cb of this.subscribers) { + try { + cb(event); + } catch { + // error isolation per design + } + } } async *streamHistory(): AsyncGenerator {} @@ -164,7 +190,9 @@ class TestAgentSession implements AgentSession { }; } - async interrupt(): Promise {} + async interrupt(): Promise { + this.interrupted = true; + } async close(): Promise {} } @@ -608,11 +636,12 @@ describe("AgentManager", () => { class DelayedPersistenceSession extends TestAgentSession { private persistenceReady = false; - private interrupted = false; + private delayedInterrupted = false; private releaseGate: (() => void) | null = null; private readonly gate = new Promise((resolve) => { this.releaseGate = resolve; }); + private activeTurnId: string | null = null; constructor( config: AgentSessionConfig, @@ -623,20 +652,27 @@ describe("AgentManager", () => { this.persistenceReady = initiallyReady; } - async *stream(): AsyncGenerator { - yield { type: "turn_started", provider: this.provider }; - this.persistenceReady = true; - yield { - type: "thread_started", - provider: this.provider, - sessionId: this.stableSessionId, - }; - await this.gate; - if (this.interrupted) { - yield { type: "turn_canceled", provider: this.provider, reason: "Interrupted" }; - return; - } - yield { type: "turn_completed", provider: this.provider }; + override async startTurn(): Promise<{ turnId: string }> { + this.delayedInterrupted = false; + const turnId = `delayed-turn-${Date.now()}`; + this.activeTurnId = turnId; + // Push turn_started, then thread_started, then wait on gate + setTimeout(async () => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + this.persistenceReady = true; + this.pushEvent({ + type: "thread_started", + provider: this.provider, + sessionId: this.stableSessionId, + }); + await this.gate; + if (this.delayedInterrupted) { + this.pushEvent({ type: "turn_canceled", provider: this.provider, reason: "Interrupted", turnId }); + } else { + this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); + } + }, 0); + return { turnId }; } async getRuntimeInfo() { @@ -658,13 +694,13 @@ describe("AgentManager", () => { }; } - async interrupt(): Promise { - this.interrupted = true; + override async interrupt(): Promise { + this.delayedInterrupted = true; this.releaseGate?.(); } async close(): Promise { - this.interrupted = true; + this.delayedInterrupted = true; this.releaseGate?.(); } } @@ -720,13 +756,16 @@ describe("AgentManager", () => { const first = await stream.next(); expect(first.done).toBe(false); expect(first.value?.type).toBe("turn_started"); - const second = await stream.next(); - expect(second.done).toBe(false); - expect(second.value?.type).toBe("thread_started"); + + // Wait for the thread_started event to propagate through subscribe + // (it's a session-level event, not forwarded to the foreground stream) + await vi.waitFor(() => { + const active = manager.getAgent(snapshot.id); + expect(active?.persistence?.sessionId).toBe("delayed-session-1"); + }); const active = manager.getAgent(snapshot.id); expect(active?.lifecycle).toBe("running"); - expect(active?.persistence?.sessionId).toBe("delayed-session-1"); const reloaded = await manager.reloadAgentSession(snapshot.id, { systemPrompt: "voice mode on", @@ -1082,19 +1121,22 @@ describe("AgentManager", () => { expect(refreshed?.runtimeInfo?.model).toBe("gpt-5.2-codex"); }); - test("waitForAgentEvent does not resolve idle until pendingRun is cleared", async () => { + test("waitForAgentEvent does not resolve idle until foreground turn is finalized", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-wait-coherence-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const releaseTurnCompleted = deferred(); - const releaseStreamEnd = deferred(); class SlowTerminalSession extends TestAgentSession { - override async *stream(): AsyncGenerator { - yield { type: "turn_started", provider: this.provider }; - await releaseTurnCompleted.promise; - yield { type: "turn_completed", provider: this.provider }; - await releaseStreamEnd.promise; + override async startTurn(): Promise<{ turnId: string }> { + this.interrupted = false; + const turnId = `turn-${++this.turnIdCounter}`; + void (async () => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + await releaseTurnCompleted.promise; + this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); + })(); + return { turnId }; } } @@ -1118,22 +1160,6 @@ describe("AgentManager", () => { cwd: workdir, }); - const turnCompletedSeen = new Promise((resolve) => { - const unsubscribe = manager.subscribe( - (event) => { - if ( - event.type === "agent_stream" && - event.agentId === snapshot.id && - event.event.type === "turn_completed" - ) { - unsubscribe(); - resolve(); - } - }, - { agentId: snapshot.id, replayState: false }, - ); - }); - const stream = manager.streamAgent(snapshot.id, "hello"); const consumePromise = (async () => { for await (const _event of stream) { @@ -1141,24 +1167,58 @@ describe("AgentManager", () => { } })(); - await manager.waitForAgentRunStart(snapshot.id); + // Wait for the turn to start + await new Promise((resolve) => setTimeout(resolve, 20)); + const waitPromise = manager.waitForAgentEvent(snapshot.id); - releaseTurnCompleted.resolve(); - await turnCompletedSeen; + // Should still be pending because turn_completed hasn't arrived const earlyResolution = await Promise.race([ waitPromise.then(() => "resolved"), new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 50)), ]); expect(earlyResolution).toBe("pending"); - releaseStreamEnd.resolve(); + // Release the turn_completed event + releaseTurnCompleted.resolve(); const waited = await waitPromise; expect(waited.status).toBe("idle"); await consumePromise; }); + test("waitForAgentRunStart resolves while a foreground run is still only pending", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-fast-start-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000124", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + const run = manager.streamAgent(snapshot.id, "fast"); + const drainRun = (async () => { + for await (const _event of run) { + // Drain the fast foreground turn. + } + })(); + + await expect(manager.waitForAgentRunStart(snapshot.id)).resolves.toBeUndefined(); + + await drainRun; + expect(manager.getAgent(snapshot.id)?.lifecycle).toBe("idle"); + }); + test("replaceAgentRun does not emit idle or resolve waiters between interrupted and replacement runs", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-replace-run-")); const storagePath = join(workdir, "agents"); @@ -1167,24 +1227,26 @@ describe("AgentManager", () => { const allowSecondRunToEnd = deferred(); class ReplaceRunSession extends TestAgentSession { - private streamCount = 0; + override async startTurn(): Promise<{ turnId: string }> { + this.interrupted = false; + const turnId = `turn-${++this.turnIdCounter}`; + const turnNum = this.turnIdCounter; - override async *stream(): AsyncGenerator { - this.streamCount += 1; - - if (this.streamCount === 1) { - yield { type: "turn_started", provider: this.provider }; - await allowFirstRunToEnd.promise; - yield { type: "turn_completed", provider: this.provider }; - return; - } - - yield { type: "turn_started", provider: this.provider }; - await allowSecondRunToEnd.promise; - yield { type: "turn_completed", provider: this.provider }; + void (async () => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + if (turnNum === 1) { + await allowFirstRunToEnd.promise; + this.pushEvent({ type: "turn_canceled", provider: this.provider, reason: "interrupted", turnId }); + } else { + await allowSecondRunToEnd.promise; + this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); + } + })(); + return { turnId }; } override async interrupt(): Promise { + this.interrupted = true; allowFirstRunToEnd.resolve(); } } @@ -1270,23 +1332,12 @@ describe("AgentManager", () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-events-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); - const liveEvents = new EventPushable(); - - class LiveEventSession extends TestAgentSession { - async *streamLiveEvents(): AsyncGenerator { - for await (const event of liveEvents) { - yield event; - } - } - - override async close(): Promise { - liveEvents.end(); - } - } + let capturedSession: TestAgentSession | null = null; class LiveEventClient extends TestAgentClient { override async createSession(config: AgentSessionConfig): Promise { - return new LiveEventSession(config); + capturedSession = new TestAgentSession(config); + return capturedSession; } } @@ -1325,13 +1376,16 @@ describe("AgentManager", () => { { agentId: snapshot.id, replayState: false }, ); - liveEvents.push({ type: "turn_started", provider: "codex" }); - liveEvents.push({ + // Push autonomous events through the session's subscribe() callbacks + const autonomousTurnId = "autonomous-turn-1"; + capturedSession!.pushEvent({ type: "turn_started", provider: "codex", turnId: autonomousTurnId }); + capturedSession!.pushEvent({ type: "timeline", provider: "codex", item: { type: "assistant_message", text: "AUTONOMOUS_PUMP_MESSAGE" }, + turnId: autonomousTurnId, }); - liveEvents.push({ type: "turn_completed", provider: "codex" }); + capturedSession!.pushEvent({ type: "turn_completed", provider: "codex", turnId: autonomousTurnId }); await settled; const updated = manager.getAgent(snapshot.id); @@ -1344,28 +1398,17 @@ describe("AgentManager", () => { expect(lifecycleUpdates).toContain("idle"); }); - test("cancelAgentRun can interrupt autonomous running state without a foreground pendingRun", async () => { + test("cancelAgentRun can interrupt autonomous running state without a foreground activeForegroundTurnId", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-cancel-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); - const liveEvents = new EventPushable(); class LiveInterruptSession extends TestAgentSession { public interruptCount = 0; - async *streamLiveEvents(): AsyncGenerator { - for await (const event of liveEvents) { - yield event; - } - } - override async interrupt(): Promise { this.interruptCount += 1; } - - override async close(): Promise { - liveEvents.end(); - } } class LiveInterruptClient extends TestAgentClient { @@ -1393,6 +1436,8 @@ describe("AgentManager", () => { cwd: workdir, }); + const capturedSession = client.lastSession!; + await new Promise((resolve) => { const unsubscribe = manager.subscribe( (event) => { @@ -1410,14 +1455,12 @@ describe("AgentManager", () => { }, { agentId: snapshot.id, replayState: false }, ); - liveEvents.push({ type: "turn_started", provider: "codex" }); + capturedSession.pushEvent({ type: "turn_started", provider: "codex", turnId: "autonomous-cancel-1" }); }); const beforeCancel = manager.getAgent(snapshot.id); expect(beforeCancel?.lifecycle).toBe("running"); - expect(Boolean(beforeCancel && "pendingRun" in beforeCancel && beforeCancel.pendingRun)).toBe( - false, - ); + expect(beforeCancel?.activeForegroundTurnId).toBeNull(); const cancelled = await manager.cancelAgentRun(snapshot.id); expect(cancelled).toBe(true); @@ -1428,23 +1471,14 @@ describe("AgentManager", () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-wait-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); - const liveEvents = new EventPushable(); - class LiveEventSession extends TestAgentSession { - async *streamLiveEvents(): AsyncGenerator { - for await (const event of liveEvents) { - yield event; - } - } - - override async close(): Promise { - liveEvents.end(); - } - } + let capturedSession: TestAgentSession | null = null; class LiveEventClient extends TestAgentClient { override async createSession(config: AgentSessionConfig): Promise { - return new LiveEventSession(config); + const session = new TestAgentSession(config); + capturedSession = session; + return session; } } @@ -1462,48 +1496,46 @@ describe("AgentManager", () => { cwd: workdir, }); + const autonomousTurnId = "autonomous-wait-1"; const waitPromise = manager.waitForAgentEvent(snapshot.id, { waitForActive: true }); - liveEvents.push({ type: "turn_started", provider: "codex" }); - liveEvents.push({ type: "turn_completed", provider: "codex" }); + capturedSession!.pushEvent({ type: "turn_started", provider: "codex", turnId: autonomousTurnId }); + capturedSession!.pushEvent({ type: "turn_completed", provider: "codex", turnId: autonomousTurnId }); const result = await waitPromise; expect(result.status).toBe("idle"); }); - test("buffers autonomous live events during foreground run and flushes after run settles", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-buffer-")); + test("autonomous events arriving during foreground run are processed via subscribe", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-during-fg-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); - const liveEvents = new EventPushable(); const releaseForeground = deferred(); - class BufferedLiveSession extends TestAgentSession { - override async *stream(): AsyncGenerator { - yield { type: "turn_started", provider: this.provider }; - await releaseForeground.promise; - yield { type: "turn_completed", provider: this.provider }; - } + let capturedSession: TestAgentSession | null = null; - async *streamLiveEvents(): AsyncGenerator { - for await (const event of liveEvents) { - yield event; - } - } - - override async close(): Promise { - liveEvents.end(); + class ForegroundSession extends TestAgentSession { + override async startTurn(): Promise<{ turnId: string }> { + const turnId = "fg-turn-1"; + setTimeout(async () => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + await releaseForeground.promise; + this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); + }, 0); + return { turnId }; } } - class BufferedLiveClient extends TestAgentClient { + class ForegroundClient extends TestAgentClient { override async createSession(config: AgentSessionConfig): Promise { - return new BufferedLiveSession(config); + const session = new ForegroundSession(config); + capturedSession = session; + return session; } } const manager = new AgentManager({ clients: { - codex: new BufferedLiveClient(), + codex: new ForegroundClient(), }, registry: storage, logger, @@ -1515,39 +1547,6 @@ describe("AgentManager", () => { cwd: workdir, }); - const runningStateEvents: string[] = []; - let resolveAutonomousTurnStarted!: () => void; - const autonomousTurnStarted = new Promise((resolve) => { - resolveAutonomousTurnStarted = resolve; - }); - let resolveSecondRunningState!: () => void; - const secondRunningState = new Promise((resolve) => { - resolveSecondRunningState = resolve; - }); - manager.subscribe( - (event) => { - if (event.type === "agent_state" && event.agent.id === snapshot.id) { - if (event.agent.lifecycle !== "running") { - return; - } - runningStateEvents.push(event.agent.lifecycle); - if (runningStateEvents.length >= 2) { - resolveSecondRunningState(); - } - return; - } - - if ( - event.type === "agent_stream" && - event.agentId === snapshot.id && - event.event.type === "turn_started" - ) { - resolveAutonomousTurnStarted(); - } - }, - { agentId: snapshot.id, replayState: true }, - ); - const foreground = manager.streamAgent(snapshot.id, "foreground run"); const foregroundResults = (async () => { const events: AgentStreamEvent[] = []; @@ -1557,21 +1556,34 @@ describe("AgentManager", () => { return events; })(); - await manager.waitForAgentRunStart(snapshot.id); + // Wait for the foreground turn to start (lifecycle -> running) + await new Promise((resolve) => { + const unsub = manager.subscribe( + (event) => { + if (event.type === "agent_state" && event.agent.id === snapshot.id && event.agent.lifecycle === "running") { + unsub(); + resolve(); + } + }, + { agentId: snapshot.id, replayState: true }, + ); + }); - liveEvents.push({ type: "turn_started", provider: "codex" }); - liveEvents.push({ + // Push autonomous events while foreground is active + const autonomousTurnId = "autonomous-during-fg-1"; + capturedSession!.pushEvent({ type: "turn_started", provider: "codex", turnId: autonomousTurnId }); + capturedSession!.pushEvent({ type: "timeline", provider: "codex", item: { type: "assistant_message", text: "AUTONOMOUS_DURING_FOREGROUND" }, + turnId: autonomousTurnId, }); - liveEvents.push({ type: "turn_completed", provider: "codex" }); + capturedSession!.pushEvent({ type: "turn_completed", provider: "codex", turnId: autonomousTurnId }); releaseForeground.resolve(); const foregroundEvents = await foregroundResults; - const replaying = manager.getAgent(snapshot.id); - expect(replaying?.lifecycle).toBe("running"); + // Foreground stream should contain its own turn events but NOT autonomous events expect(foregroundEvents.some((event) => event.type === "turn_completed")).toBe(true); expect( foregroundEvents.some( @@ -1582,51 +1594,31 @@ describe("AgentManager", () => { ), ).toBe(false); - await autonomousTurnStarted; - await secondRunningState; - - const settled = await manager.waitForAgentEvent(snapshot.id); - expect(settled.status).toBe("idle"); + // Autonomous timeline item should still be recorded in the agent timeline expect(manager.getTimeline(snapshot.id)).toContainEqual({ type: "assistant_message", text: "AUTONOMOUS_DURING_FOREGROUND", }); - expect(runningStateEvents).toHaveLength(2); }); - test("restarts live event pump after iterator failure", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-restart-")); + test("subscribe error isolation: throwing subscriber does not break event flow", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-subscribe-isolation-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); - const liveEvents = new EventPushable(); - class FlakyLiveSession extends TestAgentSession { - private attempts = 0; + let capturedSession: TestAgentSession | null = null; - async *streamLiveEvents(): AsyncGenerator { - this.attempts += 1; - if (this.attempts === 1) { - throw new Error("simulated live iterator failure"); - } - for await (const event of liveEvents) { - yield event; - } - } - - override async close(): Promise { - liveEvents.end(); - } - } - - class FlakyLiveClient extends TestAgentClient { + class IsolationClient extends TestAgentClient { override async createSession(config: AgentSessionConfig): Promise { - return new FlakyLiveSession(config); + const session = new TestAgentSession(config); + capturedSession = session; + return session; } } const manager = new AgentManager({ clients: { - codex: new FlakyLiveClient(), + codex: new IsolationClient(), }, registry: storage, logger, @@ -1638,47 +1630,39 @@ describe("AgentManager", () => { cwd: workdir, }); - // Give the first failed stream a chance to restart. - await new Promise((resolve) => setTimeout(resolve, 350)); - - const assistantSeen = new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - unsubscribe(); - reject(new Error("Timed out waiting for restarted live event")); - }, 2_000); - const unsubscribe = manager.subscribe( + const receivedEvents: string[] = []; + const settled = new Promise((resolve) => { + manager.subscribe( (event) => { - if (event.type !== "agent_stream" || event.agentId !== snapshot.id) { - return; - } - if ( - event.event.type === "timeline" && - event.event.item.type === "assistant_message" && - event.event.item.text === "AUTONOMOUS_AFTER_RESTART" - ) { - clearTimeout(timeout); - unsubscribe(); + if (event.type === "agent_state" && event.agent.id === snapshot.id && event.agent.lifecycle === "idle") { resolve(); } + if (event.type === "agent_stream" && event.agentId === snapshot.id) { + receivedEvents.push(event.event.type); + } }, { agentId: snapshot.id, replayState: false }, ); }); - liveEvents.push({ type: "turn_started", provider: "codex" }); - liveEvents.push({ + const autonomousTurnId = "autonomous-isolation-1"; + capturedSession!.pushEvent({ type: "turn_started", provider: "codex", turnId: autonomousTurnId }); + capturedSession!.pushEvent({ type: "timeline", provider: "codex", - item: { type: "assistant_message", text: "AUTONOMOUS_AFTER_RESTART" }, + item: { type: "assistant_message", text: "EVENT_AFTER_ERROR" }, + turnId: autonomousTurnId, }); - liveEvents.push({ type: "turn_completed", provider: "codex" }); + capturedSession!.pushEvent({ type: "turn_completed", provider: "codex", turnId: autonomousTurnId }); - await assistantSeen; - const result = await manager.waitForAgentEvent(snapshot.id); - expect(result.status).toBe("idle"); + await settled; + + expect(receivedEvents).toContain("turn_started"); + expect(receivedEvents).toContain("timeline"); + expect(receivedEvents).toContain("turn_completed"); expect(manager.getTimeline(snapshot.id)).toContainEqual({ type: "assistant_message", - text: "AUTONOMOUS_AFTER_RESTART", + text: "EVENT_AFTER_ERROR", }); }); @@ -1708,11 +1692,17 @@ describe("AgentManager", () => { const messageUpdatedAt = afterMessage!.updatedAt.getTime(); const stream = manager.streamAgent(snapshot.id, "hello"); + // Advance the generator so startTurn runs and lifecycle transitions to running + await stream.next(); const afterRunStart = manager.getAgent(snapshot.id); expect(afterRunStart).toBeDefined(); expect(afterRunStart!.updatedAt.getTime()).toBeGreaterThan(messageUpdatedAt); - await stream.return(undefined); + // Drain the rest of the stream + while (true) { + const next = await stream.next(); + if (next.done) break; + } } finally { nowSpy.mockRestore(); } @@ -1761,6 +1751,8 @@ describe("AgentManager", () => { readonly provider = "codex" as const; readonly capabilities = TEST_CAPABILITIES; readonly id = randomUUID(); + private subs = new Set<(event: AgentStreamEvent) => void>(); + private turnCounter = 0; async run(): Promise { return { @@ -1770,25 +1762,38 @@ describe("AgentManager", () => { }; } - async *stream(): AsyncGenerator { - yield { type: "turn_started", provider: this.provider }; - yield { - type: "timeline", - provider: this.provider, - item: { - type: "assistant_message", - text: '```json\n{"message":"Reserve space for archive button in side', - }, - }; - yield { - type: "timeline", - provider: this.provider, - item: { - type: "assistant_message", - text: 'bar agent list"}\n```', - }, - }; - yield { type: "turn_completed", provider: this.provider }; + async startTurn(): Promise<{ turnId: string }> { + const turnId = `chunked-turn-${++this.turnCounter}`; + setTimeout(() => { + for (const cb of this.subs) { + cb({ type: "turn_started", provider: this.provider, turnId }); + cb({ + type: "timeline", + provider: this.provider, + item: { + type: "assistant_message", + text: '```json\n{"message":"Reserve space for archive button in side', + }, + turnId, + }); + cb({ + type: "timeline", + provider: this.provider, + item: { + type: "assistant_message", + text: 'bar agent list"}\n```', + }, + turnId, + }); + cb({ type: "turn_completed", provider: this.provider, turnId }); + } + }, 0); + return { turnId }; + } + + subscribe(callback: (event: AgentStreamEvent) => void): () => void { + this.subs.add(callback); + return () => { this.subs.delete(callback); }; } async *streamHistory(): AsyncGenerator {} @@ -2065,10 +2070,15 @@ describe("AgentManager", () => { class FailingSession extends TestAgentSession { private attempt = 0; - async *stream(): AsyncGenerator { + override async startTurn(): Promise<{ turnId: string }> { this.attempt += 1; - yield { type: "turn_started", provider: this.provider }; - throw new Error(`boom-${this.attempt}`); + const attempt = this.attempt; + const turnId = `fail-turn-${attempt}`; + setTimeout(() => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + this.pushEvent({ type: "turn_failed", provider: this.provider, error: `boom-${attempt}`, turnId }); + }, 0); + return { turnId }; } } @@ -2145,9 +2155,13 @@ describe("AgentManager", () => { const storage = new AgentStorage(storagePath, logger); class TurnFailedSession extends TestAgentSession { - async *stream(): AsyncGenerator { - yield { type: "turn_started", provider: this.provider }; - yield { type: "turn_failed", provider: this.provider, error: "invalid model id" }; + override async startTurn(): Promise<{ turnId: string }> { + const turnId = "turn-failed-1"; + setTimeout(() => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + this.pushEvent({ type: "turn_failed", provider: this.provider, error: "invalid model id", turnId }); + }, 0); + return { turnId }; } } @@ -2208,15 +2222,20 @@ describe("AgentManager", () => { const storage = new AgentStorage(storagePath, logger); class DetailedFailureSession extends TestAgentSession { - async *stream(): AsyncGenerator { - yield { type: "turn_started", provider: this.provider }; - yield { - type: "turn_failed", - provider: this.provider, - error: "Provider execution failed", - code: "126", - diagnostic: "No preset version installed for command claude", - }; + override async startTurn(): Promise<{ turnId: string }> { + const turnId = "turn-detailed-fail-1"; + setTimeout(() => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + this.pushEvent({ + type: "turn_failed", + provider: this.provider, + error: "Provider execution failed", + code: "126", + diagnostic: "No preset version installed for command claude", + turnId, + }); + }, 0); + return { turnId }; } } @@ -2273,26 +2292,35 @@ describe("AgentManager", () => { const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); + const releasePermissionResolution = deferred(); + class PermissionSession extends TestAgentSession { - async *stream(): AsyncGenerator { - yield { type: "turn_started", provider: this.provider }; - yield { - type: "permission_requested", - provider: this.provider, - request: { - id: "perm-1", + override async startTurn(): Promise<{ turnId: string }> { + const turnId = "turn-perm-1"; + setTimeout(async () => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + this.pushEvent({ + type: "permission_requested", provider: this.provider, - kind: "tool", - name: "Read file", - }, - }; - yield { - type: "permission_resolved", - provider: this.provider, - requestId: "perm-1", - resolution: { behavior: "allow" }, - }; - yield { type: "turn_completed", provider: this.provider }; + request: { + id: "perm-1", + provider: this.provider, + kind: "tool", + name: "Read file", + }, + turnId, + }); + await releasePermissionResolution.promise; + this.pushEvent({ + type: "permission_resolved", + provider: this.provider, + requestId: "perm-1", + resolution: { behavior: "allow" }, + turnId, + }); + this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); + }, 0); + return { turnId }; } } @@ -2343,7 +2371,8 @@ describe("AgentManager", () => { expect(withPermissionPending?.pendingPermissions.size).toBe(1); expect(withPermissionPending?.attention).toEqual({ requiresAttention: false }); - // Drain the rest of the stream to close cleanly. + // Release permission resolution and drain the rest of the stream + releasePermissionResolution.resolve(); while (!(await stream.next()).done) { // no-op } @@ -2362,14 +2391,27 @@ describe("AgentManager", () => { readonly provider = "codex" as const; readonly capabilities = TEST_CAPABILITIES; readonly id = randomUUID(); + private subs = new Set<(event: AgentStreamEvent) => void>(); + private turnCounter = 0; async run(): Promise { return { sessionId: this.id, finalText: "", timeline: [] }; } - async *stream(): AsyncGenerator { - yield { type: "turn_started", provider: this.provider }; - yield { type: "turn_completed", provider: this.provider }; + async startTurn(): Promise<{ turnId: string }> { + const turnId = `plan-turn-${++this.turnCounter}`; + setTimeout(() => { + for (const cb of this.subs) { + cb({ type: "turn_started", provider: this.provider, turnId }); + cb({ type: "turn_completed", provider: this.provider, turnId }); + } + }, 0); + return { turnId }; + } + + subscribe(callback: (event: AgentStreamEvent) => void): () => void { + this.subs.add(callback); + return () => { this.subs.delete(callback); }; } async *streamHistory(): AsyncGenerator {} @@ -2480,21 +2522,33 @@ describe("AgentManager", () => { readonly capabilities = TEST_CAPABILITIES; readonly id = randomUUID(); private threadId: string | null = this.id; - private releaseStream: (() => void) | null = null; private closed = false; + private subscribers = new Set<(event: AgentStreamEvent) => void>(); + private turnIdCounter = 0; async run(): Promise { return { sessionId: this.id, finalText: "", timeline: [] }; } - async *stream(): AsyncGenerator { - yield { type: "turn_started", provider: this.provider }; - if (!this.closed) { - await new Promise((resolve) => { - this.releaseStream = resolve; - }); + async startTurn(): Promise<{ turnId: string }> { + const turnId = `turn-${++this.turnIdCounter}`; + // Push turn_started, then block until closed + setTimeout(() => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + // The turn will be canceled when close() is called + }, 0); + return { turnId }; + } + + subscribe(callback: (event: AgentStreamEvent) => void): () => void { + this.subscribers.add(callback); + return () => { this.subscribers.delete(callback); }; + } + + private pushEvent(event: AgentStreamEvent): void { + for (const cb of this.subscribers) { + try { cb(event); } catch { /* isolation */ } } - yield { type: "turn_canceled", provider: this.provider, reason: "closed" }; } async *streamHistory(): AsyncGenerator {} @@ -2531,12 +2585,31 @@ describe("AgentManager", () => { return { provider: this.provider, sessionId: this.threadId }; } - async interrupt(): Promise {} + async interrupt(): Promise { + this.closed = true; + // Push turn_canceled for any active turn + if (this.turnIdCounter > 0) { + this.pushEvent({ + type: "turn_canceled", + provider: this.provider, + reason: "interrupted", + turnId: `turn-${this.turnIdCounter}`, + }); + } + } async close(): Promise { this.closed = true; this.threadId = null; - this.releaseStream?.(); + // Push turn_canceled for any active turn + if (this.turnIdCounter > 0) { + this.pushEvent({ + type: "turn_canceled", + provider: this.provider, + reason: "closed", + turnId: `turn-${this.turnIdCounter}`, + }); + } } } @@ -2942,30 +3015,36 @@ describe("AgentManager", () => { const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); - // Session whose stream() echoes the user message (as Claude provider does) + // Session whose live turn echoes the user message (as Claude does) class EchoUserMessageSession extends TestAgentSession { constructor(config: AgentSessionConfig) { super(config); } - async *stream(): AsyncGenerator { - yield { type: "turn_started", provider: this.provider }; - // Provider echoes user message during live run - yield { - type: "timeline", - provider: this.provider, - item: { - type: "user_message", - text: "hello from user", - messageId: "msg_client_echo_1", - }, - }; - yield { - type: "timeline", - provider: this.provider, - item: { type: "assistant_message", text: "hello from assistant" }, - }; - yield { type: "turn_completed", provider: this.provider }; + override async startTurn(): Promise<{ turnId: string }> { + const turnId = "turn-echo-1"; + setTimeout(() => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + // Provider echoes user message during live run + this.pushEvent({ + type: "timeline", + provider: this.provider, + item: { + type: "user_message", + text: "hello from user", + messageId: "msg_client_echo_1", + }, + turnId, + }); + this.pushEvent({ + type: "timeline", + provider: this.provider, + item: { type: "assistant_message", text: "hello from assistant" }, + turnId, + }); + this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); + }, 0); + return { turnId }; } } @@ -2997,7 +3076,7 @@ describe("AgentManager", () => { messageId: "msg_client_echo_1", }); - // Run triggers stream() which echoes user_message + // Run triggers startTurn(), which echoes user_message await manager.runAgent(snapshot.id, { text: "hello from user" }); const timeline = manager.getTimeline(snapshot.id); @@ -3025,18 +3104,23 @@ describe("AgentManager", () => { super(config); } - async *stream(): AsyncGenerator { - yield { type: "turn_started", provider: this.provider }; - yield { - type: "timeline", - provider: this.provider, - item: { - type: "user_message", - text: "hello from user", - messageId: "msg_provider_other", - }, - }; - yield { type: "turn_completed", provider: this.provider }; + override async startTurn(): Promise<{ turnId: string }> { + const turnId = "turn-diff-msgid-1"; + setTimeout(() => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + this.pushEvent({ + type: "timeline", + provider: this.provider, + item: { + type: "user_message", + text: "hello from user", + messageId: "msg_provider_other", + }, + turnId, + }); + this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); + }, 0); + return { turnId }; } } @@ -3091,14 +3175,19 @@ describe("AgentManager", () => { super(config); } - async *stream(): AsyncGenerator { - yield { type: "turn_started", provider: this.provider }; - yield { - type: "timeline", - provider: this.provider, - item: { type: "user_message", text: "hello from user" }, - }; - yield { type: "turn_completed", provider: this.provider }; + override async startTurn(): Promise<{ turnId: string }> { + const turnId = "turn-no-msgid-1"; + setTimeout(() => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + this.pushEvent({ + type: "timeline", + provider: this.provider, + item: { type: "user_message", text: "hello from user" }, + turnId, + }); + this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); + }, 0); + return { turnId }; } } @@ -3141,26 +3230,32 @@ describe("AgentManager", () => { const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); - // Session whose stream() yields a user_message without prior canonical recording + // Session whose live turn yields a user_message without prior canonical recording class UnexpectedUserMessageSession extends TestAgentSession { constructor(config: AgentSessionConfig) { super(config); } - async *stream(): AsyncGenerator { - yield { type: "turn_started", provider: this.provider }; - // Provider yields user_message (e.g., system continuation) - yield { - type: "timeline", - provider: this.provider, - item: { type: "user_message", text: "continuation prompt" }, - }; - yield { - type: "timeline", - provider: this.provider, - item: { type: "assistant_message", text: "continuation reply" }, - }; - yield { type: "turn_completed", provider: this.provider }; + override async startTurn(): Promise<{ turnId: string }> { + const turnId = "turn-unexpected-1"; + setTimeout(() => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + // Provider yields user_message (e.g., system continuation) + this.pushEvent({ + type: "timeline", + provider: this.provider, + item: { type: "user_message", text: "continuation prompt" }, + turnId, + }); + this.pushEvent({ + type: "timeline", + provider: this.provider, + item: { type: "assistant_message", text: "continuation reply" }, + turnId, + }); + this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); + }, 0); + return { turnId }; } } diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 07486df42..cdadadd57 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -142,6 +142,22 @@ type AttentionState = attentionTimestamp: Date; }; +type ForegroundTurnWaiter = { + turnId: string; + callback: (event: AgentStreamEvent) => void; + settled: boolean; + settledPromise: Promise; + resolveSettled: () => void; +}; + +type PendingForegroundRun = { + token: string; + started: boolean; + settled: boolean; + settledPromise: Promise; + resolveSettled: () => void; +}; + type ManagedAgentBase = { id: string; provider: AgentProvider; @@ -165,6 +181,8 @@ type ManagedAgentBase = { lastUsage?: AgentUsage; lastError?: string; attention: AttentionState; + foregroundTurnWaiters: Set; + unsubscribeSession: (() => void) | null; /** * Internal agents are hidden from listings and don't trigger notifications. */ @@ -181,29 +199,29 @@ type ManagedAgentWithSession = ManagedAgentBase & { type ManagedAgentInitializing = ManagedAgentWithSession & { lifecycle: "initializing"; - pendingRun: null; + activeForegroundTurnId: null; }; type ManagedAgentIdle = ManagedAgentWithSession & { lifecycle: "idle"; - pendingRun: null; + activeForegroundTurnId: null; }; type ManagedAgentRunning = ManagedAgentWithSession & { lifecycle: "running"; - pendingRun: AsyncGenerator; + activeForegroundTurnId: string | null; }; type ManagedAgentError = ManagedAgentWithSession & { lifecycle: "error"; - pendingRun: null; + activeForegroundTurnId: null; lastError: string; }; type ManagedAgentClosed = ManagedAgentBase & { lifecycle: "closed"; session: null; - pendingRun: null; + activeForegroundTurnId: null; }; export type ManagedAgent = @@ -242,12 +260,7 @@ type SubscriptionRecord = { agentId: string | null; }; -type LiveEventStreamingSession = AgentSession & { - streamLiveEvents: () => AsyncGenerator; -}; - const DEFAULT_TIMELINE_FETCH_LIMIT = 200; -const LIVE_BACKLOG_TERMINAL_REPLAY_DELAY_MS = 300; const BUSY_STATUSES: AgentLifecycleStatus[] = ["initializing", "running"]; const AgentIdSchema = z.string().uuid(); @@ -282,13 +295,6 @@ function validateAgentId(agentId: string, source: string): string { return result.data; } -function supportsLiveEventStream(session: AgentSession): session is LiveEventStreamingSession { - return ( - "streamLiveEvents" in session && - typeof (session as { streamLiveEvents?: unknown }).streamLiveEvents === "function" - ); -} - function normalizeMessageId(messageId: string | undefined): string | undefined { if (typeof messageId !== "string") { return undefined; @@ -300,15 +306,13 @@ function normalizeMessageId(messageId: string | undefined): string | undefined { export class AgentManager { private readonly clients = new Map(); private readonly agents = new Map(); + private readonly pendingForegroundRuns = new Map(); private readonly subscribers = new Set(); private readonly maxTimelineItems: number | null; private readonly idFactory: () => string; private readonly registry?: AgentStorage; private readonly previousStatuses = new Map(); private readonly backgroundTasks = new Set>(); - private readonly liveEventPumps = new Map>(); - private readonly liveEventBacklog = new Map(); - private readonly liveEventBacklogFlushTimers = new Map>(); private onAgentAttention?: AgentAttentionCallback; private logger: Logger; @@ -350,6 +354,19 @@ export class AgentManager { return next; } + hasInFlightRun(agentId: string): boolean { + const agent = this.agents.get(agentId); + if (!agent) { + return false; + } + + return ( + agent.lifecycle === "running" || + Boolean(agent.activeForegroundTurnId) || + this.hasPendingForegroundRun(agentId) + ); + } + subscribe(callback: AgentSubscriber, options?: SubscribeOptions): () => void { const targetAgentId = options?.agentId == null ? null : validateAgentId(options.agentId, "subscribe"); @@ -698,7 +715,7 @@ export class AgentManager { overrides?: Partial, ): Promise { let existing = this.requireAgent(agentId); - if (existing.lifecycle === "running" || existing.pendingRun) { + if (this.hasInFlightRun(agentId)) { await this.cancelAgentRun(agentId); existing = this.requireAgent(agentId); } @@ -728,9 +745,15 @@ export class AgentManager { // Remove the existing agent entry before swapping sessions this.agents.delete(agentId); - this.liveEventPumps.delete(agentId); - this.liveEventBacklog.delete(agentId); - this.clearLiveEventBacklogFlushTimer(agentId); + if (existing.unsubscribeSession) { + existing.unsubscribeSession(); + existing.unsubscribeSession = null; + } + for (const waiter of existing.foregroundTurnWaiters) { + this.settleForegroundTurnWaiter(waiter); + } + existing.foregroundTurnWaiters.clear(); + this.settlePendingForegroundRun(agentId); try { await existing.session.close(); } catch (error) { @@ -760,23 +783,36 @@ export class AgentManager { { agentId, lifecycle: agent.lifecycle, - hasPendingRun: Boolean(agent.pendingRun), + activeForegroundTurnId: agent.activeForegroundTurnId, pendingPermissions: agent.pendingPermissions.size, }, "closeAgent: start", ); 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); + if (agent.unsubscribeSession) { + agent.unsubscribeSession(); + agent.unsubscribeSession = null; + } + for (const waiter of agent.foregroundTurnWaiters) { + // Wake up the generator so it can exit the await loop + waiter.callback({ + type: "turn_canceled", + provider: agent.provider, + reason: "agent closed", + turnId: waiter.turnId, + }); + this.settleForegroundTurnWaiter(waiter); + } + agent.foregroundTurnWaiters.clear(); + this.settlePendingForegroundRun(agentId); const session = agent.session; const closedAgent: ManagedAgent = { ...agent, lifecycle: "closed", session: null, - pendingRun: null, + activeForegroundTurnId: null, }; await session.close(); this.emitState(closedAgent); @@ -975,153 +1011,154 @@ export class AgentManager { { agentId, lifecycle: existingAgent.lifecycle, - hasPendingRun: Boolean(existingAgent.pendingRun), + activeForegroundTurnId: existingAgent.activeForegroundTurnId, + hasPendingForegroundRun: this.hasPendingForegroundRun(agentId), promptType: typeof prompt === "string" ? "string" : "structured", hasRunOptions: Boolean(options), }, "streamAgent: requested", ); - if (existingAgent.pendingRun) { + if (existingAgent.activeForegroundTurnId || this.hasPendingForegroundRun(agentId)) { this.logger.trace( { agentId, lifecycle: existingAgent.lifecycle, + hasPendingForegroundRun: this.hasPendingForegroundRun(agentId), }, - "streamAgent: rejected because pendingRun already exists", + "streamAgent: rejected because a foreground run is already in flight", ); throw new Error(`Agent ${agentId} already has an active run`); } const agent = existingAgent as ActiveManagedAgent; - const iterator = agent.session.stream(prompt, options); agent.pendingReplacement = false; agent.lastError = undefined; - let finalized = false; - const finalize = (error?: string) => { - this.logger.trace( - { - agentId, - error, - alreadyFinalized: finalized, - lifecycle: agent.lifecycle, - hasPendingRun: Boolean(agent.pendingRun), - }, - "streamAgent.finalize: invoked", - ); - if (finalized) { - return; - } - finalized = true; - - if (agent.pendingRun !== streamForwarder) { - this.logger.trace( - { - agentId, - error, - lifecycle: agent.lifecycle, - hasPendingRun: Boolean(agent.pendingRun), - }, - "streamAgent.finalize: skipped because pendingRun no longer points to streamForwarder", - ); - if (error) { - agent.lastError = error; - } - return; - } - - const mutableAgent = agent as ActiveManagedAgent; - mutableAgent.pendingRun = null; - const terminalError = error ?? mutableAgent.lastError; - const shouldHoldBusyForReplacement = mutableAgent.pendingReplacement && !terminalError; - mutableAgent.lifecycle = shouldHoldBusyForReplacement - ? "running" - : terminalError - ? "error" - : "idle"; - mutableAgent.lastError = terminalError; - const persistenceHandle = - mutableAgent.session.describePersistence() ?? - (mutableAgent.runtimeInfo?.sessionId - ? { provider: mutableAgent.provider, sessionId: mutableAgent.runtimeInfo.sessionId } - : null); - if (persistenceHandle) { - mutableAgent.persistence = attachPersistenceCwd(persistenceHandle, mutableAgent.cwd); - } - this.logger.trace( - { - agentId, - lifecycle: mutableAgent.lifecycle, - hasPendingRun: Boolean(mutableAgent.pendingRun), - terminalError, - pendingReplacement: mutableAgent.pendingReplacement, - }, - "streamAgent.finalize: applying terminal state", - ); - if (!shouldHoldBusyForReplacement) { - this.touchUpdatedAt(mutableAgent); - this.emitState(mutableAgent); - this.flushLiveEventBacklog(mutableAgent); - } - }; - const self = this; const streamForwarder = (async function* streamForwarder() { - let finalizeError: string | undefined; + const pendingRun = self.createPendingForegroundRun(); + self.pendingForegroundRuns.set(agentId, pendingRun); + + let turnId: string; + let waiter: ForegroundTurnWaiter | null = null; try { - for await (const event of iterator) { - self.handleStreamEvent(agent, event); - if ( - event.type === "turn_started" || - event.type === "turn_completed" || - event.type === "turn_failed" || - event.type === "turn_canceled" - ) { - self.logger.trace( - { - agentId, - eventType: event.type, - lifecycle: agent.lifecycle, - hasPendingRun: Boolean(agent.pendingRun), - }, - "streamAgent: forwarded terminal/turn event", - ); - } - yield event; - } + const result = await agent.session.startTurn(prompt, options); + turnId = result.turnId; } catch (error) { - finalizeError = error instanceof Error ? error.message : "Agent stream failed"; + const errorMsg = error instanceof Error ? error.message : "Failed to start turn"; self.handleStreamEvent(agent, { type: "turn_failed", provider: agent.provider, - error: finalizeError, + error: errorMsg, }); + self.finalizeForegroundTurn(agent); throw error; + } + + pendingRun.started = true; + agent.activeForegroundTurnId = turnId; + agent.lifecycle = "running"; + self.touchUpdatedAt(agent); + self.emitState(agent); + self.logger.trace( + { + agentId, + lifecycle: agent.lifecycle, + activeForegroundTurnId: agent.activeForegroundTurnId, + }, + "streamAgent: started", + ); + + // Create a pushable queue for this foreground turn + const queue: AgentStreamEvent[] = []; + let queueResolve: (() => void) | null = null; + let done = false; + let resolveSettled!: () => void; + const settledPromise = new Promise((resolve) => { + resolveSettled = resolve; + }); + + waiter = { + turnId, + settled: false, + settledPromise, + resolveSettled, + callback: (event: AgentStreamEvent) => { + queue.push(event); + if (queueResolve) { + queueResolve(); + queueResolve = null; + } + }, + }; + agent.foregroundTurnWaiters.add(waiter); + + try { + while (!done) { + while (queue.length > 0) { + const event = queue.shift()!; + yield event; + if (isTurnTerminalEvent(event)) { + done = true; + break; + } + } + if (!done && queue.length === 0) { + if (waiter.settled) { + break; + } + await new Promise((resolve) => { + queueResolve = resolve; + }); + } + } } finally { - await self.refreshRuntimeInfo(agent); - // Ensure we always clear the pending run and emit state when the stream is - // cancelled early (e.g., via .return()) so the UI can exit the cancelling state. - finalize(finalizeError); + if (waiter) { + agent.foregroundTurnWaiters.delete(waiter); + self.settleForegroundTurnWaiter(waiter); + } + self.settlePendingForegroundRun(agentId, pendingRun.token); + if (!agent.activeForegroundTurnId) { + await self.refreshRuntimeInfo(agent); + } } })(); - agent.pendingRun = streamForwarder; - agent.lifecycle = "running"; - // Bump updatedAt when lifecycle changes so downstream consumers can - // deterministically order idle->running transitions. - this.touchUpdatedAt(agent); - self.emitState(agent); + return streamForwarder; + } + + private finalizeForegroundTurn(agent: ActiveManagedAgent): void { + const mutableAgent = agent as ActiveManagedAgent; + mutableAgent.activeForegroundTurnId = null; + const terminalError = mutableAgent.lastError; + const shouldHoldBusyForReplacement = mutableAgent.pendingReplacement && !terminalError; + mutableAgent.lifecycle = shouldHoldBusyForReplacement + ? "running" + : terminalError + ? "error" + : "idle"; + const persistenceHandle = + mutableAgent.session.describePersistence() ?? + (mutableAgent.runtimeInfo?.sessionId + ? { provider: mutableAgent.provider, sessionId: mutableAgent.runtimeInfo.sessionId } + : null); + if (persistenceHandle) { + mutableAgent.persistence = attachPersistenceCwd(persistenceHandle, mutableAgent.cwd); + } this.logger.trace( { - agentId, - lifecycle: agent.lifecycle, - hasPendingRun: Boolean(agent.pendingRun), + agentId: agent.id, + lifecycle: mutableAgent.lifecycle, + terminalError, + pendingReplacement: mutableAgent.pendingReplacement, }, - "streamAgent: started", + "finalizeForegroundTurn: applying terminal state", ); - - return streamForwarder; + if (!shouldHoldBusyForReplacement) { + this.touchUpdatedAt(mutableAgent); + this.emitState(mutableAgent); + } } replaceAgentRun( @@ -1130,7 +1167,11 @@ export class AgentManager { options?: AgentRunOptions, ): AsyncGenerator { const snapshot = this.requireAgent(agentId); - if (snapshot.lifecycle !== "running" && !snapshot.pendingRun) { + if ( + snapshot.lifecycle !== "running" && + !snapshot.activeForegroundTurnId && + !this.hasPendingForegroundRun(agentId) + ) { return this.streamAgent(agentId, prompt, options); } @@ -1150,15 +1191,10 @@ export class AgentManager { if (latest) { const latestActive = latest as ActiveManagedAgent; latestActive.pendingReplacement = false; - const hasForegroundRun = Boolean( - (latestActive as { pendingRun: AsyncGenerator | null }).pendingRun, - ); - const lifecycle = (latestActive as { lifecycle: AgentLifecycleStatus }).lifecycle; - if (!hasForegroundRun && lifecycle === "running") { - latestActive.lifecycle = "idle"; + if (!latestActive.activeForegroundTurnId && latestActive.lifecycle === "running") { + (latestActive as ActiveManagedAgent).lifecycle = "idle"; self.touchUpdatedAt(latestActive); self.emitState(latestActive); - self.flushLiveEventBacklog(latestActive); } } throw error; @@ -1172,11 +1208,15 @@ export class AgentManager { throw new Error(`Agent ${agentId} not found`); } - if (snapshot.lifecycle === "running" && !snapshot.pendingReplacement) { + const pendingRun = this.getPendingForegroundRun(agentId); + if ( + (snapshot.lifecycle === "running" || pendingRun?.started) && + !snapshot.pendingReplacement + ) { return; } - if ((!("pendingRun" in snapshot) || !snapshot.pendingRun) && !snapshot.pendingReplacement) { + if (!snapshot.activeForegroundTurnId && !pendingRun && !snapshot.pendingReplacement) { throw new Error(`Agent ${agentId} has no pending run`); } @@ -1228,29 +1268,50 @@ export class AgentManager { options.signal.addEventListener("abort", abortHandler, { once: true }); } + const checkCurrentState = () => { + const current = this.getAgent(agentId); + if (!current) { + finishErr(new Error(`Agent ${agentId} not found`)); + return true; + } + + const currentPendingRun = this.getPendingForegroundRun(agentId); + if ( + (current.lifecycle === "running" || currentPendingRun?.started) && + !current.pendingReplacement + ) { + finishOk(); + return true; + } + + if (current.lifecycle === "error" && !currentPendingRun?.started) { + finishErr(new Error(current.lastError ?? `Agent ${agentId} failed to start`)); + return true; + } + + if ( + !currentPendingRun && + !current.activeForegroundTurnId && + !current.pendingReplacement + ) { + finishErr(new Error(`Agent ${agentId} run finished before starting`)); + return true; + } + + return false; + }; + unsubscribe = this.subscribe( (event) => { - if (event.type === "agent_state") { - if (event.agent.id !== agentId) { - return; - } - if (event.agent.lifecycle === "running" && !event.agent.pendingReplacement) { - finishOk(); - return; - } - if (event.agent.lifecycle === "error") { - finishErr(new Error(event.agent.lastError ?? `Agent ${agentId} failed to start`)); - return; - } - if ("pendingRun" in event.agent && !event.agent.pendingRun) { - finishErr(new Error(`Agent ${agentId} run finished before starting`)); - return; - } + if (event.type !== "agent_state" || event.agent.id !== agentId) { return; } + checkCurrentState(); }, - { agentId, replayState: true }, + { agentId, replayState: false }, ); + + checkCurrentState(); }); } @@ -1276,11 +1337,13 @@ export class AgentManager { async cancelAgentRun(agentId: string): Promise { const agent = this.requireAgent(agentId); - const pendingRun = agent.pendingRun; - const hasForegroundPendingRun = Boolean(pendingRun) && typeof pendingRun?.return === "function"; - const isAutonomousRunning = agent.lifecycle === "running" && !hasForegroundPendingRun; + const pendingRun = this.getPendingForegroundRun(agentId); + const foregroundTurnId = agent.activeForegroundTurnId; + const hasForegroundTurn = Boolean(foregroundTurnId); + const isAutonomousRunning = + agent.lifecycle === "running" && !hasForegroundTurn && !pendingRun; - if (!hasForegroundPendingRun && !isAutonomousRunning) { + if (!hasForegroundTurn && !isAutonomousRunning && !pendingRun) { return false; } @@ -1290,20 +1353,42 @@ export class AgentManager { this.logger.error({ err: error, agentId }, "Failed to interrupt session"); } - if (hasForegroundPendingRun && pendingRun) { - try { - // Await the generator's .return() to ensure the finally block runs - // and pendingRun is properly cleared before we return. - await pendingRun.return(undefined as unknown as AgentStreamEvent); - } catch (error) { - this.logger.error({ err: error, agentId }, "Failed to cancel run"); - throw error; + // The interrupt will produce a turn_canceled/turn_failed event via subscribe(), + // which flows through the session event dispatcher and settles the foreground turn waiter. + // Wait briefly for the event to propagate if there's an active foreground turn. + if (foregroundTurnId) { + const waiter = Array.from(agent.foregroundTurnWaiters).find( + (candidate) => candidate.turnId === foregroundTurnId, + ); + const timeout = new Promise((resolve) => setTimeout(resolve, 2000)); + if (waiter) { + await Promise.race([waiter.settledPromise, timeout]); + } else if (agent.activeForegroundTurnId === foregroundTurnId) { + await Promise.race([ + new Promise((resolve) => { + const unsubscribe = this.subscribe( + (event) => { + if ( + event.type === "agent_state" && + event.agent.id === agentId && + !event.agent.activeForegroundTurnId + ) { + unsubscribe(); + resolve(); + } + }, + { agentId, replayState: false }, + ); + }), + timeout, + ]); } + } else if (pendingRun) { + const timeout = new Promise((resolve) => setTimeout(resolve, 2000)); + await Promise.race([pendingRun.settledPromise, timeout]); } // Clear any pending permissions that weren't cleaned up by handleStreamEvent. - // Due to microtask ordering, .return() may force the generator to its finally - // block before it consumes the turn_canceled event, skipping our cleanup code. if (agent.pendingPermissions.size > 0) { for (const [requestId] of agent.pendingPermissions) { this.dispatchStream(agent.id, { @@ -1377,7 +1462,8 @@ export class AgentManager { throw new Error(`Agent ${agentId} not found`); } - const hasPendingRun = "pendingRun" in snapshot && Boolean(snapshot.pendingRun); + const hasForegroundTurn = + Boolean(snapshot.activeForegroundTurnId) || this.hasPendingForegroundRun(agentId); const immediatePermission = this.peekPendingPermission(snapshot); if (immediatePermission) { @@ -1389,7 +1475,7 @@ export class AgentManager { } const initialStatus = snapshot.lifecycle; - const initialBusy = isAgentBusy(initialStatus) || hasPendingRun; + const initialBusy = isAgentBusy(initialStatus) || hasForegroundTurn; const waitForActive = options?.waitForActive ?? false; if (!waitForActive && !initialBusy) { return { @@ -1398,7 +1484,7 @@ export class AgentManager { lastMessage: this.getLastAssistantMessage(agentId), }; } - if (waitForActive && !initialBusy && !hasPendingRun) { + if (waitForActive && !initialBusy && !hasForegroundTurn) { return { status: initialStatus, permission: null, @@ -1419,7 +1505,7 @@ export class AgentManager { } let currentStatus: AgentLifecycleStatus = initialStatus; - let hasStarted = initialBusy || hasPendingRun; + let hasStarted = initialBusy || hasForegroundTurn; let terminalStatusOverride: AgentLifecycleStatus | null = null; // Bug #3 Fix: Declare unsubscribe and abortHandler upfront so cleanup can reference them @@ -1569,7 +1655,9 @@ export class AgentManager { currentModeId: null, pendingPermissions: new Map(), pendingReplacement: false, - pendingRun: null, + activeForegroundTurnId: null, + foregroundTurnWaiters: new Set(), + unsubscribeSession: null, timeline: initialTimeline, timelineRows: initialTimelineRows, timelineEpoch: options?.timelineEpoch ?? randomUUID(), @@ -1606,10 +1694,91 @@ export class AgentManager { managed.lifecycle = "idle"; await this.persistSnapshot(managed); this.emitState(managed); - this.startLiveEventPump(managed); + this.subscribeToSession(managed); return { ...managed }; } + private subscribeToSession(agent: ActiveManagedAgent): void { + if (agent.unsubscribeSession) { + return; + } + const agentId = agent.id; + const unsubscribe = agent.session.subscribe((event: AgentStreamEvent) => { + const current = this.agents.get(agentId); + if (!current) { + return; + } + this.dispatchSessionEvent(current, event); + }); + agent.unsubscribeSession = unsubscribe; + } + + private dispatchSessionEvent(agent: ActiveManagedAgent, event: AgentStreamEvent): void { + const turnId = (event as { turnId?: string }).turnId; + const matchingWaiters = + turnId == null + ? [] + : Array.from(agent.foregroundTurnWaiters).filter( + (waiter) => waiter.turnId === turnId && !waiter.settled, + ); + + this.handleStreamEvent(agent, event); + + for (const waiter of matchingWaiters) { + waiter.callback(event); + if (isTurnTerminalEvent(event)) { + this.settleForegroundTurnWaiter(waiter); + } + } + } + + private settleForegroundTurnWaiter(waiter: ForegroundTurnWaiter): void { + if (waiter.settled) { + return; + } + waiter.settled = true; + waiter.resolveSettled(); + } + + private createPendingForegroundRun(): PendingForegroundRun { + let resolveSettled!: () => void; + const settledPromise = new Promise((resolve) => { + resolveSettled = resolve; + }); + return { + token: randomUUID(), + started: false, + settled: false, + settledPromise, + resolveSettled, + }; + } + + private getPendingForegroundRun(agentId: string): PendingForegroundRun | null { + return this.pendingForegroundRuns.get(agentId) ?? null; + } + + private hasPendingForegroundRun(agentId: string): boolean { + return this.pendingForegroundRuns.has(agentId); + } + + private settlePendingForegroundRun(agentId: string, token?: string): void { + const pendingRun = this.pendingForegroundRuns.get(agentId); + if (!pendingRun) { + return; + } + if (token && pendingRun.token !== token) { + return; + } + + this.pendingForegroundRuns.delete(agentId); + if (pendingRun.settled) { + return; + } + pendingRun.settled = true; + pendingRun.resolveSettled(); + } + private async resolveInitialPersistedTitle( agentId: string, config: AgentSessionConfig, @@ -1762,6 +1931,11 @@ export class AgentManager { canonicalUserMessagesById?: ReadonlyMap; }, ): void { + const eventTurnId = (event as { turnId?: string }).turnId; + const isForegroundEvent = Boolean( + eventTurnId && agent.activeForegroundTurnId === eventTurnId, + ); + // Only update timestamp for live events, not history replay if (!options?.fromHistory) { this.touchUpdatedAt(agent); @@ -1771,8 +1945,6 @@ export class AgentManager { switch (event.type) { case "thread_started": - // Update persistence with the new session ID from the provider. - // persistence.sessionId is the single source of truth for session identity. { const previousSessionId = agent.persistence?.sessionId ?? null; const handle = agent.session.describePersistence(); @@ -1786,9 +1958,6 @@ export class AgentManager { break; case "timeline": // Skip provider-replayed user_message items during history hydration. - // These are already canonically recorded by recordUserMessage() and replaying them would - // create duplicates. Match by messageId (not text) to avoid dropping legitimate - // provider-origin messages that happen to reuse the same text. if (options?.fromHistory && event.item.type === "user_message") { const eventMessageId = normalizeMessageId(event.item.messageId); if (eventMessageId) { @@ -1798,8 +1967,27 @@ export class AgentManager { } } } - if (this.shouldSuppressLiveUserMessageEcho(agent, event, options)) { - break; + // Suppress user_message echoes for the active foreground turn — + // these are already recorded by recordUserMessage(). + if ( + !options?.fromHistory && + event.item.type === "user_message" && + isForegroundEvent + ) { + const eventMessageId = normalizeMessageId(event.item.messageId); + const eventText = event.item.text; + if (eventMessageId) { + const alreadyRecorded = agent.timelineRows.some((row) => { + if (row.item.type !== "user_message") { + return false; + } + const rowMessageId = normalizeMessageId(row.item.messageId); + return rowMessageId === eventMessageId && row.item.text === eventText; + }); + if (alreadyRecorded) { + break; + } + } } timelineRow = this.recordTimeline(agent, event.item); if (!options?.fromHistory && event.item.type === "user_message") { @@ -1812,20 +2000,34 @@ export class AgentManager { { agentId: agent.id, lifecycle: agent.lifecycle, - hasPendingRun: Boolean(agent.pendingRun), + activeForegroundTurnId: agent.activeForegroundTurnId, + eventTurnId, }, "handleStreamEvent: turn_completed", ); agent.lastUsage = event.usage; agent.lastError = undefined; - if (!agent.pendingRun && agent.lifecycle !== "idle") { + // For autonomous turns (not foreground), transition to idle + if (!isForegroundEvent && agent.lifecycle !== "idle") { (agent as ActiveManagedAgent).lifecycle = "idle"; this.emitState(agent); } void this.refreshRuntimeInfo(agent); break; case "turn_failed": - agent.lifecycle = "error"; + this.logger.trace( + { + agentId: agent.id, + lifecycle: agent.lifecycle, + activeForegroundTurnId: agent.activeForegroundTurnId, + eventTurnId, + }, + "handleStreamEvent: turn_failed", + ); + // For autonomous turns, set error state directly + if (!isForegroundEvent) { + agent.lifecycle = "error"; + } agent.lastError = event.error; this.appendSystemErrorTimelineMessage( agent, @@ -1844,18 +2046,22 @@ export class AgentManager { }); } } - this.emitState(agent); + if (!isForegroundEvent) { + this.emitState(agent); + } break; case "turn_canceled": this.logger.trace( { agentId: agent.id, lifecycle: agent.lifecycle, - hasPendingRun: Boolean(agent.pendingRun), + activeForegroundTurnId: agent.activeForegroundTurnId, + eventTurnId, }, "handleStreamEvent: turn_canceled", ); - if (!agent.pendingRun) { + // For autonomous turns, transition to idle + if (!isForegroundEvent) { (agent as ActiveManagedAgent).lifecycle = "idle"; } agent.lastError = undefined; @@ -1870,18 +2076,22 @@ export class AgentManager { }); } } - this.emitState(agent); + if (!isForegroundEvent) { + this.emitState(agent); + } break; case "turn_started": this.logger.trace( { agentId: agent.id, lifecycle: agent.lifecycle, - hasPendingRun: Boolean(agent.pendingRun), + activeForegroundTurnId: agent.activeForegroundTurnId, + eventTurnId, }, "handleStreamEvent: turn_started", ); - if (!agent.pendingRun) { + // For autonomous turn_started (no foreground match), set running + if (!isForegroundEvent) { (agent as ActiveManagedAgent).lifecycle = "running"; this.emitState(agent); } @@ -1904,6 +2114,10 @@ export class AgentManager { break; } + if (!options?.fromHistory && isForegroundEvent && isTurnTerminalEvent(event)) { + this.finalizeForegroundTurn(agent); + } + // Skip dispatching individual stream events during history replay. if (!options?.fromHistory) { this.dispatchStream( @@ -1975,35 +2189,6 @@ export class AgentManager { return parts.join("\n\n"); } - private shouldSuppressLiveUserMessageEcho( - agent: ActiveManagedAgent, - event: AgentStreamEvent, - options?: { - fromHistory?: boolean; - canonicalUserMessagesById?: ReadonlyMap; - }, - ): boolean { - if (options?.fromHistory || event.type !== "timeline") { - return false; - } - if (event.item.type !== "user_message" || !agent.pendingRun) { - return false; - } - const eventMessageId = normalizeMessageId(event.item.messageId); - const eventText = event.item.text; - if (!eventMessageId) { - return false; - } - return agent.timelineRows.some((row) => { - const rowItem = row.item; - if (rowItem.type !== "user_message") { - return false; - } - const rowMessageId = normalizeMessageId(rowItem.messageId); - return rowMessageId === eventMessageId && rowItem.text === eventText; - }); - } - private recordTimeline(agent: ManagedAgent, item: AgentTimelineItem): AgentTimelineRow { const timelineState = this.ensureTimelineState(agent); const row: AgentTimelineRow = { @@ -2216,154 +2401,4 @@ export class AgentManager { return agent; } - private startLiveEventPump(agent: ActiveManagedAgent): void { - if (!supportsLiveEventStream(agent.session)) { - return; - } - if (this.liveEventPumps.has(agent.id)) { - return; - } - const pump = (async () => { - while (true) { - const current = this.agents.get(agent.id); - if (!current) { - return; - } - if (!supportsLiveEventStream(current.session)) { - return; - } - - try { - for await (const event of current.session.streamLiveEvents()) { - const latest = this.agents.get(agent.id); - if (!latest) { - return; - } - // Keep consuming provider events even during an active foreground run, - // then replay them immediately once that run settles. - if (latest.pendingRun) { - this.logger.trace( - { - agentId: latest.id, - eventType: event.type, - backlogSize: (this.liveEventBacklog.get(latest.id)?.length ?? 0) + 1, - }, - "Live event pump: queued event because pendingRun is active", - ); - this.enqueueLiveEvent(latest.id, event); - continue; - } - this.flushLiveEventBacklog(latest); - this.handleStreamEvent(latest, event); - } - this.logger.warn({ agentId: agent.id }, "Live event pump stream ended; restarting"); - } catch (error) { - this.logger.warn({ err: error, agentId: agent.id }, "Live event pump failed"); - } - - // Keep pump alive unless the agent is gone. - await new Promise((resolve) => setTimeout(resolve, 250)); - const latest = this.agents.get(agent.id); - if (!latest) { - return; - } - if (!latest.pendingRun) { - this.flushLiveEventBacklog(latest); - } - } - })(); - this.liveEventPumps.set(agent.id, pump); - pump.finally(() => { - const current = this.liveEventPumps.get(agent.id); - if (current === pump) { - this.liveEventPumps.delete(agent.id); - } - }); - } - - private enqueueLiveEvent(agentId: string, event: AgentStreamEvent): void { - const existing = this.liveEventBacklog.get(agentId); - if (existing) { - existing.push(event); - return; - } - 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; - } - const pending = this.liveEventBacklog.get(agent.id); - 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); - } } diff --git a/packages/server/src/server/agent/agent-projections.test.ts b/packages/server/src/server/agent/agent-projections.test.ts index 7cf3a2792..d8e81201d 100644 --- a/packages/server/src/server/agent/agent-projections.test.ts +++ b/packages/server/src/server/agent/agent-projections.test.ts @@ -40,8 +40,8 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent } = overrides; const sessionValue = lifecycle === "closed" ? null : (restOverrides.session ?? ({} as any)); - const pendingRunValue = - restOverrides.pendingRun ?? (lifecycle === "running" ? (async function* noop() {})() : null); + const activeForegroundTurnIdValue = + restOverrides.activeForegroundTurnId ?? (lifecycle === "running" ? "test-turn-id" : null); const lastErrorValue = restOverrides.lastError ?? (lifecycle === "error" ? "encountered error" : undefined); @@ -69,7 +69,9 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent ], currentModeId: "plan", pendingPermissions: pendingPermissionsOverride ?? new Map(), - pendingRun: pendingRunValue as ManagedAgent["pendingRun"], + activeForegroundTurnId: activeForegroundTurnIdValue, + foregroundTurnWaiters: new Set(), + unsubscribeSession: null, timeline: [], runtimeInfo: { provider: "claude", diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 681c8052c..a4440aa81 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -259,23 +259,25 @@ export type AgentTimelineItem = export type AgentStreamEvent = | { type: "thread_started"; sessionId: string; provider: AgentProvider } - | { type: "turn_started"; provider: AgentProvider } - | { type: "turn_completed"; provider: AgentProvider; usage?: AgentUsage } + | { type: "turn_started"; provider: AgentProvider; turnId?: string } + | { type: "turn_completed"; provider: AgentProvider; usage?: AgentUsage; turnId?: string } | { type: "turn_failed"; provider: AgentProvider; error: string; code?: string; diagnostic?: string; + turnId?: string; } - | { type: "turn_canceled"; provider: AgentProvider; reason: string } - | { type: "timeline"; item: AgentTimelineItem; provider: AgentProvider } - | { type: "permission_requested"; provider: AgentProvider; request: AgentPermissionRequest } + | { type: "turn_canceled"; provider: AgentProvider; reason: string; turnId?: string } + | { type: "timeline"; item: AgentTimelineItem; provider: AgentProvider; turnId?: string } + | { type: "permission_requested"; provider: AgentProvider; request: AgentPermissionRequest; turnId?: string } | { type: "permission_resolved"; provider: AgentProvider; requestId: string; resolution: AgentPermissionResponse; + turnId?: string; } | { type: "attention_required"; @@ -391,7 +393,8 @@ export interface AgentSession { readonly id: string | null; readonly capabilities: AgentCapabilityFlags; run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise; - stream(prompt: AgentPromptInput, options?: AgentRunOptions): AsyncGenerator; + startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>; + subscribe(callback: (event: AgentStreamEvent) => void): () => void; streamHistory(): AsyncGenerator; getRuntimeInfo(): Promise; getAvailableModes(): Promise; @@ -402,19 +405,8 @@ export interface AgentSession { describePersistence(): AgentPersistenceHandle | null; interrupt(): Promise; close(): Promise; - /** - * List available slash commands for this session. - * Commands are provider-specific - Claude supports skills and built-in commands. - */ listCommands?(): Promise; - /** - * Update the model used for subsequent turns (if supported by provider). - */ setModel?(modelId: string | null): Promise; - /** - * Update the thinking/effort setting used for subsequent turns (if supported). - * Normalized to a string option id (provider-specific interpretation). - */ setThinkingOption?(thinkingOptionId: string | null): Promise; } diff --git a/packages/server/src/server/agent/agent-storage.test.ts b/packages/server/src/server/agent/agent-storage.test.ts index 303e07758..43f0ab527 100644 --- a/packages/server/src/server/agent/agent-storage.test.ts +++ b/packages/server/src/server/agent/agent-storage.test.ts @@ -15,12 +15,12 @@ import type { type ManagedAgentOverrides = Omit< Partial, - "config" | "pendingPermissions" | "session" | "pendingRun" + "config" | "pendingPermissions" | "session" | "activeForegroundTurnId" > & { config?: Partial; pendingPermissions?: Map; session?: AgentSession | null; - pendingRun?: ManagedAgent["pendingRun"]; + activeForegroundTurnId?: string | null; runtimeInfo?: ManagedAgent["runtimeInfo"]; attention?: ManagedAgent["attention"]; }; @@ -42,8 +42,8 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent mcpServers: configOverrides.mcpServers, }; const session = lifecycle === "closed" ? null : (overrides.session ?? ({} as AgentSession)); - const pendingRun = - overrides.pendingRun ?? (lifecycle === "running" ? (async function* noop() {})() : null); + const activeForegroundTurnId = + overrides.activeForegroundTurnId ?? (lifecycle === "running" ? "test-turn-id" : null); const agent: ManagedAgent = { id: overrides.id ?? "agent-test", @@ -65,7 +65,9 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent availableModes: overrides.availableModes ?? [], currentModeId: overrides.currentModeId ?? config.modeId ?? null, pendingPermissions: overrides.pendingPermissions ?? new Map(), - pendingRun, + activeForegroundTurnId, + foregroundTurnWaiters: new Set(), + unsubscribeSession: null, timeline: overrides.timeline ?? [], attention: overrides.attention ?? { requiresAttention: false }, runtimeInfo: overrides.runtimeInfo ?? { diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 742e06d1c..60f20cf5a 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -199,10 +199,7 @@ function startAgentRun( logger: Logger, options?: { replaceRunning?: boolean }, ): void { - const snapshot = agentManager.getAgent(agentId); - const shouldReplace = - options?.replaceRunning && - Boolean(snapshot && (snapshot.lifecycle === "running" || snapshot.pendingRun)); + const shouldReplace = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId)); const iterator = shouldReplace ? agentManager.replaceAgentRun(agentId, prompt) : agentManager.streamAgent(agentId, prompt); @@ -696,7 +693,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom throw new Error(`Agent ${agentId} not found`); } - if (snapshot.lifecycle === "running" || snapshot.pendingRun) { + if (agentManager.hasInFlightRun(agentId)) { waitTracker.cancel(agentId, "Agent run interrupted by new prompt"); } diff --git a/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts b/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts new file mode 100644 index 000000000..cab8286aa --- /dev/null +++ b/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts @@ -0,0 +1,477 @@ +/** + * Integration tests for the agent event stream redesign (Unit 3). + * + * These tests verify the behavioral guarantees of the new provider contract + * (`startTurn` + `subscribe`) as specified in docs/design/agent-event-stream-redesign.md. + * + * All tests use REAL Claude SDK sessions — no mocks. + * + * CREDENTIALS: These tests require a running `claude` CLI and either + * CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY in the environment. + * They are skipped automatically when credentials are unavailable. + */ +import { describe, expect, test } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; + +import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js"; +import { isCommandAvailable } from "../../provider-launch-config.js"; +import { ClaudeAgentClient } from "../claude-agent.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const logger = pino({ level: "silent" }); +const client = new ClaudeAgentClient({ logger }); +const hasClaudeCredentials = + !!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY; +const canRun = isCommandAvailable("claude") && hasClaudeCredentials; + +function tmpCwd(prefix: string): string { + return mkdtempSync(path.join(tmpdir(), prefix)); +} + +function isTerminalEvent(event: AgentStreamEvent): boolean { + return ( + event.type === "turn_completed" || + event.type === "turn_failed" || + event.type === "turn_canceled" + ); +} + +// turnId is optional on AgentStreamEvent — this narrows to events where it's present. +type EventWithTurnId = AgentStreamEvent & { turnId: string }; + +function hasTurnId(event: AgentStreamEvent): event is EventWithTurnId { + return "turnId" in event && typeof (event as Record).turnId === "string"; +} + +function eventsForTurn(events: AgentStreamEvent[], turnId: string): AgentStreamEvent[] { + return events.filter((e) => hasTurnId(e) && e.turnId === turnId); +} + +function userMessagesWithText(events: AgentStreamEvent[], text: string): AgentStreamEvent[] { + return events.filter( + (e) => + e.type === "timeline" && + e.item.type === "user_message" && + e.item.text === text, + ); +} + +async function createSession(params?: { + cwdPrefix?: string; +}): Promise<{ cwd: string; session: AgentSession }> { + const cwd = tmpCwd(params?.cwdPrefix ?? "event-stream-integration-"); + const session = await client.createSession({ + provider: "claude", + cwd, + title: "event-stream integration", + modeId: "acceptEdits", + model: "haiku", + }); + return { cwd, session }; +} + +async function cleanupSession(handle: { cwd: string; session: AgentSession }): Promise { + await handle.session.close().catch(() => undefined); + rmSync(handle.cwd, { recursive: true, force: true }); +} + +async function startTurnAndCollectEvents( + session: AgentSession, + prompt: string, + options?: { extraMs?: number; timeoutMs?: number }, +): Promise<{ turnId: string; events: AgentStreamEvent[] }> { + const { extraMs = 0, timeoutMs = 45_000 } = options ?? {}; + + return await new Promise((resolve, reject) => { + const events: AgentStreamEvent[] = []; + let turnId: string | null = null; + let settled = false; + + const timeout = setTimeout(() => { + unsubscribe(); + reject(new Error(`Timed out after ${timeoutMs}ms waiting for terminal event`)); + }, timeoutMs); + + const finish = () => { + if (settled || !turnId) { + return; + } + settled = true; + clearTimeout(timeout); + unsubscribe(); + resolve({ turnId, events }); + }; + + const unsubscribe = session.subscribe((event) => { + events.push(event); + if (!turnId) { + return; + } + if (!isTerminalEvent(event) || !hasTurnId(event) || event.turnId !== turnId) { + return; + } + if (extraMs > 0) { + setTimeout(finish, extraMs); + return; + } + finish(); + }); + + void session + .startTurn(prompt) + .then((result) => { + turnId = result.turnId; + }) + .catch((error) => { + clearTimeout(timeout); + unsubscribe(); + reject(error); + }); + }); +} + +// --------------------------------------------------------------------------- +// Invariant assertions — run after every test +// --------------------------------------------------------------------------- + +function assertInvariants(events: AgentStreamEvent[], foregroundTurnIds: string[]): void { + // Invariant 1: For each foreground turnId, at most ONE user_message event. + // The manager records foreground prompts separately, so provider echoes may be suppressed. + for (const turnId of foregroundTurnIds) { + const turnEvents = eventsForTurn(events, turnId); + const userMsgs = turnEvents.filter( + (e) => e.type === "timeline" && e.item.type === "user_message", + ); + expect( + userMsgs.length, + `Expected at most 1 user_message for turnId ${turnId}, got ${userMsgs.length}`, + ).toBeLessThanOrEqual(1); + } + + // Invariant 2: Every turn_started has exactly one matching terminal + const turnStartedIds = events + .filter((e) => e.type === "turn_started" && hasTurnId(e)) + .map((e) => (e as EventWithTurnId).turnId); + + for (const turnId of turnStartedIds) { + const terminals = eventsForTurn(events, turnId).filter(isTerminalEvent); + expect( + terminals.length, + `Expected exactly 1 terminal for turnId ${turnId}, got ${terminals.length}`, + ).toBe(1); + } + + // Invariant 3: After terminal for a foreground turnId, no later event with + // that turnId appears (would indicate stale routing to autonomous) + for (const turnId of foregroundTurnIds) { + const allWithTurn = events + .map((e, i) => ({ event: e, index: i })) + .filter(({ event }) => hasTurnId(event) && event.turnId === turnId); + + const terminalEntry = allWithTurn.find(({ event }) => isTerminalEvent(event)); + if (!terminalEntry) continue; + + const afterTerminal = allWithTurn.filter(({ index }) => index > terminalEntry.index); + expect( + afterTerminal.length, + `No events should appear for foreground turnId ${turnId} after terminal`, + ).toBe(0); + } + + // Invariant 4: Autonomous turns have distinct turnIds from foreground turns + const allTurnIds = new Set( + events.filter(hasTurnId).map((e) => e.turnId), + ); + const autonomousTurnIds = [...allTurnIds].filter( + (id) => !foregroundTurnIds.includes(id), + ); + for (const autoId of autonomousTurnIds) { + expect(foregroundTurnIds).not.toContain(autoId); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("Agent event stream redesign — integration", () => { + test.skipIf(!canRun)("Test 1: Basic foreground turn", async () => { + const handle = await createSession({ cwdPrefix: "event-stream-basic-" }); + + try { + const { turnId, events } = await startTurnAndCollectEvents( + handle.session, + "respond with just the word hello", + ); + + const turnStarted = events.find( + (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId, + ); + expect(turnStarted).toBeDefined(); + + const terminal = events.find( + (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId, + ); + expect(terminal).toBeDefined(); + + assertInvariants(events, [turnId]); + } finally { + await cleanupSession(handle); + } + }, 60_000); + + test.skipIf(!canRun)("Test 2: No duplicate user_messages — THE BUG", async () => { + const handle = await createSession({ cwdPrefix: "event-stream-dedup-" }); + + try { + const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", { + extraMs: 3_000, + }); + + expect(userMessagesWithText(events, "say hi").length).toBeLessThanOrEqual(1); + + // No turn_started after terminal for the same turnId + const terminalIdx = events.findIndex( + (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId, + ); + const staleTurnStarted = events.slice(terminalIdx + 1).filter( + (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId, + ); + expect(staleTurnStarted.length).toBe(0); + + assertInvariants(events, [turnId]); + } finally { + await cleanupSession(handle); + } + }, 60_000); + + test.skipIf(!canRun)("Test 3: Lifecycle doesn't get stuck in running", async () => { + const handle = await createSession({ cwdPrefix: "event-stream-lifecycle-" }); + + try { + const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", { + extraMs: 3_000, + }); + + const terminalIdx = events.findIndex( + (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId, + ); + const afterTerminal = events.slice(terminalIdx + 1); + + // No subsequent turn_started for same turnId + expect( + afterTerminal.filter( + (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId, + ).length, + ).toBe(0); + + // Any turn_started after terminal must have a different turnId + for (const ts of afterTerminal.filter((e) => e.type === "turn_started" && hasTurnId(e))) { + expect((ts as EventWithTurnId).turnId).not.toBe(turnId); + } + + assertInvariants(events, [turnId]); + } finally { + await cleanupSession(handle); + } + }, 60_000); + + test.skipIf(!canRun)("Test 4: Autonomous run", async () => { + const handle = await createSession({ cwdPrefix: "event-stream-autonomous-" }); + const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`; + + try { + const { turnId: fgTurnId, events } = await startTurnAndCollectEvents( + handle.session, + [ + "Use the Task tool to start a background sub-agent.", + "In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE", + "Do not wait for task completion.", + "Reply immediately with exactly: SPAWNED", + `When the background task completes later, reply with exactly: ${autonomousWakeToken}`, + ].join(" "), + { + extraMs: 10_000, + timeoutMs: 60_000, + }, + ); + + const fgTerminalIdx = events.findIndex( + (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === fgTurnId, + ); + const afterForeground = events.slice(fgTerminalIdx + 1); + + // Autonomous turn_started with a different turnId + const autoStarts = afterForeground.filter( + (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId, + ) as EventWithTurnId[]; + if (autoStarts.length === 0) { + assertInvariants(events, [fgTurnId]); + return; + } + + const autoTurnId = autoStarts[0]!.turnId; + expect(fgTurnId).not.toBe(autoTurnId); + + // Autonomous turn reaches terminal + expect( + afterForeground.find( + (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === autoTurnId, + ), + ).toBeDefined(); + + assertInvariants(events, [fgTurnId]); + } finally { + await cleanupSession(handle); + } + }, 90_000); + + test.skipIf(!canRun)("Test 5: Interruption", async () => { + const handle = await createSession({ cwdPrefix: "event-stream-interrupt-" }); + + try { + const { turnId } = await handle.session.startTurn( + "write a very long essay about the history of computing", + ); + + // Use a single subscription to avoid missing events between unsubscribe/resubscribe + const events = await new Promise((resolve, reject) => { + const collected: AgentStreamEvent[] = []; + let interrupted = false; + + const timeout = setTimeout(() => { + unsubscribe(); + reject(new Error("Timed out after 45000ms waiting for terminal event")); + }, 45_000); + + const unsubscribe = handle.session.subscribe((event) => { + collected.push(event); + + // Once we see turn_started, fire the interrupt + if ( + !interrupted && + event.type === "turn_started" && + hasTurnId(event) && + event.turnId === turnId + ) { + interrupted = true; + handle.session.interrupt().catch(() => undefined); + } + + // Resolve when we get a terminal event for this turn + if (isTerminalEvent(event) && hasTurnId(event) && event.turnId === turnId) { + clearTimeout(timeout); + unsubscribe(); + resolve(collected); + } + }); + }); + + // turn_canceled or turn_failed arrives for that turnId + const terminal = events.find( + (e) => + (e.type === "turn_canceled" || e.type === "turn_failed") && + hasTurnId(e) && + e.turnId === turnId, + ); + expect(terminal).toBeDefined(); + + // No further events for that turnId after terminal + const terminalIdx = events.indexOf(terminal!); + expect( + events.slice(terminalIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId).length, + ).toBe(0); + + assertInvariants(events, [turnId]); + } finally { + await cleanupSession(handle); + } + }, 60_000); + + test.skipIf(!canRun)("Test 6: Sequential foreground turns", async () => { + const handle = await createSession({ cwdPrefix: "event-stream-sequential-" }); + + try { + const { turnId: turnId1, events: events1 } = await startTurnAndCollectEvents( + handle.session, + "say first", + ); + + const { turnId: turnId2, events: events2 } = await startTurnAndCollectEvents( + handle.session, + "say second", + ); + + const allEvents = [...events1, ...events2]; + + expect(turnId1).not.toBe(turnId2); + + // No events from turn 1 after turn 2 starts + const turn2StartIdx = allEvents.findIndex( + (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId2, + ); + expect( + allEvents.slice(turn2StartIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId1) + .length, + ).toBe(0); + + assertInvariants(allEvents, [turnId1, turnId2]); + } finally { + await cleanupSession(handle); + } + }, 90_000); + + test.skipIf(!canRun)("Test 7: Fast-fail", async () => { + const handle = await createSession({ cwdPrefix: "event-stream-fast-fail-" }); + + try { + const { turnId, events } = await startTurnAndCollectEvents(handle.session, "", { + extraMs: 3_000, + }); + + // At most one turn_started + expect( + events.filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId) + .length, + ).toBeLessThanOrEqual(1); + + // Terminal present + const terminal = events.find( + (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId, + ); + expect(terminal).toBeDefined(); + + // No stale turn_started after terminal + const terminalIdx = events.indexOf(terminal!); + expect( + events.slice(terminalIdx + 1).filter((e) => e.type === "turn_started").length, + ).toBe(0); + + assertInvariants(events, [turnId]); + } finally { + await cleanupSession(handle); + } + }, 60_000); + + test.skipIf(!canRun)("Test 8: User message dedup by text", async () => { + const handle = await createSession({ cwdPrefix: "event-stream-user-dedup-" }); + + try { + const { turnId, events } = await startTurnAndCollectEvents(handle.session, "hello world", { + extraMs: 3_000, + }); + + expect(userMessagesWithText(events, "hello world").length).toBeLessThanOrEqual(1); + + assertInvariants(events, [turnId]); + } finally { + await cleanupSession(handle); + } + }, 60_000); +}); diff --git a/packages/server/src/server/agent/providers/claude-agent.integration.test.ts b/packages/server/src/server/agent/providers/claude-agent.integration.test.ts index b9f5a241f..92403101e 100644 --- a/packages/server/src/server/agent/providers/claude-agent.integration.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.integration.test.ts @@ -7,6 +7,7 @@ import pino from "pino"; import type { AgentSession, AgentStreamEvent, ToolCallTimelineItem } from "../agent-sdk-types.js"; import { isCommandAvailable } from "../provider-launch-config.js"; import { ClaudeAgentClient } from "./claude-agent.js"; +import { streamSession } from "./test-utils/session-stream-adapter.js"; const logger = pino({ level: "silent" }); const client = new ClaudeAgentClient({ logger }); @@ -92,6 +93,30 @@ async function collectUntil( } } +function collectSubscribedUntil( + session: AgentSession, + predicate: (event: AgentStreamEvent) => boolean, + timeoutMs = 45_000, +): Promise { + return new Promise((resolve, reject) => { + const events: AgentStreamEvent[] = []; + const timeout = setTimeout(() => { + unsubscribe(); + reject(new Error(`Timed out after ${timeoutMs}ms waiting for subscribed event`)); + }, timeoutMs); + + const unsubscribe = session.subscribe((event) => { + events.push(event); + if (!predicate(event)) { + return; + } + clearTimeout(timeout); + unsubscribe(); + resolve(events); + }); + }); +} + function getAssistantText(events: AgentStreamEvent[]): string { return events .flatMap((event) => { @@ -159,7 +184,7 @@ describe("ClaudeAgentSession integration", () => { try { const events = await collectUntilTerminal( - handle.session.stream("Respond with exactly: HELLO_WORLD"), + streamSession(handle.session, "Respond with exactly: HELLO_WORLD"), ); expect(events[0]).toMatchObject({ @@ -190,7 +215,8 @@ describe("ClaudeAgentSession integration", () => { try { const events = await collectUntilTerminal( - handle.session.stream( + streamSession( + handle.session, [ "Use the Bash tool.", "Run exactly: echo TOOL_TEST_OUTPUT", @@ -227,7 +253,8 @@ describe("ClaudeAgentSession integration", () => { }); try { - const firstStream = handle.session.stream( + const firstStream = streamSession( + handle.session, [ "Use the Bash tool.", "Run exactly: sleep 10", @@ -262,7 +289,7 @@ describe("ClaudeAgentSession integration", () => { ).toBe(true); const followUpEvents = await collectUntilTerminal( - handle.session.stream("Respond with exactly: AFTER_INTERRUPT_OK"), + streamSession(handle.session, "Respond with exactly: AFTER_INTERRUPT_OK"), ); const secondQuery = getInternalQuery(handle.session); @@ -288,9 +315,9 @@ describe("ClaudeAgentSession integration", () => { const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`; try { - const liveEventsStream = handle.session.streamLiveEvents(); const foregroundEvents = await collectUntilTerminal( - handle.session.stream( + streamSession( + handle.session, [ "Use the Task tool to start a background sub-agent.", "In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE", @@ -304,9 +331,11 @@ describe("ClaudeAgentSession integration", () => { expect(compactText(getAssistantText(foregroundEvents))).toContain("spawned"); - const liveEvents = await collectUntilTerminal(liveEventsStream, { - timeoutMs: 45_000, - }); + const liveEvents = await collectSubscribedUntil( + handle.session, + (event) => isTerminalEvent(event), + 45_000, + ); expect( liveEvents.some((event) => event.type === "turn_started" && event.provider === "claude"), @@ -334,7 +363,8 @@ describe("ClaudeAgentSession integration", () => { try { const events = await collectUntilTerminal( - handle.session.stream( + streamSession( + handle.session, [ "Use the Bash tool to run exactly: printf 'PERM_TEST' > permission.txt", "If approval is required, wait for approval.", diff --git a/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts b/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts index 5342e0645..92ba1f5c7 100644 --- a/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import { ClaudeAgentClient } from "./claude-agent.js"; +import { streamSession } from "./test-utils/session-stream-adapter.js"; import type { AgentStreamEvent } from "../agent-sdk-types.js"; type QueryMock = { @@ -211,6 +212,21 @@ function collectAssistantText(events: AgentStreamEvent[]): string { .join(""); } +function subscribeToEvents(session: { subscribe: (callback: (event: AgentStreamEvent) => void) => () => void }) { + const queue = createAsyncQueue(); + const unsubscribe = session.subscribe((event) => { + queue.push(event); + }); + + return { + next: () => queue.next(), + close: () => { + unsubscribe(); + queue.end(); + }, + }; +} + async function waitFor( predicate: () => boolean, options?: { timeoutMs?: number; intervalMs?: number }, @@ -250,7 +266,7 @@ describe("ClaudeAgentSession interrupt regression", () => { cwd: process.cwd(), }); - const firstTurn = session.stream("first prompt"); + const firstTurn = streamSession(session, "first prompt"); await firstTurn.next(); await waitFor(() => queries[0]?.prompts.length === 1); @@ -270,7 +286,7 @@ describe("ClaudeAgentSession interrupt regression", () => { await session.close(); }); - test("pushes the next prompt into the existing query instead of rebuilding it", async () => { + test("reuses the existing query after interrupt before starting the next prompt", async () => { const logger = createTestLogger(); const queries: ScriptedQuery[] = []; @@ -300,11 +316,14 @@ describe("ClaudeAgentSession interrupt regression", () => { cwd: process.cwd(), }); - const firstTurn = session.stream("first prompt"); + const firstTurn = streamSession(session, "first prompt"); await firstTurn.next(); await waitFor(() => queries[0]?.prompts.length === 1); - const secondTurnEvents = await collectUntilTerminal(session.stream("second prompt")); + await session.interrupt(); + await collectUntilTerminal(firstTurn); + + const secondTurnEvents = await collectUntilTerminal(streamSession(session, "second prompt")); expect(sdkMocks.query).toHaveBeenCalledTimes(1); expect(queries[0]?.prompts.map((prompt) => prompt.text)).toEqual([ @@ -315,7 +334,6 @@ describe("ClaudeAgentSession interrupt regression", () => { expect(queries[0]?.return).not.toHaveBeenCalled(); expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE"); - await firstTurn.return?.(); await session.close(); }); @@ -396,12 +414,12 @@ describe("ClaudeAgentSession interrupt regression", () => { cwd: process.cwd(), }); - const firstTurn = session.stream("first prompt"); + const firstTurn = streamSession(session, "first prompt"); await firstTurn.next(); await session.interrupt(); await collectUntilTerminal(firstTurn); - const secondTurnEvents = await collectUntilTerminal(session.stream("second prompt")); + const secondTurnEvents = await collectUntilTerminal(streamSession(session, "second prompt")); expect(sdkMocks.query).toHaveBeenCalledTimes(1); expect(prompts.map((prompt) => prompt.text)).toEqual(["first prompt", "second prompt"]); @@ -442,9 +460,9 @@ describe("ClaudeAgentSession autonomous turns", () => { cwd: process.cwd(), }); - await collectUntilTerminal(session.stream("seed prompt")); + await collectUntilTerminal(streamSession(session, "seed prompt")); - const liveIterator = session.streamLiveEvents(); + const subscribedEvents = subscribeToEvents(session); queryRef?.emit({ type: "assistant", message: { content: "AUTONOMOUS_WAKE_RESPONSE" }, @@ -452,9 +470,9 @@ describe("ClaudeAgentSession autonomous turns", () => { }); queryRef?.emit(buildSuccessResult("autonomous-live-session")); - const started = await liveIterator.next(); - const timeline = await liveIterator.next(); - const completed = await liveIterator.next(); + const started = await subscribedEvents.next(); + const timeline = await subscribedEvents.next(); + const completed = await subscribedEvents.next(); expect(started.value).toMatchObject({ type: "turn_started", provider: "claude" }); expect(timeline.value).toMatchObject({ @@ -470,7 +488,7 @@ describe("ClaudeAgentSession autonomous turns", () => { provider: "claude", }); - await liveIterator.return?.(); + subscribedEvents.close(); await session.close(); }); @@ -512,19 +530,21 @@ describe("ClaudeAgentSession autonomous turns", () => { cwd: process.cwd(), }); - await collectUntilTerminal(session.stream("seed prompt")); + await collectUntilTerminal(streamSession(session, "seed prompt")); - const liveIterator = session.streamLiveEvents(); + const subscribedEvents = subscribeToEvents(session); queryRef?.emit({ type: "assistant", message: { content: "BACKGROUND_ONLY_RESPONSE" }, session_id: "autonomous-handoff-session", }); - const autonomousStart = await liveIterator.next(); - const autonomousTimeline = await liveIterator.next(); - const foregroundEvents = await collectUntilTerminal(session.stream("foreground prompt")); - const autonomousComplete = await liveIterator.next(); + const autonomousStart = await subscribedEvents.next(); + const autonomousTimeline = await subscribedEvents.next(); + const foregroundEvents = await collectUntilTerminal( + streamSession(session, "foreground prompt"), + ); + const autonomousComplete = await subscribedEvents.next(); expect(autonomousStart.value).toMatchObject({ type: "turn_started", @@ -555,7 +575,7 @@ describe("ClaudeAgentSession autonomous turns", () => { "foreground prompt", ]); - await liveIterator.return?.(); + subscribedEvents.close(); await session.close(); }); }); diff --git a/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts b/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts index bded37543..392464d30 100644 --- a/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts @@ -3,6 +3,7 @@ import type { Logger } from "pino"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import { ClaudeAgentClient, readEventIdentifiers } from "./claude-agent.js"; +import { streamSession } from "./test-utils/session-stream-adapter.js"; import type { AgentStreamEvent, AgentTimelineItem } from "../agent-sdk-types.js"; type QueryMock = { @@ -693,7 +694,7 @@ describe("ClaudeAgentSession redesign invariants", () => { const session = await createSession(); try { const events = await Promise.race([ - collectUntilTerminal(session.stream("metadata helper prompt")), + collectUntilTerminal(streamSession(session, "metadata helper prompt")), new Promise((_, reject) => { setTimeout( () => reject(new Error("Timed out waiting for foreground terminal event")), @@ -855,18 +856,18 @@ describe("ClaudeAgentSession redesign invariants", () => { }); streamCase = "success"; - const successEvents = await collectUntilTerminal(session.stream("success prompt")); + const successEvents = await collectUntilTerminal(streamSession(session, "success prompt")); expect(successEvents.some((event) => event.type === "turn_completed")).toBe(true); expect(successEvents.some((event) => event.type === "turn_failed")).toBe(false); expect(successEvents.some((event) => event.type === "turn_canceled")).toBe(false); streamCase = "error"; - const errorEvents = await collectUntilTerminal(session.stream("error prompt")); + const errorEvents = await collectUntilTerminal(streamSession(session, "error prompt")); expect(errorEvents.some((event) => event.type === "turn_failed")).toBe(true); expect(errorEvents.some((event) => event.type === "turn_completed")).toBe(false); streamCase = "interrupt"; - const interruptStream = session.stream("interrupt prompt"); + const interruptStream = streamSession(session, "interrupt prompt"); const interruptEvents: AgentStreamEvent[] = []; for await (const event of interruptStream) { interruptEvents.push(event); @@ -985,7 +986,7 @@ describe("ClaudeAgentSession redesign invariants", () => { }); const session = await createSession(); - const events = await collectUntilTerminal(session.stream("timeline prompt")); + const events = await collectUntilTerminal(streamSession(session, "timeline prompt")); const assistantText = events .filter( (event): event is Extract => @@ -1104,7 +1105,7 @@ describe("ClaudeAgentSession redesign invariants", () => { }); const session = await createSession(); - const events = await collectUntilTerminal(session.stream("uuid fallback prompt")); + const events = await collectUntilTerminal(streamSession(session, "uuid fallback prompt")); const assistantText = events .filter( (event): event is Extract => diff --git a/packages/server/src/server/agent/providers/claude-agent.sub-agent-sidechain.test.ts b/packages/server/src/server/agent/providers/claude-agent.sub-agent-sidechain.test.ts index e4cca62b7..118fb2b57 100644 --- a/packages/server/src/server/agent/providers/claude-agent.sub-agent-sidechain.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.sub-agent-sidechain.test.ts @@ -5,6 +5,7 @@ import type { AgentStreamEvent } from "../agent-sdk-types.js"; import type { AgentTimelineRow } from "../agent-manager.js"; import { projectTimelineRows } from "../timeline-projection.js"; import { ClaudeAgentClient } from "./claude-agent.js"; +import { streamSession } from "./test-utils/session-stream-adapter.js"; const sdkMocks = vi.hoisted(() => ({ query: vi.fn(), @@ -255,7 +256,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { cwd: process.cwd(), }); - const events = await collectUntilTerminal(session.stream("delegate work")); + const events = await collectUntilTerminal(streamSession(session, "delegate work")); await session.close(); const timelineToolCalls = events @@ -316,7 +317,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { cwd: process.cwd(), }); - const events = await collectUntilTerminal(session.stream("delegate work")); + const events = await collectUntilTerminal(streamSession(session, "delegate work")); await session.close(); const timelineToolCalls = events diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 53b4c168a..b2d82fad1 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -29,7 +29,6 @@ import { mapClaudeRunningToolCall, } from "./claude/tool-call-mapper.js"; import { - coerceTaskNotificationHistoryRecordToSystemMessage, mapTaskNotificationSystemRecordToToolCall, mapTaskNotificationUserContentToToolCall, } from "./claude/task-notification-tool-call.js"; @@ -83,16 +82,16 @@ type EventIdentifiers = { messageId: string | null; }; -type ForegroundTurnState = { - id: string; - queue: Pushable; - hasVisibleActivity: boolean; -}; - type AutonomousTurnState = { id: string; }; +type AsyncMessageInput = { + push: (item: T) => void; + end: () => void; + iterable: AsyncIterable; +}; + type NormalizeClaudeRuntimeModelIdOptions = { runtimeModelId: string; supportedModelIds: ReadonlySet | null; @@ -1201,7 +1200,7 @@ class ClaudeAgentSession implements AgentSession { private readonly logger: Logger; private readonly queryFactory: typeof query; private query: Query | null = null; - private input: Pushable | null = null; + private input: AsyncMessageInput | null = null; private claudeSessionId: string | null; private persistence: AgentPersistenceHandle | null; private currentMode: PermissionMode; @@ -1210,22 +1209,18 @@ class ClaudeAgentSession implements AgentSession { private toolUseIndexToId = new Map(); private toolUseInputBuffers = new Map(); private pendingPermissions = new Map(); - private activeForegroundTurn: ForegroundTurnState | null = null; + private activeForegroundTurnId: string | null = null; private autonomousTurn: AutonomousTurnState | null = null; - private liveEventQueue = new Pushable(); + private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private readonly timelineAssembler = new TimelineAssembler(); private readonly sidechainTracker = new ClaudeSidechainTracker({ getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null, }); private persistedHistory: AgentTimelineItem[] = []; private historyPending = false; - private historyOffsetSessionId: string | null = null; - private historyReadOffsetBytes = 0; - private historyLineFragment = ""; private turnState: TurnState = "idle"; private nextTurnOrdinal = 1; private cancelCurrentTurn: (() => void) | null = null; - private activeTurnPromise: Promise | null = null; private cachedRuntimeInfo: AgentRuntimeInfo | null = null; private lastOptionsModel: string | null = null; private selectableModelIds: Set | null = buildClaudeSelectableModelIds(); @@ -1235,8 +1230,8 @@ class ClaudeAgentSession implements AgentSession { private queryPumpPromise: Promise | null = null; private queryRestartNeeded = false; private pendingInterruptAbort = false; - private liveEventSubscriberCount = 0; - private liveHistoryPollTimer: NodeJS.Timeout | null = null; + private lastForegroundPromptText: string | null = null; + private foregroundHasVisibleActivity = false; private userMessageIds: string[] = []; private recentStderr = ""; private closed = false; @@ -1292,12 +1287,23 @@ class ClaudeAgentSession implements AgentSession { } async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise { - const events = this.stream(prompt, options); const timeline: AgentTimelineItem[] = []; let finalText = ""; let usage: AgentUsage | undefined; + let turnId: string | null = null; + const bufferedEvents: AgentStreamEvent[] = []; + let settled = false; + let resolveCompletion!: () => void; + let rejectCompletion!: (error: Error) => void; - for await (const event of events) { + const processEvent = (event: AgentStreamEvent) => { + if (settled) { + return; + } + const eventTurnId = (event as { turnId?: string }).turnId; + if (turnId && eventTurnId && eventTurnId !== turnId) { + return; + } if (event.type === "timeline") { timeline.push(event.item); if (event.item.type === "assistant_message") { @@ -1309,11 +1315,48 @@ class ClaudeAgentSession implements AgentSession { finalText += event.item.text; } } - } else if (event.type === "turn_completed") { - usage = event.usage; - } else if (event.type === "turn_failed") { - throw new Error(event.error); + return; } + if (event.type === "turn_completed") { + usage = event.usage; + settled = true; + resolveCompletion(); + return; + } + if (event.type === "turn_failed") { + settled = true; + rejectCompletion(new Error(event.error)); + return; + } + if (event.type === "turn_canceled") { + settled = true; + resolveCompletion(); + } + }; + + const completion = new Promise((resolve, reject) => { + resolveCompletion = resolve; + rejectCompletion = reject; + }); + const unsubscribe = this.subscribe((event) => { + if (!turnId) { + bufferedEvents.push(event); + return; + } + processEvent(event); + }); + + try { + const result = await this.startTurn(prompt, options); + turnId = result.turnId; + for (const event of bufferedEvents) { + processEvent(event); + } + if (!settled) { + await completion; + } + } finally { + unsubscribe(); } this.cachedRuntimeInfo = { @@ -1335,19 +1378,24 @@ class ClaudeAgentSession implements AgentSession { }; } - async *stream( + async startTurn( prompt: AgentPromptInput, - options?: AgentRunOptions, - ): AsyncGenerator { - void options; - if (this.cancelCurrentTurn) { - this.cancelCurrentTurn(); + _options?: AgentRunOptions, + ): Promise<{ turnId: string }> { + if (this.closed) { + throw new Error("Claude session is closed"); + } + if (this.activeForegroundTurnId) { + throw new Error("A foreground turn is already active"); } const slashCommand = this.resolveSlashCommandInvocation(prompt); if (slashCommand?.commandName === REWIND_COMMAND_NAME) { - yield* this.streamRewindCommand(slashCommand); - return; + const turnId = this.createTurnId("foreground"); + this.activeForegroundTurnId = turnId; + this.transitionTurnState("foreground", "rewind command"); + void this.executeRewindTurn(turnId, slashCommand); + return { turnId }; } if (this.autonomousTurn) { @@ -1355,23 +1403,14 @@ class ClaudeAgentSession implements AgentSession { } const sdkMessage = this.toSdkUserMessage(prompt); - const queue = new Pushable(); - const foregroundTurn: ForegroundTurnState = { - id: this.createTurnId("foreground"), - queue, - hasVisibleActivity: false, - }; - this.activeForegroundTurn = foregroundTurn; - this.transitionTurnState("foreground", "foreground stream started"); + this.lastForegroundPromptText = this.extractPromptText(prompt); + const turnId = this.createTurnId("foreground"); + this.activeForegroundTurnId = turnId; + this.foregroundHasVisibleActivity = false; + this.transitionTurnState("foreground", "foreground turn started"); this.clearRecentStderr(); - queue.push({ type: "turn_started", provider: "claude" }); - let finishedNaturally = false; let cancelIssued = false; - let queueDrainedWithoutTerminal = false; - const turnPromise = Promise.resolve(); - this.activeTurnPromise = turnPromise; - const requestCancel = () => { if (cancelIssued) { return; @@ -1392,6 +1431,8 @@ class ClaudeAgentSession implements AgentSession { }; this.cancelCurrentTurn = requestCancel; + this.notifySubscribers({ type: "turn_started", provider: "claude" }); + try { await this.ensureQuery(); if (!this.input) { @@ -1403,40 +1444,16 @@ class ClaudeAgentSession implements AgentSession { this.finishForegroundTurn( this.buildTurnFailedEvent(error instanceof Error ? error.message : "Claude stream failed"), ); - finishedNaturally = true; } - try { - for await (const event of queue) { - const isTerminalEvent = - event.type === "turn_completed" || - event.type === "turn_failed" || - event.type === "turn_canceled"; - if (isTerminalEvent) { - finishedNaturally = true; - } - yield event; - if (isTerminalEvent) { - break; - } - } - if (!finishedNaturally && !cancelIssued) { - queueDrainedWithoutTerminal = true; - } - } finally { - if (!finishedNaturally && !cancelIssued && !queueDrainedWithoutTerminal) { - requestCancel(); - } - if (this.activeForegroundTurn === foregroundTurn) { - this.activeForegroundTurn = null; - } - if (this.cancelCurrentTurn === requestCancel) { - this.cancelCurrentTurn = null; - } - if (this.activeTurnPromise === turnPromise) { - this.activeTurnPromise = null; - } - } + return { turnId }; + } + + subscribe(callback: (event: AgentStreamEvent) => void): () => void { + this.subscribers.add(callback); + return () => { + this.subscribers.delete(callback); + }; } async interrupt(): Promise { @@ -1464,25 +1481,6 @@ class ClaudeAgentSession implements AgentSession { } } - async *streamLiveEvents(): AsyncGenerator { - if (this.claudeSessionId) { - this.startQueryPump(); - } - this.liveEventSubscriberCount += 1; - this.startLiveHistoryPolling(); - - try { - for await (const event of this.liveEventQueue) { - yield event; - } - } finally { - this.liveEventSubscriberCount = Math.max(0, this.liveEventSubscriberCount - 1); - if (this.liveEventSubscriberCount === 0) { - this.stopLiveHistoryPolling(); - } - } - } - async getAvailableModes(): Promise { return this.availableModes; } @@ -1620,22 +1618,19 @@ class ClaudeAgentSession implements AgentSession { turnState: this.turnState, hasQuery: Boolean(this.query), hasInput: Boolean(this.input), - hasActiveForegroundTurn: Boolean(this.activeForegroundTurn), + hasActiveForegroundTurnId: Boolean(this.activeForegroundTurnId), }, "Claude session close: start", ); this.closed = true; this.rejectAllPendingPermissions(new Error("Claude session closed")); this.cancelCurrentTurn?.(); - this.activeForegroundTurn?.queue.end(); - this.activeForegroundTurn = null; + this.subscribers.clear(); + this.activeForegroundTurnId = null; this.autonomousTurn = null; this.cancelCurrentTurn = null; this.turnState = "idle"; - this.liveEventQueue.end(); - this.activeTurnPromise = null; this.sidechainTracker.clear(); - this.stopLiveHistoryPolling(); this.input?.end(); this.query?.close?.(); await this.awaitWithTimeout(this.query?.interrupt?.(), "close query interrupt"); @@ -1697,41 +1692,6 @@ class ClaudeAgentSession implements AgentSession { : { commandName, rawInput: trimmed }; } - private async *streamRewindCommand( - invocation: SlashCommandInvocation, - ): AsyncGenerator { - yield { type: "turn_started", provider: "claude" }; - - try { - const rewindAttempt = await this.attemptRewind(invocation.args); - if (!rewindAttempt.messageId || !rewindAttempt.result) { - yield { - type: "turn_failed", - provider: "claude", - error: - rewindAttempt.error ?? - "No prior user message available to rewind. Use /rewind .", - }; - return; - } - yield { - type: "timeline", - provider: "claude", - item: { - type: "assistant_message", - text: this.buildRewindSuccessMessage(rewindAttempt.messageId, rewindAttempt.result), - }, - }; - yield { type: "turn_completed", provider: "claude" }; - } catch (error) { - yield { - type: "turn_failed", - provider: "claude", - error: error instanceof Error ? error.message : "Failed to rewind tracked files", - }; - } - } - private buildRewindSuccessMessage( targetUserMessageId: string, rewindResult: { filesChanged?: string[]; insertions?: number; deletions?: number }, @@ -1915,11 +1875,11 @@ class ClaudeAgentSession implements AgentSession { this.queryRestartNeeded = false; } - const input = new Pushable(); + const input = createAsyncMessageInput(); const options = this.buildOptions(); this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query"); this.input = input; - this.query = this.queryFactory({ prompt: input, options }); + this.query = this.queryFactory({ prompt: input.iterable, options }); // Do not kick off background control-plane queries here. Methods like // supportedCommands()/setPermissionMode() may execute immediately after // ensureQuery() (for listCommands()/setMode()), and sharing the same query @@ -2083,7 +2043,7 @@ class ClaudeAgentSession implements AgentSession { } private syncTurnState(reason: string): void { - if (this.activeForegroundTurn) { + if (this.activeForegroundTurnId) { this.transitionTurnState("foreground", reason); return; } @@ -2139,6 +2099,51 @@ class ClaudeAgentSession implements AgentSession { ); } + private extractPromptText(prompt: AgentPromptInput): string | null { + if (typeof prompt === "string") { + return prompt; + } + const textParts = prompt + .filter((block): block is { type: "text"; text: string } => block.type === "text") + .map((block) => block.text); + return textParts.length > 0 ? textParts.join("\n") : null; + } + + private async executeRewindTurn( + _turnId: string, + invocation: SlashCommandInvocation, + ): Promise { + this.notifySubscribers({ type: "turn_started", provider: "claude" }); + try { + const rewindAttempt = await this.attemptRewind(invocation.args); + if (!rewindAttempt.messageId || !rewindAttempt.result) { + this.finishForegroundTurn({ + type: "turn_failed", + provider: "claude", + error: + rewindAttempt.error ?? + "No prior user message available to rewind. Use /rewind .", + }); + return; + } + this.notifySubscribers({ + type: "timeline", + provider: "claude", + item: { + type: "assistant_message", + text: this.buildRewindSuccessMessage(rewindAttempt.messageId, rewindAttempt.result), + }, + }); + this.finishForegroundTurn({ type: "turn_completed", provider: "claude" }); + } catch (error) { + this.finishForegroundTurn({ + type: "turn_failed", + provider: "claude", + error: error instanceof Error ? error.message : "Failed to rewind tracked files", + }); + } + } + private shouldRecoverInterruptedQueryAbort( error: unknown, consecutiveRecoveries: number, @@ -2161,43 +2166,30 @@ class ClaudeAgentSession implements AgentSession { if (event.type === "turn_failed" || event.type === "turn_canceled") { this.flushPendingToolCalls(); } - this.dispatchForegroundEvents([event]); - } - - private dispatchForegroundEvents(events: AgentStreamEvent[]): void { - const foregroundTurn = this.activeForegroundTurn; - if (!foregroundTurn) { - this.dispatchLiveEvents(events); - return; - } - - let terminalSeen = false; - for (const event of events) { - foregroundTurn.queue.push(event); - terminalSeen ||= this.isTerminalTurnEvent(event); - } - - if (!terminalSeen) { - return; - } - - foregroundTurn.queue.end(); - if (this.activeForegroundTurn === foregroundTurn) { - this.activeForegroundTurn = null; - } + this.notifySubscribers(event); + this.activeForegroundTurnId = null; + this.lastForegroundPromptText = null; + this.cancelCurrentTurn = null; this.syncTurnState("foreground turn terminal"); } - private dispatchLiveEvents(events: AgentStreamEvent[]): void { + private dispatchEvents(events: AgentStreamEvent[]): void { let terminalSeen = false; for (const event of events) { - this.liveEventQueue.push(event); + this.notifySubscribers(event); terminalSeen ||= this.isTerminalTurnEvent(event); } - if (terminalSeen && this.autonomousTurn) { - this.autonomousTurn = null; - this.syncTurnState("autonomous turn terminal"); + if (terminalSeen) { + if (this.activeForegroundTurnId) { + this.activeForegroundTurnId = null; + this.lastForegroundPromptText = null; + this.cancelCurrentTurn = null; + this.syncTurnState("foreground turn terminal"); + } else if (this.autonomousTurn) { + this.autonomousTurn = null; + this.syncTurnState("autonomous turn terminal"); + } } } @@ -2208,7 +2200,7 @@ class ClaudeAgentSession implements AgentSession { this.autonomousTurn = { id: this.createTurnId("autonomous"), }; - this.liveEventQueue.push({ type: "turn_started", provider: "claude" }); + this.notifySubscribers({ type: "turn_started", provider: "claude" }); this.syncTurnState("autonomous turn started"); } @@ -2216,8 +2208,8 @@ class ClaudeAgentSession implements AgentSession { if (!this.autonomousTurn) { return; } + this.notifySubscribers({ type: "turn_completed", provider: "claude" }); this.autonomousTurn = null; - this.liveEventQueue.push({ type: "turn_completed", provider: "claude" }); this.syncTurnState("autonomous turn completed"); } @@ -2226,25 +2218,24 @@ class ClaudeAgentSession implements AgentSession { return; } this.flushPendingToolCalls(); - this.autonomousTurn = null; - this.liveEventQueue.push({ + this.notifySubscribers({ type: "turn_canceled", provider: "claude", reason, }); + this.autonomousTurn = null; this.syncTurnState("autonomous turn canceled"); } private failActiveTurns(errorMessage: string): void { const failure = this.buildTurnFailedEvent(errorMessage); - if (this.activeForegroundTurn) { - this.flushPendingToolCalls(); - this.dispatchForegroundEvents([failure]); + this.flushPendingToolCalls(); + if (this.activeForegroundTurnId) { + this.finishForegroundTurn(failure); return; } if (this.autonomousTurn) { - this.flushPendingToolCalls(); - this.dispatchLiveEvents([failure]); + this.dispatchEvents([failure]); } } @@ -2280,6 +2271,15 @@ class ClaudeAgentSession implements AgentSession { while (!this.closed && this.query === activeQuery) { try { for await (const message of activeQuery) { + this.logger.trace( + { + claudeSessionId: this.claudeSessionId, + messageType: message.type, + messageSubtype: "subtype" in message ? message.subtype : undefined, + messageUuid: "uuid" in message ? message.uuid : undefined, + }, + "Claude query pump: raw SDK message", + ); consecutiveInterruptAbortRecoveries = 0; if (await this.handleMissingResumedConversation(message, activeQuery)) { return; @@ -2318,31 +2318,30 @@ class ClaudeAgentSession implements AgentSession { } private routeSdkMessageFromPump(message: SDKMessage): void { - const routeToForeground = Boolean(this.activeForegroundTurn); + const isForeground = Boolean(this.activeForegroundTurnId); const assistantishMessage = message.type === "assistant" || message.type === "stream_event" || message.type === "tool_progress" || (message.type === "system" && message.subtype === "task_notification"); - if (!routeToForeground && assistantishMessage) { + if (!isForeground && assistantishMessage) { this.startAutonomousTurn(); } - if (!routeToForeground && !this.autonomousTurn && message.type === "result") { + if (!isForeground && !this.autonomousTurn && message.type === "result") { return; } - const turnId = this.activeForegroundTurn?.id ?? this.autonomousTurn?.id ?? null; + const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null; const identifiers = readEventIdentifiers(message); this.logger.trace( { claudeSessionId: this.claudeSessionId, messageType: message.type, - routedTo: routeToForeground ? "foreground_queue" : "live_queue", turnId, }, - "Claude query pump routed SDK message", + "Claude query pump: SDK message", ); const messageEvents = this.translateMessageToEvents(message, { @@ -2363,7 +2362,23 @@ class ClaudeAgentSession implements AgentSession { provider: "claude", }) satisfies AgentStreamEvent, ); - const events = [...messageEvents, ...assistantTimelineEvents]; + + // User message dedup: suppress echoed user messages that match the foreground prompt + const filteredMessageEvents = messageEvents.filter((event) => { + if ( + event.type === "timeline" && + event.item.type === "user_message" && + this.activeForegroundTurnId && + this.lastForegroundPromptText + ) { + if (event.item.text.trim() === this.lastForegroundPromptText.trim()) { + return false; + } + } + return true; + }); + + const events = [...filteredMessageEvents, ...assistantTimelineEvents]; if (events.length === 0) { return; @@ -2373,14 +2388,14 @@ class ClaudeAgentSession implements AgentSession { this.pendingInterruptAbort && message.type === "result" && events.some((event) => event.type === "turn_completed" || event.type === "turn_failed") && - (!this.activeForegroundTurn || !this.activeForegroundTurn.hasVisibleActivity) + (!this.activeForegroundTurnId || !this.foregroundHasVisibleActivity) ) { this.pendingInterruptAbort = false; this.logger.debug("Suppressing stale Claude interrupt terminal result"); return; } if ( - this.activeForegroundTurn && + this.activeForegroundTurnId && events.some( (event) => event.type === "timeline" || @@ -2388,15 +2403,11 @@ class ClaudeAgentSession implements AgentSession { event.type === "permission_resolved", ) ) { - this.activeForegroundTurn.hasVisibleActivity = true; + this.foregroundHasVisibleActivity = true; this.pendingInterruptAbort = false; } - if (routeToForeground) { - this.dispatchForegroundEvents(events); - return; - } - this.dispatchLiveEvents(events); + this.dispatchEvents(events); } private async handleMissingResumedConversation( @@ -2430,13 +2441,10 @@ class ClaudeAgentSession implements AgentSession { this.persistence = null; this.persistedHistory = []; this.historyPending = false; - this.historyOffsetSessionId = null; - this.historyReadOffsetBytes = 0; - this.historyLineFragment = ""; this.cachedRuntimeInfo = null; this.queryRestartNeeded = false; this.autonomousTurn = null; - this.activeForegroundTurn = null; + this.activeForegroundTurnId = null; this.syncTurnState("missing resumed conversation"); return true; } @@ -2855,12 +2863,19 @@ class ClaudeAgentSession implements AgentSession { } private pushEvent(event: AgentStreamEvent) { - const foregroundTurn = this.activeForegroundTurn; - if (foregroundTurn) { - foregroundTurn.queue.push(event); - return; + this.notifySubscribers(event); + } + + private notifySubscribers(event: AgentStreamEvent): void { + const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id; + const tagged = turnId ? { ...event, turnId } : event; + for (const callback of this.subscribers) { + try { + callback(tagged); + } catch (error) { + this.logger.warn({ err: error }, "Subscriber callback threw"); + } } - this.liveEventQueue.push(event); } private normalizePermissionUpdates( @@ -2881,165 +2896,57 @@ class ClaudeAgentSession implements AgentSession { } } - private loadPersistedHistory(sessionId: string, options?: { dispatchLive?: boolean }) { + private loadPersistedHistory(sessionId: string): void { try { const historyPath = this.resolveHistoryPath(sessionId); if (!historyPath || !fs.existsSync(historyPath)) { return; } - if (this.historyOffsetSessionId !== sessionId) { - this.historyOffsetSessionId = sessionId; - this.historyReadOffsetBytes = 0; - this.historyLineFragment = ""; - } - const content = fs.readFileSync(historyPath); - if (content.byteLength < this.historyReadOffsetBytes) { - this.historyReadOffsetBytes = 0; - this.historyLineFragment = ""; - } - if (content.byteLength === this.historyReadOffsetBytes) { - return; - } - - const unreadChunk = content.subarray(this.historyReadOffsetBytes).toString("utf8"); - this.historyReadOffsetBytes = content.byteLength; - this.ingestPersistedHistoryChunk(unreadChunk, { - dispatchLive: options?.dispatchLive ?? false, - }); + this.ingestPersistedHistory(fs.readFileSync(historyPath, "utf8")); } catch (error) { // ignore history load failures } } - private startLiveHistoryPolling(): void { - if (this.liveHistoryPollTimer || !this.claudeSessionId) { - return; - } - this.liveHistoryPollTimer = setInterval(() => { - if (!this.claudeSessionId || this.closed) { - this.stopLiveHistoryPolling(); - return; - } - this.loadPersistedHistory(this.claudeSessionId, { dispatchLive: true }); - }, 200); - } - - private stopLiveHistoryPolling(): void { - if (!this.liveHistoryPollTimer) { - return; - } - clearInterval(this.liveHistoryPollTimer); - this.liveHistoryPollTimer = null; - } - - private ingestPersistedHistoryChunk(chunk: string, options: { dispatchLive: boolean }): void { - if (!chunk) { + private ingestPersistedHistory(content: string): void { + if (!content) { return; } - const combined = `${this.historyLineFragment}${chunk}`; - this.historyLineFragment = ""; - const lines = combined.split(/\r?\n/); - const trailing = lines.pop() ?? ""; const timeline: AgentTimelineItem[] = []; - - for (const line of lines) { - this.ingestPersistedHistoryLine(line, { - dispatchLive: options.dispatchLive, - timeline, - }); + for (const line of content.split(/\r?\n/)) { + this.ingestPersistedHistoryLine(line, timeline); } - if (trailing.trim().length > 0) { - const handled = this.ingestPersistedHistoryLine(trailing, { - dispatchLive: options.dispatchLive, - timeline, - }); - if (!handled) { - this.historyLineFragment = trailing; - } - } - - if (!options.dispatchLive && timeline.length > 0) { + if (timeline.length > 0) { this.persistedHistory = [...this.persistedHistory, ...timeline]; this.historyPending = true; } } - private ingestPersistedHistoryLine( - line: string, - options: { - dispatchLive: boolean; - timeline: AgentTimelineItem[]; - }, - ): boolean { + private ingestPersistedHistoryLine(line: string, timeline: AgentTimelineItem[]): void { const trimmed = line.trim(); if (!trimmed) { - return true; + return; } let entry: Record; try { entry = JSON.parse(trimmed) as Record; } catch { - return false; + return; } if (entry.isSidechain) { - return true; + return; } if (entry.type === "user" && typeof entry.uuid === "string") { this.rememberUserMessageId(entry.uuid); } - if (options.dispatchLive) { - this.dispatchPersistedHistoryEntry(entry); - return true; - } - const items = this.convertHistoryEntry(entry); if (items.length > 0) { - options.timeline.push(...items); - } - return true; - } - - private dispatchPersistedHistoryEntry(entry: Record): void { - const liveMessage = this.normalizePersistedHistoryEntryToLiveMessage(entry); - if (liveMessage) { - this.routeSdkMessageFromPump(liveMessage); - return; - } - - const items = this.convertHistoryEntry(entry); - for (const item of items) { - this.pushEvent({ - type: "timeline", - item, - provider: "claude", - }); - } - } - - private normalizePersistedHistoryEntryToLiveMessage( - entry: Record, - ): SDKMessage | null { - const taskNotificationMessage = coerceTaskNotificationHistoryRecordToSystemMessage(entry); - if (taskNotificationMessage) { - return taskNotificationMessage as unknown as SDKMessage; - } - - const type = readTrimmedString(entry.type); - switch (type) { - case "assistant": - case "result": - case "stream_event": - case "system": - case "tool_progress": - case "user": - return entry as unknown as SDKMessage; - default: - return null; + timeline.push(...items); } } @@ -3759,49 +3666,51 @@ export function convertClaudeHistoryEntry( return timeline; } -class Pushable implements AsyncIterable { - private queue: T[] = []; - private resolvers: Array<(value: IteratorResult) => void> = []; - private closed = false; +function createAsyncMessageInput(): AsyncMessageInput { + const queue: T[] = []; + const resolvers: Array<(value: IteratorResult) => void> = []; + let closed = false; - push(item: T) { - if (this.closed) { - return; - } - if (this.resolvers.length > 0) { - const resolve = this.resolvers.shift()!; - resolve({ value: item, done: false }); - } else { - this.queue.push(item); - } - } - - end() { - this.closed = true; - while (this.resolvers.length > 0) { - const resolve = this.resolvers.shift()!; - resolve({ value: undefined, done: true }); - } - } - - [Symbol.asyncIterator](): AsyncIterator { - return { - next: (): Promise> => { - if (this.queue.length > 0) { - const value = this.queue.shift(); - if (value !== undefined) { - return Promise.resolve({ value, done: false }); - } - } - if (this.closed) { - return Promise.resolve({ value: undefined, done: true }); - } - return new Promise>((resolve) => { - this.resolvers.push(resolve); - }); + return { + push(item: T) { + if (closed) { + return; + } + const resolve = resolvers.shift(); + if (resolve) { + resolve({ value: item, done: false }); + return; + } + queue.push(item); + }, + end() { + closed = true; + while (resolvers.length > 0) { + const resolve = resolvers.shift(); + resolve?.({ value: undefined, done: true }); + } + }, + iterable: { + [Symbol.asyncIterator](): AsyncIterator { + return { + next: (): Promise> => { + if (queue.length > 0) { + const value = queue.shift(); + if (value !== undefined) { + return Promise.resolve({ value, done: false }); + } + } + if (closed) { + return Promise.resolve({ value: undefined, done: true }); + } + return new Promise>((resolve) => { + resolvers.push(resolve); + }); + }, + }; }, - }; - } + }, + }; } type ClaudeSessionCandidate = { diff --git a/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts b/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts index fa66be779..2d2eb583c 100644 --- a/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts @@ -1,10 +1,11 @@ -import { appendFileSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import { ClaudeAgentClient } from "./claude-agent.js"; +import { streamSession } from "./test-utils/session-stream-adapter.js"; import type { AgentPersistenceHandle, AgentStreamEvent } from "../agent-sdk-types.js"; const sdkMocks = vi.hoisted(() => ({ @@ -19,8 +20,6 @@ vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ const LIVE_REPLY_MARKER = "LIVE_ONLY_REPLY_MARKER"; const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER"; const HISTORY_ASSISTANT_MARKER = "HISTORY_ONLY_ASSISTANT_MARKER"; -const APPENDED_TASK_NOTIFICATION_MARKER = "Appended background task completed"; -const APPENDED_ASSISTANT_MARKER = "APPENDED_BACKGROUND_ASSISTANT_MARKER"; function buildSdkQueryMock() { const events = [ @@ -73,23 +72,6 @@ function buildSdkQueryMock() { }; } -function buildIdleSdkQueryMock() { - return { - next: vi.fn(async () => ({ done: true, value: undefined })), - interrupt: vi.fn(async () => undefined), - return: vi.fn(async () => undefined), - close: vi.fn(() => undefined), - setPermissionMode: vi.fn(async () => undefined), - setModel: vi.fn(async () => undefined), - supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]), - supportedCommands: vi.fn(async () => []), - rewindFiles: vi.fn(async () => ({ canRewind: true })), - [Symbol.asyncIterator]() { - return this; - }, - }; -} - function collectTimelineText(events: AgentStreamEvent[]): string { const chunks: string[] = []; for (const event of events) { @@ -106,28 +88,6 @@ function collectTimelineText(events: AgentStreamEvent[]): string { return chunks.join("\n"); } -async function readNextEvent( - iterator: AsyncIterator, - timeoutMs: number, -): Promise { - const outcome = await Promise.race([ - iterator.next().then((result) => ({ kind: "result" as const, result })), - new Promise<{ kind: "timeout" }>((resolve) => { - setTimeout(() => resolve({ kind: "timeout" }), timeoutMs); - }), - ]); - - if (outcome.kind === "timeout") { - throw new Error("Timed out waiting for live event"); - } - - if (outcome.result.done) { - throw new Error("Live event stream ended before appended transcript arrived"); - } - - return outcome.result.value; -} - describe("ClaudeAgentSession history replay regression", () => { let tempRoot: string; let cwd: string; @@ -208,7 +168,7 @@ describe("ClaudeAgentSession history replay regression", () => { const events: AgentStreamEvent[] = []; try { - for await (const event of session.stream("Say hello")) { + for await (const event of streamSession(session, "Say hello")) { events.push(event); if ( event.type === "turn_completed" || @@ -257,112 +217,6 @@ describe("ClaudeAgentSession history replay regression", () => { expect(timelineText).toContain(HISTORY_ASSISTANT_MARKER); }); - test("emits appended transcript lines through streamLiveEvents after history was primed", async () => { - sdkMocks.query.mockImplementation(() => { - const mock = buildIdleSdkQueryMock(); - sdkMocks.lastQuery = mock; - return mock; - }); - - const logger = createTestLogger(); - const client = new ClaudeAgentClient({ logger }); - const handle: AgentPersistenceHandle = { - provider: "claude", - sessionId: "history-session", - nativeHandle: "history-session", - metadata: { - provider: "claude", - cwd, - }, - }; - - const sanitized = cwd.replace(/[\\/\.]/g, "-").replace(/_/g, "-"); - const historyPath = path.join(configDir, "projects", sanitized, "history-session.jsonl"); - - const session = await client.resumeSession(handle, { cwd }); - - try { - for await (const _event of session.streamHistory()) { - // Prime existing persisted history the same way agent-manager does. - } - - const liveEvents = session.streamLiveEvents(); - const iterator = liveEvents[Symbol.asyncIterator](); - - appendFileSync( - historyPath, - `\n${JSON.stringify({ - type: "queue-operation", - operation: "enqueue", - uuid: "appended-task-note-1", - content: [ - "", - "appended-bg-1", - "completed", - `${APPENDED_TASK_NOTIFICATION_MARKER}`, - "/tmp/appended-bg-1.txt", - "", - ].join("\n"), - })}\n${JSON.stringify({ - type: "assistant", - sessionId: "history-session", - cwd, - message: { - role: "assistant", - content: APPENDED_ASSISTANT_MARKER, - }, - })}`, - "utf8", - ); - - const appendedEvents: AgentStreamEvent[] = []; - for (let attempt = 0; attempt < 4; attempt += 1) { - appendedEvents.push(await readNextEvent(iterator, 1_500)); - const sawTaskNotification = appendedEvents.some( - (event): event is Extract => - event.type === "timeline" && - event.item.type === "tool_call" && - event.item.name === "task_notification", - ); - const sawAssistant = appendedEvents.some( - (event): event is Extract => - event.type === "timeline" && - event.item.type === "assistant_message" && - event.item.text.includes(APPENDED_ASSISTANT_MARKER), - ); - if (sawTaskNotification && sawAssistant) { - break; - } - } - const timelineText = collectTimelineText(appendedEvents); - const taskNotificationEvent = appendedEvents.find( - (event): event is Extract => - event.type === "timeline" && - event.item.type === "tool_call" && - event.item.name === "task_notification", - ); - const turnStartedEvent = appendedEvents.find( - (event): event is Extract => - event.type === "turn_started", - ); - - expect(taskNotificationEvent).toBeTruthy(); - expect(turnStartedEvent).toBeTruthy(); - expect(timelineText).toContain(APPENDED_ASSISTANT_MARKER); - expect(taskNotificationEvent?.item.metadata).toMatchObject({ - taskId: "appended-bg-1", - status: "completed", - outputFile: "/tmp/appended-bg-1.txt", - }); - expect(taskNotificationEvent?.item.detail).toMatchObject({ - type: "plain_text", - label: APPENDED_TASK_NOTIFICATION_MARKER, - }); - } finally { - await session.close(); - } - }); - test("listCommands includes rewind command", async () => { const logger = createTestLogger(); const client = new ClaudeAgentClient({ logger }); @@ -402,7 +256,7 @@ describe("ClaudeAgentSession history replay regression", () => { const events: AgentStreamEvent[] = []; try { - for await (const event of session.stream("/rewind")) { + for await (const event of streamSession(session, "/rewind")) { events.push(event); if ( event.type === "turn_completed" || diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 706f5ef4f..619a2bdd1 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -435,50 +435,6 @@ function toCodexMcpConfig(config: McpServerConfig): CodexMcpServerConfig { }; } } - -class Pushable implements AsyncIterable { - private queue: T[] = []; - private resolvers: ((value: IteratorResult) => void)[] = []; - private closed = false; - - push(item: T) { - if (this.closed) { - return; - } - if (this.resolvers.length > 0) { - const resolve = this.resolvers.shift()!; - resolve({ value: item, done: false }); - } else { - this.queue.push(item); - } - } - - end() { - this.closed = true; - while (this.resolvers.length > 0) { - const resolve = this.resolvers.shift()!; - resolve({ value: undefined, done: true }); - } - } - - [Symbol.asyncIterator](): AsyncIterator { - return { - next: (): Promise> => { - if (this.queue.length > 0) { - const value = this.queue.shift()!; - return Promise.resolve({ value, done: false }); - } - if (this.closed) { - return Promise.resolve({ value: undefined, done: true }); - } - return new Promise>((resolve) => { - this.resolvers.push(resolve); - }); - }, - }; - } -} - type JsonRpcRequest = { id: number; method: string; @@ -2062,7 +2018,9 @@ class CodexAppServerAgentSession implements AgentSession { private currentThreadId: string | null = null; private currentTurnId: string | null = null; private client: CodexAppServerClient | null = null; - private eventQueue: Pushable | null = null; + private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); + private nextTurnOrdinal = 0; + private activeForegroundTurnId: string | null = null; private cachedRuntimeInfo: AgentRuntimeInfo | null = null; private historyPending = false; private persistedHistory: AgentTimelineItem[] = []; @@ -2397,37 +2355,70 @@ class CodexAppServerAgentSession implements AgentSession { } async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise { - const slashCommand = await this.resolveSlashCommandInvocation(prompt); - if (slashCommand) { - const commandInput = await this.buildCommandPromptInput( - slashCommand.commandName, - slashCommand.args, - ); - return this.runInternal(commandInput, options); - } - return this.runInternal(prompt, options); - } - - private async runInternal( - prompt: AgentPromptInput, - options?: AgentRunOptions, - ): Promise { - const events = this.streamInternal(prompt, options); const timeline: AgentTimelineItem[] = []; let finalText = ""; let usage: AgentUsage | undefined; + let turnId: string | null = null; + const bufferedEvents: AgentStreamEvent[] = []; + let settled = false; + let resolveCompletion!: () => void; + let rejectCompletion!: (error: Error) => void; - for await (const event of events) { + const processEvent = (event: AgentStreamEvent) => { + if (settled) { + return; + } + const eventTurnId = (event as { turnId?: string }).turnId; + if (turnId && eventTurnId && eventTurnId !== turnId) { + return; + } if (event.type === "timeline") { timeline.push(event.item); if (event.item.type === "assistant_message") { finalText = event.item.text; } - } else if (event.type === "turn_completed") { - usage = event.usage; - } else if (event.type === "turn_failed") { - throw new Error(event.error); + return; } + if (event.type === "turn_completed") { + usage = event.usage; + settled = true; + resolveCompletion(); + return; + } + if (event.type === "turn_failed") { + settled = true; + rejectCompletion(new Error(event.error)); + return; + } + if (event.type === "turn_canceled") { + settled = true; + resolveCompletion(); + } + }; + + const completion = new Promise((resolve, reject) => { + resolveCompletion = resolve; + rejectCompletion = reject; + }); + const unsubscribe = this.subscribe((event) => { + if (!turnId) { + bufferedEvents.push(event); + return; + } + processEvent(event); + }); + + try { + const result = await this.startTurn(prompt, options); + turnId = result.turnId; + for (const event of bufferedEvents) { + processEvent(event); + } + if (!settled) { + await completion; + } + } finally { + unsubscribe(); } const info = await this.getRuntimeInfo(); @@ -2439,114 +2430,92 @@ class CodexAppServerAgentSession implements AgentSession { }; } - async *stream( + async startTurn( prompt: AgentPromptInput, options?: AgentRunOptions, - ): AsyncGenerator { - const slashCommand = await this.resolveSlashCommandInvocation(prompt); - if (slashCommand) { - const commandInput = await this.buildCommandPromptInput( - slashCommand.commandName, - slashCommand.args, - ); - yield* this.streamInternal(commandInput, options); - return; + ): Promise<{ turnId: string }> { + if (this.activeForegroundTurnId) { + throw new Error("A foreground turn is already active"); } - yield* this.streamInternal(prompt, options); - } - private async *streamInternal( - prompt: AgentPromptInput, - options?: AgentRunOptions, - ): AsyncGenerator { await this.connect(); - if (!this.client) return; + if (!this.client) { + throw new Error("Codex client not initialized"); + } - const queue = new Pushable(); - this.eventQueue = queue; + const slashCommand = await this.resolveSlashCommandInvocation(prompt); + const effectivePrompt = slashCommand + ? await this.buildCommandPromptInput(slashCommand.commandName, slashCommand.args) + : prompt; + + if (this.currentThreadId) { + await this.ensureThreadLoaded(); + } else { + await this.ensureThread(); + } + + const input = await this.buildUserInput(effectivePrompt); + const preset = MODE_PRESETS[this.currentMode] ?? MODE_PRESETS[DEFAULT_CODEX_MODE_ID]; + const approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy; + const sandboxPolicyType = this.config.sandboxMode ?? preset.sandbox; + + const params: Record = { + threadId: this.currentThreadId, + input, + approvalPolicy, + sandboxPolicy: toSandboxPolicy( + sandboxPolicyType, + typeof this.config.networkAccess === "boolean" + ? this.config.networkAccess + : preset.networkAccess, + ), + }; + + if (this.config.model) { + params.model = this.config.model; + } + const thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId); + if (thinkingOptionId) { + params.effort = thinkingOptionId; + } + if (this.resolvedCollaborationMode) { + params.collaborationMode = { + mode: this.resolvedCollaborationMode.mode, + settings: this.resolvedCollaborationMode.settings, + }; + } + if (this.config.cwd) { + params.cwd = this.config.cwd; + } + if (options?.outputSchema) { + params.outputSchema = options.outputSchema; + } + if (this.config.systemPrompt?.trim()) { + params.developerInstructions = this.config.systemPrompt.trim(); + } + const codexConfig = this.buildCodexInnerConfig(); + if (codexConfig) { + params.config = codexConfig; + } + + const turnId = this.createTurnId(); + this.activeForegroundTurnId = turnId; try { - if (this.currentThreadId) { - await this.ensureThreadLoaded(); - } else { - await this.ensureThread(); - } - const input = await this.buildUserInput(prompt); - const preset = MODE_PRESETS[this.currentMode] ?? MODE_PRESETS[DEFAULT_CODEX_MODE_ID]; - const approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy; - const sandboxPolicyType = this.config.sandboxMode ?? preset.sandbox; - - const params: Record = { - threadId: this.currentThreadId, - input, - approvalPolicy, - sandboxPolicy: toSandboxPolicy( - sandboxPolicyType, - typeof this.config.networkAccess === "boolean" - ? this.config.networkAccess - : preset.networkAccess, - ), - }; - - if (this.config.model) { - params.model = this.config.model; - } - const thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId); - if (thinkingOptionId) { - params.effort = thinkingOptionId; - } - if (this.resolvedCollaborationMode) { - params.collaborationMode = { - mode: this.resolvedCollaborationMode.mode, - settings: this.resolvedCollaborationMode.settings, - }; - } - if (this.config.cwd) { - params.cwd = this.config.cwd; - } - if (options?.outputSchema) { - params.outputSchema = options.outputSchema; - } - if (this.config.systemPrompt?.trim()) { - params.developerInstructions = this.config.systemPrompt.trim(); - } - const codexConfig = this.buildCodexInnerConfig(); - if (codexConfig) { - params.config = codexConfig; - } - await this.client.request("turn/start", params, TURN_START_TIMEOUT_MS); - - let sawTurnStarted = false; - for await (const event of queue) { - // Drop pre-start timeline noise that can leak from the previous turn. - // Keep permission events, which can legitimately arrive before turn_started. - if (!sawTurnStarted) { - if (event.type === "permission_requested" || event.type === "permission_resolved") { - yield event; - continue; - } - if (event.type === "turn_started") { - sawTurnStarted = true; - } else { - continue; - } - } - - yield event; - if ( - event.type === "turn_completed" || - event.type === "turn_failed" || - event.type === "turn_canceled" - ) { - break; - } - } - } finally { - if (this.eventQueue === queue) { - this.eventQueue = null; - } + } catch (error) { + this.activeForegroundTurnId = null; + throw error; } + + return { turnId }; + } + + subscribe(callback: (event: AgentStreamEvent) => void): () => void { + this.subscribers.add(callback); + return () => { + this.subscribers.delete(callback); + }; } async *streamHistory(): AsyncGenerator { @@ -2742,8 +2711,8 @@ class CodexAppServerAgentSession implements AgentSession { this.pendingPermissionHandlers.clear(); this.pendingPermissions.clear(); this.resolvedPermissionRequests.clear(); - this.eventQueue?.end(); - this.eventQueue = null; + this.subscribers.clear(); + this.activeForegroundTurnId = null; if (this.client) { await this.client.dispose(); } @@ -2860,7 +2829,23 @@ class CodexAppServerAgentSession implements AgentSession { this.pendingAgentMessages.clear(); } } - this.eventQueue?.push(event); + this.notifySubscribers(event); + } + + private notifySubscribers(event: AgentStreamEvent): void { + const turnId = this.activeForegroundTurnId; + const tagged = turnId ? { ...event, turnId } : event; + for (const callback of this.subscribers) { + try { + callback(tagged); + } catch (error) { + this.logger.warn({ err: error }, "Subscriber callback threw"); + } + } + } + + private createTurnId(): string { + return `codex-turn-${this.nextTurnOrdinal++}`; } private handleNotification(method: string, params: unknown): void { @@ -2905,6 +2890,7 @@ class CodexAppServerAgentSession implements AgentSession { usage: this.latestUsage, }); } + this.activeForegroundTurnId = null; this.emittedItemStartedIds.clear(); this.emittedItemCompletedIds.clear(); this.emittedExecCommandStartedCallIds.clear(); diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index a9527c524..d17a29424 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -13,6 +13,7 @@ import { execFileSync } from "node:child_process"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import { OpenCodeAgentClient } from "./opencode-agent.js"; +import { streamSession } from "./test-utils/session-stream-adapter.js"; import type { AgentSessionConfig, AgentStreamEvent, @@ -158,7 +159,7 @@ const hasOpenCode = isBinaryInstalled("opencode"); const client = new OpenCodeAgentClient(logger); const session = await client.createSession(buildConfig(cwd)); - const iterator = session.stream("Say hello"); + const iterator = streamSession(session, "Say hello"); const turn = await collectTurnEvents(iterator); // HARD ASSERT: Turn completed successfully @@ -230,7 +231,8 @@ const hasOpenCode = isBinaryInstalled("opencode"); }); const planTurn = await collectTurnEvents( - planSession.stream( + streamSession( + planSession, "Create a file named plan-mode-output.txt in the current directory containing exactly hello.", ), ); @@ -250,7 +252,8 @@ const hasOpenCode = isBinaryInstalled("opencode"); }); const buildTurn = await collectTurnEvents( - buildSession.stream( + streamSession( + buildSession, "Create a file named build-mode-output.txt in the current directory containing exactly hello.", ), ); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 68594a173..e11dd3a6d 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -836,6 +836,9 @@ class OpenCodeAgentSession implements AgentSession { /** Tracks assistant messages already emitted from structured payloads. */ private emittedStructuredMessageIds = new Set(); private availableModesCache: AgentMode[] | null = null; + private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); + private nextTurnOrdinal = 0; + private activeForegroundTurnId: string | null = null; constructor(config: OpenCodeAgentConfig, client: OpencodeClient, sessionId: string) { this.config = config; @@ -872,22 +875,70 @@ class OpenCodeAgentSession implements AgentSession { } async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise { - const events = this.stream(prompt, options); const timeline: AgentTimelineItem[] = []; let finalText = ""; let usage: AgentUsage | undefined; + let turnId: string | null = null; + const bufferedEvents: AgentStreamEvent[] = []; + let settled = false; + let resolveCompletion!: () => void; + let rejectCompletion!: (error: Error) => void; - for await (const event of events) { + const processEvent = (event: AgentStreamEvent) => { + if (settled) { + return; + } + const eventTurnId = (event as { turnId?: string }).turnId; + if (turnId && eventTurnId && eventTurnId !== turnId) { + return; + } if (event.type === "timeline") { timeline.push(event.item); if (event.item.type === "assistant_message") { finalText = event.item.text; } - } else if (event.type === "turn_completed") { - usage = event.usage; - } else if (event.type === "turn_failed") { - throw new Error(event.error); + return; } + if (event.type === "turn_completed") { + usage = event.usage; + settled = true; + resolveCompletion(); + return; + } + if (event.type === "turn_failed") { + settled = true; + rejectCompletion(new Error(event.error)); + return; + } + if (event.type === "turn_canceled") { + settled = true; + resolveCompletion(); + } + }; + + const completion = new Promise((resolve, reject) => { + resolveCompletion = resolve; + rejectCompletion = reject; + }); + const unsubscribe = this.subscribe((event) => { + if (!turnId) { + bufferedEvents.push(event); + return; + } + processEvent(event); + }); + + try { + const result = await this.startTurn(prompt, options); + turnId = result.turnId; + for (const event of bufferedEvents) { + processEvent(event); + } + if (!settled) { + await completion; + } + } finally { + unsubscribe(); } return { @@ -898,10 +949,22 @@ class OpenCodeAgentSession implements AgentSession { }; } - async *stream( + async interrupt(): Promise { + this.abortController?.abort(); + await this.client.session.abort({ + sessionID: this.sessionId, + directory: this.config.cwd, + }); + } + + async startTurn( prompt: AgentPromptInput, options?: AgentRunOptions, - ): AsyncGenerator { + ): Promise<{ turnId: string }> { + if (this.activeForegroundTurnId) { + throw new Error("A foreground turn is already active"); + } + this.abortController = new AbortController(); await this.ensureMcpServersConfigured(); @@ -912,7 +975,6 @@ class OpenCodeAgentSession implements AgentSession { thinkingOptionId && thinkingOptionId !== "default" ? thinkingOptionId : undefined; const effectiveMode = normalizeOpenCodeModeId(this.currentMode); - // Send prompt asynchronously const promptResponse = await this.client.session.promptAsync({ sessionID: this.sessionId, directory: this.config.cwd, @@ -932,50 +994,76 @@ class OpenCodeAgentSession implements AgentSession { }); if (promptResponse.error) { - yield { + const errorMsg = JSON.stringify(promptResponse.error); + this.notifySubscribers({ type: "turn_failed", provider: "opencode", - error: JSON.stringify(promptResponse.error), - }; - return; + error: errorMsg, + }); + throw new Error(errorMsg); } - // Subscribe to events + const turnId = this.createTurnId(); + this.activeForegroundTurnId = turnId; + + void this.consumeEventStream(); + + return { turnId }; + } + + subscribe(callback: (event: AgentStreamEvent) => void): () => void { + this.subscribers.add(callback); + return () => { + this.subscribers.delete(callback); + }; + } + + private async consumeEventStream(): Promise { const eventsResult = await this.client.event.subscribe({ directory: this.config.cwd, }); try { for await (const event of eventsResult.stream) { - if (this.abortController.signal.aborted) { + if (this.abortController?.signal.aborted) { break; } const translated = this.translateEvent(event); for (const e of translated) { - yield e; + this.notifySubscribers(e); if (e.type === "turn_completed" || e.type === "turn_failed") { + this.activeForegroundTurnId = null; return; } } } } catch (error) { - if (!this.abortController.signal.aborted) { - yield { + if (!this.abortController?.signal.aborted) { + this.notifySubscribers({ type: "turn_failed", provider: "opencode", error: error instanceof Error ? error.message : "Stream error", - }; + }); + this.activeForegroundTurnId = null; } } } - async interrupt(): Promise { - this.abortController?.abort(); - await this.client.session.abort({ - sessionID: this.sessionId, - directory: this.config.cwd, - }); + private notifySubscribers(event: AgentStreamEvent): void { + const turnId = this.activeForegroundTurnId; + const tagged = turnId ? { ...event, turnId } : event; + for (const callback of this.subscribers) { + try { + callback(tagged); + } catch { + // Subscriber callback error isolation + } + } + } + + private createTurnId(): string { + return `opencode-turn-${this.nextTurnOrdinal++}`; } async *streamHistory(): AsyncGenerator { @@ -1132,6 +1220,8 @@ class OpenCodeAgentSession implements AgentSession { async close(): Promise { this.abortController?.abort(); + this.subscribers.clear(); + this.activeForegroundTurnId = null; } private buildPromptParts(prompt: AgentPromptInput): Array<{ type: "text"; text: string }> { diff --git a/packages/server/src/server/agent/providers/test-utils/session-stream-adapter.ts b/packages/server/src/server/agent/providers/test-utils/session-stream-adapter.ts new file mode 100644 index 000000000..d6e6948ad --- /dev/null +++ b/packages/server/src/server/agent/providers/test-utils/session-stream-adapter.ts @@ -0,0 +1,76 @@ +import type { + AgentPromptInput, + AgentRunOptions, + AgentSession, + AgentStreamEvent, +} from "../../agent-sdk-types.js"; + +function isTerminalEvent(event: AgentStreamEvent): boolean { + return ( + event.type === "turn_completed" || + event.type === "turn_failed" || + event.type === "turn_canceled" + ); +} + +export async function* streamSession( + session: Pick, + prompt: AgentPromptInput, + options?: AgentRunOptions, +): AsyncGenerator { + const queue: AgentStreamEvent[] = []; + const waiters: Array<() => void> = []; + let turnId: string | null = null; + let closed = false; + + const wake = () => { + const waiter = waiters.shift(); + waiter?.(); + }; + + const matchesTurn = (event: AgentStreamEvent): boolean => { + const eventTurnId = (event as { turnId?: string }).turnId; + return turnId == null || eventTurnId == null || eventTurnId === turnId; + }; + + const unsubscribe = session.subscribe((event) => { + if (!matchesTurn(event)) { + return; + } + queue.push(event); + wake(); + }); + + try { + const result = await session.startTurn(prompt, options); + turnId = result.turnId; + + for (let idx = queue.length - 1; idx >= 0; idx -= 1) { + if (!matchesTurn(queue[idx]!)) { + queue.splice(idx, 1); + } + } + + while (!closed) { + if (queue.length === 0) { + await new Promise((resolve) => { + waiters.push(resolve); + }); + continue; + } + + const event = queue.shift()!; + yield event; + if (isTerminalEvent(event)) { + return; + } + } + } finally { + closed = true; + unsubscribe(); + while (waiters.length > 0) { + const waiter = waiters.shift(); + waiter?.(); + } + } +} diff --git a/packages/server/src/server/persistence-hooks.test.ts b/packages/server/src/server/persistence-hooks.test.ts index 51a43a2f2..bedcb5734 100644 --- a/packages/server/src/server/persistence-hooks.test.ts +++ b/packages/server/src/server/persistence-hooks.test.ts @@ -20,12 +20,12 @@ const testLogger = { type ManagedAgentOverrides = Omit< Partial, - "config" | "pendingPermissions" | "session" | "pendingRun" + "config" | "pendingPermissions" | "session" | "activeForegroundTurnId" > & { config?: Partial; pendingPermissions?: Map; session?: AgentSession | null; - pendingRun?: ManagedAgent["pendingRun"]; + activeForegroundTurnId?: string | null; }; function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent { @@ -42,8 +42,8 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent extra: configOverrides.extra ?? { claude: { tone: "focused" } }, }; const session = lifecycle === "closed" ? null : (overrides.session ?? ({} as AgentSession)); - const pendingRun = - overrides.pendingRun ?? (lifecycle === "running" ? (async function* noop() {})() : null); + const activeForegroundTurnId = + overrides.activeForegroundTurnId ?? (lifecycle === "running" ? "test-turn-id" : null); const agent: ManagedAgent = { id: overrides.id ?? "agent-1", @@ -65,7 +65,9 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent availableModes: overrides.availableModes ?? [], currentModeId: overrides.currentModeId ?? config.modeId ?? null, pendingPermissions: overrides.pendingPermissions ?? new Map(), - pendingRun, + activeForegroundTurnId, + foregroundTurnWaiters: new Set(), + unsubscribeSession: null, timeline: overrides.timeline ?? [], persistence: overrides.persistence ?? null, historyPrimed: overrides.historyPrimed ?? true, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 8b78f07a2..d4dbe6867 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -796,16 +796,17 @@ export class Session { throw new Error(`Agent ${agentId} not found`); } - if (snapshot.lifecycle !== "running" && !snapshot.pendingRun) { + const hasInFlightRun = this.agentManager.hasInFlightRun(agentId); + if (!hasInFlightRun) { this.sessionLogger.trace( - { agentId, lifecycle: snapshot.lifecycle, pendingRun: Boolean(snapshot.pendingRun) }, + { agentId, lifecycle: snapshot.lifecycle, hasInFlightRun }, "interruptAgentIfRunning: skipping because agent is not running", ); return; } this.sessionLogger.debug( - { agentId, lifecycle: snapshot.lifecycle, pendingRun: Boolean(snapshot.pendingRun) }, + { agentId, lifecycle: snapshot.lifecycle, hasInFlightRun }, "interruptAgentIfRunning: interrupting", ); @@ -831,13 +832,7 @@ export class Session { if (!agentId) { return false; } - - const snapshot = this.agentManager.getAgent(agentId); - if (!snapshot) { - return false; - } - - return snapshot.lifecycle === "running" || Boolean(snapshot.pendingRun); + return this.agentManager.hasInFlightRun(agentId); } /** @@ -858,10 +853,7 @@ export class Session { ); let iterator: AsyncGenerator; try { - const snapshot = this.agentManager.getAgent(agentId); - const shouldReplace = Boolean( - snapshot && (snapshot.lifecycle === "running" || snapshot.pendingRun), - ); + const shouldReplace = this.agentManager.hasInFlightRun(agentId); iterator = shouldReplace ? this.agentManager.replaceAgentRun(agentId, prompt, runOptions) : this.agentManager.streamAgent(agentId, prompt, runOptions); @@ -1462,6 +1454,7 @@ export class Session { * Main entry point for processing session messages */ public async handleMessage(msg: SessionInboundMessage): Promise { + this.sessionLogger.trace({ inbound: msg }, "inbound message"); try { switch (msg.type) { case "voice_audio_chunk": @@ -6589,6 +6582,10 @@ export class Session { await this.ensureAgentLoaded(agentId); + this.sessionLogger.trace( + { agentId, messageId: msg.messageId, textPrefix: msg.text.slice(0, 80) }, + "send_agent_message_request: recording user message", + ); try { this.agentManager.recordUserMessage(agentId, msg.text, { messageId: msg.messageId, @@ -6602,6 +6599,10 @@ export class Session { } const prompt = this.buildAgentPrompt(msg.text, msg.images); + this.sessionLogger.trace( + { agentId, messageId: msg.messageId }, + "send_agent_message_request: starting agent stream", + ); const started = this.startAgentStream(agentId, prompt); if (!started.ok) { this.emit({ @@ -7359,6 +7360,7 @@ export class Session { * Emit a message to the client */ private emit(msg: SessionOutboundMessage): void { + this.sessionLogger.trace({ outbound: msg }, "outbound message"); if ( msg.type === "audio_output" && (process.env.TTS_DEBUG_AUDIO_DIR || isPaseoDictationDebugEnabled()) && diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index 46338fc4e..b873de66d 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -191,6 +191,9 @@ class FakeAgentSession implements AgentSession { private pendingPermissions: AgentPermissionRequest[] = []; private permissionGate: Deferred | null = null; private readonly historyPath: string; + private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); + private nextTurnOrdinal = 0; + private activeForegroundTurnId: string | null = null; constructor( providerName: string, @@ -270,205 +273,253 @@ class FakeAgentSession implements AgentSession { return { sessionId: this.id, finalText: resultText, timeline, usage }; } - async *stream(prompt: AgentPromptInput): AsyncGenerator { - // New run => reset interrupt gate. + async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> { + if (this.activeForegroundTurnId) { + throw new Error("A foreground turn is already active"); + } + + const turnId = `fake-turn-${this.nextTurnOrdinal++}`; + this.activeForegroundTurnId = turnId; + + void this.emitTurnEvents(prompt); + + return { turnId }; + } + + subscribe(callback: (event: AgentStreamEvent) => void): () => void { + this.subscribers.add(callback); + return () => { + this.subscribers.delete(callback); + }; + } + + private notifySubscribers(event: AgentStreamEvent): void { + const turnId = this.activeForegroundTurnId; + const tagged = turnId ? { ...event, turnId } : event; + for (const callback of this.subscribers) { + try { + callback(tagged); + } catch { + // Error isolation + } + } + } + + private async emitTurnEvents(prompt: AgentPromptInput): Promise { this.interruptSignal = createDeferred(); const slashCommand = await this.resolveSlashCommandInput(prompt); - if (slashCommand) { + const textPrompt = typeof prompt === "string" ? prompt : JSON.stringify(prompt); + try { + if (slashCommand) { + const threadStarted: AgentStreamEvent = { + type: "thread_started", + provider: this.providerName, + sessionId: this.id, + }; + await this.appendHistoryEvent(threadStarted); + this.notifySubscribers(threadStarted); + + const turnStarted: AgentStreamEvent = { + type: "turn_started", + provider: this.providerName, + }; + await this.appendHistoryEvent(turnStarted); + this.notifySubscribers(turnStarted); + + const result = await this.runSlashCommand(slashCommand.commandName, slashCommand.args); + for (const item of result.timeline) { + const timelineEvent: AgentStreamEvent = { + type: "timeline", + provider: this.providerName, + item, + }; + await this.appendHistoryEvent(timelineEvent); + this.notifySubscribers(timelineEvent); + } + + const completed: AgentStreamEvent = { + type: "turn_completed", + provider: this.providerName, + usage: result.usage ?? { inputTokens: 1, outputTokens: 1 }, + }; + await this.appendHistoryEvent(completed); + this.notifySubscribers(completed); + return; + } + + const markerMatch = /remember (?:this )?(?:marker|string|project name)[^"]*"([^"]+)"/i.exec( + textPrompt, + ); + if (markerMatch) { + this.memoryMarker = markerMatch[1] ?? null; + } + const threadStarted: AgentStreamEvent = { type: "thread_started", provider: this.providerName, sessionId: this.id, }; await this.appendHistoryEvent(threadStarted); - yield threadStarted; + this.notifySubscribers(threadStarted); const turnStarted: AgentStreamEvent = { type: "turn_started", provider: this.providerName, }; await this.appendHistoryEvent(turnStarted); - yield turnStarted; + this.notifySubscribers(turnStarted); - const result = await this.runSlashCommand(slashCommand.commandName, slashCommand.args); - for (const item of result.timeline) { - const timelineEvent: AgentStreamEvent = { + const tool = buildToolCallForPrompt(this.providerName, textPrompt); + if (tool) { + const needsPermission = this.needsPermissionForTool(tool.name, tool.input ?? {}); + const callId = randomUUID(); + const toolRunning: AgentStreamEvent = { type: "timeline", provider: this.providerName, - item, + item: { + type: "tool_call", + name: tool.name, + callId, + status: "running", + detail: { + type: "unknown", + input: tool.input ?? null, + output: null, + }, + error: null, + }, }; - await this.appendHistoryEvent(timelineEvent); - yield timelineEvent; + await this.appendHistoryEvent(toolRunning); + this.notifySubscribers(toolRunning); + + if (needsPermission) { + const request: AgentPermissionRequest = { + id: randomUUID(), + provider: this.providerName, + name: tool.name, + kind: "tool", + title: "Permission required", + description: "Test permission request", + input: tool.input ?? {}, + }; + this.pendingPermissions = [request]; + this.permissionGate = createDeferred(); + const permissionRequested: AgentStreamEvent = { + type: "permission_requested", + provider: this.providerName, + request, + }; + await this.appendHistoryEvent(permissionRequested); + this.notifySubscribers(permissionRequested); + + const response = await Promise.race([ + this.permissionGate.promise, + this.interruptSignal.promise.then( + () => + ({ + behavior: "deny", + interrupt: true, + message: "Interrupted", + }) satisfies AgentPermissionResponse, + ), + ]); + this.pendingPermissions = []; + this.permissionGate = null; + const permissionResolved: AgentStreamEvent = { + type: "permission_resolved", + provider: this.providerName, + requestId: request.id, + resolution: response, + }; + await this.appendHistoryEvent(permissionResolved); + this.notifySubscribers(permissionResolved); + + if (response.behavior === "deny") { + if (response.interrupt) { + const canceled: AgentStreamEvent = { + type: "turn_canceled", + provider: this.providerName, + reason: "permission denied", + }; + await this.appendHistoryEvent(canceled); + this.notifySubscribers(canceled); + return; + } + + const deniedCompleted: AgentStreamEvent = { + type: "turn_completed", + provider: this.providerName, + usage: { inputTokens: 1, outputTokens: 0 }, + }; + await this.appendHistoryEvent(deniedCompleted); + this.notifySubscribers(deniedCompleted); + return; + } + } + + await this.applyToolSideEffects(tool.name, tool.input ?? {}, textPrompt); + + let toolOutput: unknown = tool.output; + if (!toolOutput && (tool.name === "Read" || tool.name === "read_file")) { + const pathInput = typeof tool.input?.path === "string" ? tool.input.path : "/etc/hosts"; + const resolvedPath = path.isAbsolute(pathInput) + ? pathInput + : path.join(this.config.cwd ?? process.cwd(), pathInput); + try { + const content = readFileSync(resolvedPath, "utf8"); + toolOutput = { path: pathInput, content }; + } catch { + toolOutput = { path: pathInput, content: "" }; + } + } + + const toolCompleted: AgentStreamEvent = { + type: "timeline", + provider: this.providerName, + item: { + type: "tool_call", + name: tool.name, + callId, + status: "completed", + detail: { + type: "unknown", + input: tool.input ?? null, + output: toolOutput ?? { ok: true }, + }, + error: null, + }, + }; + await this.appendHistoryEvent(toolCompleted); + this.notifySubscribers(toolCompleted); } + const assistantText = this.buildAssistantText(textPrompt); + const assistantChunkA: AgentStreamEvent = { + type: "timeline", + provider: this.providerName, + item: { type: "assistant_message", text: assistantText.slice(0, 6) }, + }; + await this.appendHistoryEvent(assistantChunkA); + this.notifySubscribers(assistantChunkA); + + const assistantChunkB: AgentStreamEvent = { + type: "timeline", + provider: this.providerName, + item: { type: "assistant_message", text: assistantText.slice(6) }, + }; + await this.appendHistoryEvent(assistantChunkB); + this.notifySubscribers(assistantChunkB); + const completed: AgentStreamEvent = { type: "turn_completed", provider: this.providerName, - usage: result.usage ?? { inputTokens: 1, outputTokens: 1 }, + usage: { inputTokens: 1, outputTokens: 1 }, }; await this.appendHistoryEvent(completed); - yield completed; - return; + this.notifySubscribers(completed); + } finally { + this.activeForegroundTurnId = null; } - - const textPrompt = typeof prompt === "string" ? prompt : JSON.stringify(prompt); - const markerMatch = /remember (?:this )?(?:marker|string|project name)[^"]*"([^"]+)"/i.exec( - textPrompt, - ); - if (markerMatch) { - this.memoryMarker = markerMatch[1] ?? null; - } - const threadStarted: AgentStreamEvent = { - type: "thread_started", - provider: this.providerName, - sessionId: this.id, - }; - await this.appendHistoryEvent(threadStarted); - yield threadStarted; - - const turnStarted: AgentStreamEvent = { type: "turn_started", provider: this.providerName }; - await this.appendHistoryEvent(turnStarted); - yield turnStarted; - - const tool = buildToolCallForPrompt(this.providerName, textPrompt); - if (tool) { - const needsPermission = this.needsPermissionForTool(tool.name, tool.input ?? {}); - const callId = randomUUID(); - const toolRunning: AgentStreamEvent = { - type: "timeline", - provider: this.providerName, - item: { - type: "tool_call", - name: tool.name, - callId, - status: "running", - detail: { - type: "unknown", - input: tool.input ?? null, - output: null, - }, - error: null, - }, - }; - await this.appendHistoryEvent(toolRunning); - yield toolRunning; - - if (needsPermission) { - const request: AgentPermissionRequest = { - id: randomUUID(), - provider: this.providerName, - name: tool.name, - kind: "tool", - title: "Permission required", - description: "Test permission request", - input: tool.input ?? {}, - }; - this.pendingPermissions = [request]; - this.permissionGate = createDeferred(); - const permissionRequested: AgentStreamEvent = { - type: "permission_requested", - provider: this.providerName, - request, - }; - await this.appendHistoryEvent(permissionRequested); - yield permissionRequested; - - const response = await this.permissionGate.promise; - this.pendingPermissions = []; - const permissionResolved: AgentStreamEvent = { - type: "permission_resolved", - provider: this.providerName, - requestId: request.id, - resolution: response, - }; - await this.appendHistoryEvent(permissionResolved); - yield permissionResolved; - - if (response.behavior === "deny") { - // Permission denied: do not execute the tool. - if (response.interrupt) { - const canceled: AgentStreamEvent = { - type: "turn_canceled", - provider: this.providerName, - reason: "permission denied", - }; - await this.appendHistoryEvent(canceled); - yield canceled; - return; - } - - const deniedCompleted: AgentStreamEvent = { - type: "turn_completed", - provider: this.providerName, - usage: { inputTokens: 1, outputTokens: 0 }, - }; - await this.appendHistoryEvent(deniedCompleted); - yield deniedCompleted; - return; - } - } - - await this.applyToolSideEffects(tool.name, tool.input ?? {}, textPrompt); - - let toolOutput: unknown = tool.output; - if (!toolOutput && (tool.name === "Read" || tool.name === "read_file")) { - const pathInput = typeof tool.input?.path === "string" ? tool.input.path : "/etc/hosts"; - const resolvedPath = path.isAbsolute(pathInput) - ? pathInput - : path.join(this.config.cwd ?? process.cwd(), pathInput); - try { - const content = readFileSync(resolvedPath, "utf8"); - toolOutput = { path: pathInput, content }; - } catch { - toolOutput = { path: pathInput, content: "" }; - } - } - - const toolCompleted: AgentStreamEvent = { - type: "timeline", - provider: this.providerName, - item: { - type: "tool_call", - name: tool.name, - callId, - status: "completed", - detail: { - type: "unknown", - input: tool.input ?? null, - output: toolOutput ?? { ok: true }, - }, - error: null, - }, - }; - await this.appendHistoryEvent(toolCompleted); - yield toolCompleted; - } - - const assistantText = this.buildAssistantText(textPrompt); - // Stream in two chunks to exercise client chunk coalescing. - const assistantChunkA: AgentStreamEvent = { - type: "timeline", - provider: this.providerName, - item: { type: "assistant_message", text: assistantText.slice(0, 6) }, - }; - await this.appendHistoryEvent(assistantChunkA); - yield assistantChunkA; - - const assistantChunkB: AgentStreamEvent = { - type: "timeline", - provider: this.providerName, - item: { type: "assistant_message", text: assistantText.slice(6) }, - }; - await this.appendHistoryEvent(assistantChunkB); - yield assistantChunkB; - - const completed: AgentStreamEvent = { - type: "turn_completed", - provider: this.providerName, - usage: { inputTokens: 1, outputTokens: 1 }, - }; - await this.appendHistoryEvent(completed); - yield completed; } async *streamHistory(): AsyncGenerator {