From 42245d139ad1f3ba93c3e691e9e0a7971169ea79 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 16 Aug 2026 13:58:40 +0200 Subject: [PATCH] Add active-turn steering (#3394) * feat(agents): add active-turn steering Let users choose whether an immediate prompt interrupts or joins the active turn. The daemon owns exact-turn admission, provider delivery, safe fallback, and canonical timeline identity while queueing remains client-side. * fix(agents): stabilize steering history and replay QA * fix(agents): preserve steering timeline order * fix(agents): reconcile resumed provider history * fix(agents): serialize steering with cancellation * test(agents): prove Claude multi-tool steering * test(agents): force explicit history hydration * fix(agents): flush output before steer admission --- docs/providers.md | 3 + .../browser/agent-message-submission.spec.ts | 146 +- .../settings-toggle-tab-regression.spec.ts | 2 +- packages/app/e2e/support/helpers/composer.ts | 12 +- .../support/helpers/daemon-websocket-gate.ts | 116 +- packages/app/src/agent-stream/layout.test.ts | 59 +- packages/app/src/agent-stream/layout.ts | 23 +- .../src/agent-stream/render-strategy.test.ts | 35 + packages/app/src/agent-stream/strategy.ts | 5 +- .../src/agent-stream/turn-membership.test.ts | 113 ++ .../app/src/agent-stream/turn-membership.ts | 26 + packages/app/src/composer/actions.test.ts | 67 + packages/app/src/composer/actions.ts | 11 +- packages/app/src/composer/index.tsx | 39 +- packages/app/src/composer/input/input.tsx | 7 +- .../app/src/composer/input/labels.test.ts | 16 + packages/app/src/composer/input/labels.ts | 10 +- packages/app/src/composer/input/state.test.ts | 23 + packages/app/src/composer/input/state.ts | 3 +- packages/app/src/composer/submit.test.ts | 33 + .../src/hooks/use-settings/storage.test.ts | 8 + .../app/src/hooks/use-settings/storage.ts | 11 +- packages/app/src/i18n/resources/ar.ts | 4 + packages/app/src/i18n/resources/en.ts | 4 + packages/app/src/i18n/resources/es.ts | 4 + packages/app/src/i18n/resources/fr.ts | 4 + packages/app/src/i18n/resources/ja.ts | 4 + packages/app/src/i18n/resources/ko.ts | 4 + packages/app/src/i18n/resources/pt-BR.ts | 4 + packages/app/src/i18n/resources/ru.ts | 4 + packages/app/src/i18n/resources/zh-CN.ts | 3 + .../src/runtime/replica-cache/index.test.ts | 37 + .../app/src/runtime/replica-cache/index.ts | 4 + packages/app/src/screens/settings-screen.tsx | 6 +- .../timeline/session-stream-reducers.test.ts | 38 +- .../src/timeline/session-stream-reducers.ts | 2 + packages/app/src/timeline/turn-time.ts | 9 +- packages/app/src/types/stream.test.ts | 71 + packages/app/src/types/stream.ts | 106 +- packages/client/src/daemon-client.ts | 3 + .../src/messages.active-turn-behavior.test.ts | 87 + packages/protocol/src/messages.ts | 7 + .../agent-manager-stream-coalescing.test.ts | 4 +- .../src/server/agent/agent-manager.test.ts | 1454 ++++++++++++++++- .../server/src/server/agent/agent-manager.ts | 419 ++++- .../src/server/agent/agent-prompt.test.ts | 40 - .../server/src/server/agent/agent-prompt.ts | 83 +- .../src/server/agent/agent-sdk-types.ts | 7 + .../agent/agent-timeline-store-types.ts | 11 +- .../src/server/agent/agent-timeline-store.ts | 3 +- .../agent/durable-timeline-buffer.test.ts | 226 +++ .../server/agent/durable-timeline-buffer.ts | 118 ++ .../agent/file-agent-timeline-store.test.ts | 226 +++ .../server/agent/file-agent-timeline-store.ts | 274 ++++ .../agent/history-reconciliation.test.ts | 135 ++ .../server/agent/history-reconciliation.ts | 157 ++ .../agent/providers/claude/agent.test.ts | 48 + .../server/agent/providers/claude/agent.ts | 53 +- .../providers/codex-app-server-agent.test.ts | 139 ++ .../agent/providers/codex-app-server-agent.ts | 93 +- .../providers/codex/app-server-transport.ts | 21 +- .../codex/test-utils/fake-app-server.ts | 21 + .../agent/providers/mock-load-test-agent.ts | 82 + .../src/server/agent/timeline-projection.ts | 10 +- packages/server/src/server/bootstrap.ts | 3 + ...d-during-tool-call-claude.real.e2e.test.ts | 323 +++- ...nd-during-tool-call-codex.real.e2e.test.ts | 282 +++- packages/server/src/server/session.ts | 41 +- .../server/src/server/wire-compat.test.ts | 26 + 69 files changed, 5203 insertions(+), 269 deletions(-) create mode 100644 packages/app/src/agent-stream/turn-membership.test.ts create mode 100644 packages/app/src/agent-stream/turn-membership.ts create mode 100644 packages/protocol/src/messages.active-turn-behavior.test.ts create mode 100644 packages/server/src/server/agent/durable-timeline-buffer.test.ts create mode 100644 packages/server/src/server/agent/durable-timeline-buffer.ts create mode 100644 packages/server/src/server/agent/file-agent-timeline-store.test.ts create mode 100644 packages/server/src/server/agent/file-agent-timeline-store.ts create mode 100644 packages/server/src/server/agent/history-reconciliation.test.ts create mode 100644 packages/server/src/server/agent/history-reconciliation.ts diff --git a/docs/providers.md b/docs/providers.md index 4e199e3b0..bd348dfba 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -81,6 +81,8 @@ OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prom `AgentManager` owns the one canonical timeline row for a foreground prompt carrying a Paseo `clientMessageId`. It records that row when `startTurn` accepts, with the wire `messageId` set to the same value. Provider adapters still emit their native user-message echo with the same `clientMessageId` when available; the manager records its provider identity on the internal row without changing or redispatching the wire item. If an adapter emits the echo before `startTurn` resolves, the manager records the provider identity with the row at acceptance. Provider adapters continue to own externally initiated user rows that have no Paseo client identity. Do not perform global transcript text dedupe. +Active-turn steering is an optional `AgentSession.steerActiveTurn` operation. The manager owns admission against its exact foreground turn, canonical user-message creation, echo reconciliation, and falls back to the normal interrupt-and-replace path only when the adapter reports `unavailable`. An adapter error is ambiguous delivery and must surface without an interrupt or retry. Codex calls `turn/steer` with the native expected turn and Paseo client user-message ID. Claude pushes an admitted steer into the exact active SDK query input; isolated control commands remain unavailable. The released OpenCode v1 adapter does not implement this operation. + Rewind accepts the canonical wire `messageId` and resolves it to the provider identity before calling the adapter. A submitted prompt cannot be rewound until its provider echo supplies that identity. Submitted user-message wire items carry the same Paseo ID in `messageId` and `clientMessageId`. Provider adapters attach `clientMessageId` only to the echo for that foreground submission; provider history and externally initiated user rows do not have a Paseo client ID. @@ -424,6 +426,7 @@ interface AgentSession { readonly features?: AgentFeature[]; run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise; startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>; + steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise; subscribe(callback: (event: AgentStreamEvent) => void): () => void; streamHistory(): AsyncGenerator; getRuntimeInfo(): Promise; diff --git a/packages/app/e2e/browser/agent-message-submission.spec.ts b/packages/app/e2e/browser/agent-message-submission.spec.ts index ebdd78358..ebd33be83 100644 --- a/packages/app/e2e/browser/agent-message-submission.spec.ts +++ b/packages/app/e2e/browser/agent-message-submission.spec.ts @@ -30,13 +30,14 @@ import { buildHostWorkspaceRoute } from "@/utils/host-routes"; import { WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES } from "@/screens/workspace/workspace-deck-retention"; import { delayBrowserAgentCreatedStatus } from "../support/helpers/new-workspace"; import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; -import { selectModel } from "../support/helpers/app"; +import { gotoAppShell, openSettings, selectModel } from "../support/helpers/app"; import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery"; import { expectResumeOverflowFallsBackToOneTail, rememberTimelineRequestCounts, } from "../support/helpers/timeline-resume"; import { workspaceDeckEntryLocator } from "../support/helpers/workspace-ui"; +import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork"; import { scrollTimelineToNewestLoadedEdge, scrollTimelineUntilOlderHistoryIsReachable, @@ -264,14 +265,20 @@ async function submitMessageThatWillBeRejected(page: Page, prompt: string): Prom async function expectRejectedSubmissionRestored( page: Page, - input: { prompt: string; errorMessage: string }, + input: { prompt: string; errorMessage: string; preservesActiveTurn?: boolean }, ): Promise { - await expect(page.getByText(input.errorMessage)).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole("alert").filter({ hasText: input.errorMessage })).toBeVisible({ + timeout: 30_000, + }); await expectComposerDraft(page, input.prompt); await expectComposerEditable(page); await expectAttachmentPill(page, "composer-image-attachment-pill"); - await expect(page.getByRole("button", { name: "Send message" })).toBeEnabled(); await expect(page.getByTestId("user-message").filter({ hasText: input.prompt })).toHaveCount(0); + if (input.preservesActiveTurn) { + await expect(page.getByTestId("turn-working-indicator")).toBeVisible(); + return; + } + await expect(page.getByRole("button", { name: "Send message" })).toBeEnabled(); await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0); } @@ -284,6 +291,60 @@ async function retryRestoredSubmission(page: Page, prompt: string): Promise { + const modifier = process.platform === "darwin" ? "Meta" : "Control"; + await page.keyboard.press(`${modifier}+Comma`); + await expect(page).toHaveURL(/\/settings\/general$/); + await selectSteerInSettings(page); +} + +async function selectSteerInSettings(page: Page): Promise { + await page.getByRole("button", { name: "Steer", exact: true }).click(); + await expect + .poll(async () => { + const raw = await page.evaluate(() => localStorage.getItem("@paseo:app-settings")); + return raw ? (JSON.parse(raw) as { sendBehavior?: unknown }).sendBehavior : null; + }) + .toBe("steer"); +} + +async function replaySteeredSleepTurnInBrowser( + page: Page, + testInfo: { workerIndex: number }, + shape: "claude" | "codex", +): Promise { + const gate = await installDaemonWebSocketGate(page); + gate.holdNextShellToolCall("completed"); + await gotoAppShell(page); + await openSettings(page); + await selectSteerInSettings(page); + const agent = await startRunningMockAgent(page, { + prefix: `steer-replay-${shape}-${testInfo.workerIndex}-`, + model: "ten-second-stream", + prompt: `Replay a ${shape}-shaped foreground shell tool call while the user steers this turn.`, + }); + try { + await expect(page.getByTestId("tool-call-badge").last()).toBeVisible({ timeout: 30_000 }); + await expectComposerVisible(page); + await submitMessage(page, "hello"); + + await expect(page.getByText("hello", { exact: true })).toHaveCount(1); + await expect(page.getByRole("button", { name: /^Worked for/ })).toHaveCount(0); + await expectInFlightForkAvailable(page); + + await gate.waitForHeldServerMessage(); + gate.releaseHeldServerMessage(); + await agent.client.waitForFinish(agent.agentId, 30_000); + + await expect(page.getByText("hello", { exact: true })).toHaveCount(1); + await expect(page.getByRole("button", { name: /^Worked for/ })).toHaveCount(1); + await expect(page.getByRole("button", { name: "Fork chat" }).last()).toBeVisible(); + } finally { + gate.restore(); + await agent.cleanup(); + } +} + async function queueMessage(page: Page, prompt: string): Promise { await fillComposerDraft(page, prompt); await sendDraftToQueue(page); @@ -1030,6 +1091,83 @@ test.describe("Agent message submission", () => { await retryRestoredSubmission(page, prompt); }); + test("restores an ambiguous Steer failure without retrying or interrupting", async ({ + page, + }, testInfo) => { + test.setTimeout(120_000); + const gate = await installDaemonWebSocketGate(page); + const prompt = "Restore this ambiguous Steer submission."; + const agent = await startRunningMockAgent(page, { + prefix: `steer-ambiguous-${testInfo.workerIndex}-`, + model: "one-minute-stream", + prompt: "Keep this turn active while a Steer request fails.", + featureValues: { mockSteerAmbiguousFailures: 1 }, + }); + try { + await configureSteerInSettings(page); + await page.goBack(); + await expectComposerVisible(page); + await expectAgentReadyToInterrupt(page); + const sendsBefore = gate.getClientRequestCount("send_agent_message_request"); + const cancelsBefore = gate.getClientRequestCount("cancel_agent_request"); + + await submitMessageThatWillBeRejected(page, prompt); + await expectRejectedSubmissionRestored(page, { + prompt, + errorMessage: "Requested mock steer transport failure", + preservesActiveTurn: true, + }); + + expect(gate.getClientRequestCount("send_agent_message_request")).toBe(sendsBefore + 1); + expect(gate.getClientRequestCount("cancel_agent_request")).toBe(cancelsBefore); + expect(gate.getClientRequests("send_agent_message_request").at(-1)).toMatchObject({ + text: prompt, + activeTurnBehavior: "steer", + }); + } finally { + gate.restore(); + await agent.cleanup(); + } + }); + + test("keeps an optimistic Steer prompt inside the active turn before acknowledgement", async ({ + page, + }, testInfo) => { + test.setTimeout(120_000); + const gate = await installDaemonWebSocketGate(page); + const agent = await startRunningMockAgent(page, { + prefix: `steer-optimistic-${testInfo.workerIndex}-`, + model: "one-minute-stream", + prompt: "Keep this turn active while the user steers it.", + }); + try { + await configureSteerInSettings(page); + await page.goBack(); + await expectComposerVisible(page); + await expectAgentReadyToInterrupt(page); + gate.holdNextServerMessage("send_agent_message_response"); + await submitMessage(page, "hello"); + await gate.waitForHeldServerMessage("send_agent_message_response"); + await expect(page.getByText("hello", { exact: true })).toHaveCount(1); + await expect(page.getByText(/^Worked for/)).toHaveCount(0); + gate.releaseHeldServerMessage("send_agent_message_response"); + await expect(page.getByText("hello", { exact: true })).toHaveCount(1); + } finally { + gate.restore(); + await agent.cleanup(); + } + }); + + test("replays Claude-shaped steering inside one active turn", async ({ page }, testInfo) => { + test.setTimeout(120_000); + await replaySteeredSleepTurnInBrowser(page, testInfo, "claude"); + }); + + test("replays Codex-shaped steering inside one active turn", async ({ page }, testInfo) => { + test.setTimeout(120_000); + await replaySteeredSleepTurnInBrowser(page, testInfo, "codex"); + }); + test("restores overlapping queued sends when their connection fails", async ({ page, }, testInfo) => { diff --git a/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts b/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts index ff3d1f7cc..456f8e623 100644 --- a/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts +++ b/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts @@ -12,7 +12,7 @@ async function pressSettingsToggleShortcut(page: import("@playwright/test").Page async function expectSendBehavior( page: import("@playwright/test").Page, - expected: "interrupt" | "queue", + expected: "interrupt" | "queue" | "steer", ) { await expect .poll(async () => { diff --git a/packages/app/e2e/support/helpers/composer.ts b/packages/app/e2e/support/helpers/composer.ts index a0d846cbc..c989ce6fd 100644 --- a/packages/app/e2e/support/helpers/composer.ts +++ b/packages/app/e2e/support/helpers/composer.ts @@ -198,13 +198,20 @@ export async function selectGithubOption( export interface MockAgentSetup { client: SeedDaemonClient; repo: Awaited>; + workspaceId: string; + agentId: string; cleanup: () => Promise; } /** Create a temp repo, start a mock agent, navigate to it, and wait for it to be running. */ export async function startRunningMockAgent( page: Page, - opts: { prefix: string; model: string; prompt: string }, + opts: { + prefix: string; + model: string; + prompt: string; + featureValues?: Record; + }, ): Promise { const serverId = getServerId(); @@ -222,6 +229,7 @@ export async function startRunningMockAgent( cwd: repo.path, workspaceId: workspace.id, model: opts.model, + featureValues: opts.featureValues, }); const agentUrl = `${buildHostWorkspaceRoute(serverId, workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`; await page.goto(agentUrl); @@ -233,6 +241,8 @@ export async function startRunningMockAgent( return { client, repo, + workspaceId: workspace.id, + agentId: agent.id, cleanup: async () => { await client.removeProject(workspace.projectId).catch(() => undefined); await client.close().catch(() => undefined); diff --git a/packages/app/e2e/support/helpers/daemon-websocket-gate.ts b/packages/app/e2e/support/helpers/daemon-websocket-gate.ts index 687ab6e06..869c8f1e4 100644 --- a/packages/app/e2e/support/helpers/daemon-websocket-gate.ts +++ b/packages/app/e2e/support/helpers/daemon-websocket-gate.ts @@ -20,6 +20,9 @@ interface ClientRequest { payload?: unknown; mode?: unknown; path?: unknown; + agentId?: unknown; + text?: unknown; + activeTurnBehavior?: unknown; } interface ServerMessage { @@ -48,6 +51,11 @@ interface HeldServerMessage { blockedAgentId?: string; } +interface PendingServerMessageHold { + matches: (message: ClientRequest | null) => boolean; + blockAgentStreamFollowers?: boolean; +} + function readSessionMessage(message: string | Buffer): ClientRequest | null { try { const envelope = JSON.parse(typeof message === "string" ? message : message.toString()) as { @@ -125,6 +133,25 @@ function forceTimelineReset(message: string | Buffer, enabled: boolean): string return JSON.stringify(envelope); } +function rewriteShellToolCommand( + message: string | Buffer, + command: string | null, +): string | Buffer { + if (!command || typeof message !== "string") return message; + const envelope = JSON.parse(message) as { + message?: { payload?: { event?: { type?: unknown; item?: Record } } }; + payload?: { event?: { type?: unknown; item?: Record } }; + }; + const event = (envelope.message?.payload ?? envelope.payload)?.event; + if (event?.type !== "timeline" || event.item?.type !== "tool_call") return message; + const detail = event.item.detail; + if (!detail || typeof detail !== "object" || (detail as { type?: unknown }).type !== "shell") { + return message; + } + (detail as { command?: unknown }).command = command; + return JSON.stringify(envelope); +} + function readAgentStreamEventType(message: ClientRequest | null): string | null { if (message?.type !== "agent_stream" || !message.payload || typeof message.payload !== "object") { return null; @@ -152,6 +179,36 @@ function readAgentStreamItemType(message: ClientRequest | null): string | null { : null; } +function matchesToolCall( + message: ClientRequest | null, + input: { status: string; command?: string }, +): boolean { + if (readAgentStreamItemType(message) !== "tool_call") return false; + const event = ( + message?.payload as + | { + event?: { item?: { status?: unknown; detail?: { command?: unknown } } }; + } + | undefined + )?.event; + return ( + event?.item?.status === input.status && + (input.command === undefined || event.item.detail?.command === input.command) + ); +} + +function matchesShellToolCall(message: ClientRequest | null, status: string): boolean { + if (readAgentStreamItemType(message) !== "tool_call") return false; + const event = ( + message?.payload as + | { + event?: { item?: { status?: unknown; detail?: { type?: unknown } } }; + } + | undefined + )?.event; + return event?.item?.status === status && event.item.detail?.type === "shell"; +} + function shouldSuppressServerMessage(input: { message: ClientRequest | null; messageTypes: ReadonlySet; @@ -226,10 +283,11 @@ export async function installDaemonWebSocketGate(page: Page) { let forceTimelineEpochReset = false; let stripAssistantMessageIds = false; let stripCanonicalSubmittedPromptsFeature = false; + let shellToolCommandOverride: string | null = null; let heldClientRequestType: string | null = null; let heldClientRequest: { server: WebSocketRoute; message: string | Buffer } | null = null; let resolveHeldClientRequest: (() => void) | null = null; - const pendingServerMessageHolds = new Map boolean>(); + const pendingServerMessageHolds = new Map(); const heldServerMessages: HeldServerMessage[] = []; const heldServerMessageWaiters = new Set<() => void>(); const suppressedServerMessageTypes = new Set(); @@ -243,6 +301,7 @@ export async function installDaemonWebSocketGate(page: Page) { total: { agents: 0, workspaces: 0 }, }; const clientRequestCounts = new Map(); + const clientRequests = new Map(); const timelineRequestCounts = new Map(); const serverMessageCounts = new Map(); const agentStreamEventCounts = new Map(); @@ -322,11 +381,11 @@ export async function installDaemonWebSocketGate(page: Page) { if (!suppressed) blocked.agentStreamFollowers.push(input.message); return true; } - const matchedHold = Array.from(pendingServerMessageHolds).find(([, matches]) => - matches(input.parsed), + const matchedHold = Array.from(pendingServerMessageHolds).find(([, hold]) => + hold.matches(input.parsed), ); if (!matchedHold) return suppressed; - const [key] = matchedHold; + const [key, hold] = matchedHold; pendingServerMessageHolds.delete(key); heldServerMessages.push({ browser: input.browser, @@ -334,7 +393,7 @@ export async function installDaemonWebSocketGate(page: Page) { key, agentStreamFollowers: [], blockedAgentId: - readAgentStreamEventType(input.parsed) === "turn_started" + hold.blockAgentStreamFollowers || readAgentStreamEventType(input.parsed) === "turn_started" ? (agentId ?? undefined) : undefined, }); @@ -385,6 +444,11 @@ export async function installDaemonWebSocketGate(page: Page) { } const request = readClientRequest(message); recordClientRequest(request, clientRequestCounts, timelineRequestCounts, directoryStarts); + if (typeof request?.type === "string") { + const requests = clientRequests.get(request.type) ?? []; + requests.push(request); + clientRequests.set(request.type, requests); + } if (request?.type === heldClientRequestType) { heldClientRequest = { server, message }; resolveHeldClientRequest?.(); @@ -417,6 +481,7 @@ export async function installDaemonWebSocketGate(page: Page) { stripAssistantMessageIds, serverMessage?.type, ); + outboundMessage = rewriteShellToolCommand(outboundMessage, shellToolCommandOverride); outboundMessage = stripCanonicalSubmittedPrompts( outboundMessage, stripCanonicalSubmittedPromptsFeature, @@ -516,25 +581,36 @@ export async function installDaemonWebSocketGate(page: Page) { heldClientRequestType = null; }, holdNextServerMessage(type: string): void { - pendingServerMessageHolds.set(serverMessageKey(type), (message) => message?.type === type); + pendingServerMessageHolds.set(serverMessageKey(type), { + matches: (message) => message?.type === type, + }); }, holdNextAgentUpdate(agentId: string, status: string): void { const heldAgentUpdate = { agentId, status }; - pendingServerMessageHolds.set(agentUpdateKey(agentId, status), (message) => - matchesAgentUpdate(message, heldAgentUpdate), - ); + pendingServerMessageHolds.set(agentUpdateKey(agentId, status), { + matches: (message) => matchesAgentUpdate(message, heldAgentUpdate), + }); }, holdNextAgentStreamEvent(type: string): void { - pendingServerMessageHolds.set( - agentStreamEventKey(type), - (message) => readAgentStreamEventType(message) === type, - ); + pendingServerMessageHolds.set(agentStreamEventKey(type), { + matches: (message) => readAgentStreamEventType(message) === type, + }); }, holdNextAgentStreamItem(type: string): void { - pendingServerMessageHolds.set( - agentStreamItemKey(type), - (message) => readAgentStreamItemType(message) === type, - ); + pendingServerMessageHolds.set(agentStreamItemKey(type), { + matches: (message) => readAgentStreamItemType(message) === type, + }); + }, + holdNextToolCall(input: { status: string; command?: string }): void { + pendingServerMessageHolds.set(`tool-call:${input.status}:${input.command ?? ""}`, { + matches: (message) => matchesToolCall(message, input), + }); + }, + holdNextShellToolCall(status: string): void { + pendingServerMessageHolds.set(`shell-tool-call:${status}`, { + matches: (message) => matchesShellToolCall(message, status), + blockAgentStreamFollowers: true, + }); }, async waitForHeldServerMessage(type?: string): Promise { const key = type ? serverMessageKey(type) : null; @@ -686,6 +762,9 @@ export async function installDaemonWebSocketGate(page: Page) { setCanonicalSubmittedPromptsStripped(stripped: boolean): void { stripCanonicalSubmittedPromptsFeature = stripped; }, + setShellToolCommandOverride(command: string | null): void { + shellToolCommandOverride = command; + }, setAgentStreamSuppressed(suppressed: boolean): void { suppressAgentStream = suppressed; }, @@ -702,6 +781,9 @@ export async function installDaemonWebSocketGate(page: Page) { getClientRequestCount(type: string): number { return clientRequestCounts.get(type) ?? 0; }, + getClientRequests(type: string): ReadonlyArray { + return [...(clientRequests.get(type) ?? [])]; + }, getTimelineRequestCount(direction: "tail" | "before" | "after"): number { return timelineRequestCounts.get(direction) ?? 0; }, diff --git a/packages/app/src/agent-stream/layout.test.ts b/packages/app/src/agent-stream/layout.test.ts index 1cc645f16..ec75aaec5 100644 --- a/packages/app/src/agent-stream/layout.test.ts +++ b/packages/app/src/agent-stream/layout.test.ts @@ -26,6 +26,7 @@ function assistantMessage( id: string, seed: number, block?: { groupId: string; index: number }, + turnId?: string, ): Extract { return { kind: "assistant_message", @@ -33,14 +34,20 @@ function assistantMessage( text: id, timestamp: timestamp(seed), ...(block ? { blockGroupId: block.groupId, blockIndex: block.index } : {}), + ...(turnId ? { turnId } : {}), }; } -function toolCall(id: string, seed: number): Extract { +function toolCall( + id: string, + seed: number, + turnId?: string, +): Extract { return { kind: "tool_call", id, timestamp: timestamp(seed), + ...(turnId ? { turnId } : {}), payload: { source: "orchestrator", data: { @@ -143,6 +150,44 @@ function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem { } describe("layoutStream", () => { + it("does not borrow an assistant footer across adjacent tagged tool-only turns", () => { + const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1"); + const nextTool = toolCall("next-tool", 2, "turn-2"); + const layout = layoutFor({ + platform: "web", + tail: [priorAssistant, nextTool], + timingIds: [priorAssistant.id], + }); + + expect(layout.auxiliaryTurnFooter).toBeNull(); + expect(footerOwners(layout)).toEqual([priorAssistant.id]); + }); + + it("recomputes cached history layout when only the live boundary turn changes", () => { + const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1"); + const history = [priorAssistant]; + const liveHead = [toolCall("live-tool", 2, "turn-1")]; + const strategy = strategyFor("web"); + const first = layoutStream({ + strategy, + isTurnActive: true, + history, + liveHead, + timingByAssistantId: timingFor(priorAssistant.id), + }); + + liveHead[0] = { ...liveHead[0]!, turnId: "turn-2" }; + const second = layoutStream({ + strategy, + isTurnActive: true, + history, + liveHead, + timingByAssistantId: timingFor(priorAssistant.id), + }); + + expect(footerOwners(first)).toEqual([]); + expect(footerOwners(second)).toEqual([priorAssistant.id]); + }); it.each(["web", "android"] as const)( "marks only the active live-head assistant block as streaming on %s", (platform) => { @@ -249,6 +294,18 @@ describe("layoutStream", () => { expect(assistantRow.frameOrder).toBe("footer-then-content"); }); + it("keeps an accepted steer inline while its canonical turn is active", () => { + const assistant = { ...assistantMessage("a1", 2), turnId: "turn-1" }; + const steer = { ...userMessage("u2", 3), turnId: "turn-1" }; + const layout = layoutFor({ + platform: "web", + isTurnActive: true, + tail: [{ ...userMessage("u1", 1), turnId: "turn-1" }, assistant, steer], + timingIds: [assistant.id], + }); + expect(footerOwners(layout)).toEqual([]); + }); + it("keeps forward stream content before its completed footer", () => { const assistant = assistantMessage("a1", 2); const layout = layoutFor({ diff --git a/packages/app/src/agent-stream/layout.ts b/packages/app/src/agent-stream/layout.ts index 990b1f807..221dbda33 100644 --- a/packages/app/src/agent-stream/layout.ts +++ b/packages/app/src/agent-stream/layout.ts @@ -2,6 +2,7 @@ import type { TurnTiming } from "@/timeline/turn-time"; import type { StreamItem } from "@/types/stream"; import { getAssistantBlockSpacing, getGapBetweenStreamItems } from "./spacing"; import type { StreamFrameChildOrder, StreamStrategy } from "./strategy"; +import { continuesTurn, isTurnBoundary } from "./turn-membership"; export type StreamToolSequence = "single" | "first" | "middle" | "last" | "none"; @@ -87,6 +88,7 @@ function findLatestAssistantInTurn(input: { let items = input.items; let index = input.startIndex; let canCrossBoundary = true; + let laterItem: StreamItem | null = null; while (true) { for ( @@ -95,12 +97,13 @@ function findLatestAssistantInTurn(input: { index = input.strategy.getNeighborIndex(index, "above") ) { const item = items[index]; - if (!item || item.kind === "user_message") { + if (!item || (laterItem && !continuesTurn(item, laterItem))) { return null; } if (item.kind === "assistant_message") { return { item, items, index }; } + laterItem = item; } if ( @@ -157,7 +160,7 @@ function resolveCompletedFooter(input: { boundaryAboveItems: StreamItem[] | null; boundaryAboveIndex: number | null; }): TurnFooterHost | null { - if (input.item.kind === "user_message" || input.belowItem?.kind !== "user_message") { + if (input.item.kind === "user_message" || !isTurnBoundary(input.item, input.belowItem)) { return null; } @@ -194,8 +197,10 @@ function getToolSequence(input: { return "none"; } - const hasAbove = isToolSequenceItem(input.aboveItem); - const hasBelow = isToolSequenceItem(input.belowItem); + const hasAbove = + isToolSequenceItem(input.aboveItem) && continuesTurn(input.aboveItem, input.item); + const hasBelow = + isToolSequenceItem(input.belowItem) && continuesTurn(input.item, input.belowItem); if (hasAbove && hasBelow) { return "middle"; } @@ -273,7 +278,9 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] { toolSequence: getToolSequence({ item, aboveItem, belowItem }), isFirstInUserGroup: item.kind === "user_message" && aboveItem?.kind !== "user_message", isLastInUserGroup: item.kind === "user_message" && belowItem?.kind !== "user_message", - isLastInToolSequence: isToolSequenceItem(item) && !isToolSequenceItem(belowItem), + isLastInToolSequence: + isToolSequenceItem(item) && + !(isToolSequenceItem(belowItem) && continuesTurn(item, belowItem)), frameOrder: input.frameOrder, phase: input.phase, }; @@ -297,14 +304,14 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout { let history: StreamLayoutItem[]; if (input.history.length > 0) { - // The cache key encodes every input that can change history layout. liveHeadBoundaryItem.id - // and .kind are stable across text-only flushes (text growth doesn't change what kind of - // item borders history), so cached layout stays valid between flushes. + // The cache key encodes every input that can change history layout. The boundary turn ID + // is membership, so changing it must not reuse a layout from the adjacent turn. const historyCacheKey = [ frameOrder, historyBoundaryIndex ?? "null", liveHeadBoundaryItem?.id ?? "null", liveHeadBoundaryItem?.kind ?? "null", + liveHeadBoundaryItem?.turnId ?? "null", auxiliaryTurnFooter?.itemId ?? "null", ].join(":"); let byKey = historyLayoutCache.get(input.history); diff --git a/packages/app/src/agent-stream/render-strategy.test.ts b/packages/app/src/agent-stream/render-strategy.test.ts index ef6478455..c91910dc6 100644 --- a/packages/app/src/agent-stream/render-strategy.test.ts +++ b/packages/app/src/agent-stream/render-strategy.test.ts @@ -38,6 +38,24 @@ function assistantMessage(id: string, text: string, seed: number): StreamItem { }; } +function toolCall(id: string, seed: number): StreamItem { + return { + kind: "tool_call", + id, + timestamp: createTimestamp(seed), + payload: { + source: "orchestrator", + data: { + toolCallId: id, + toolName: "Shell", + arguments: "echo hi", + result: null, + status: "completed", + }, + }, + }; +} + describe("resolveStreamRenderStrategy", () => { it("uses forward_stream on web", () => { const strategy = resolveStreamRenderStrategy({ @@ -196,6 +214,23 @@ describe("neighbor and traversal semantics", () => { ).toBe("assistant-1\n\nassistant-2"); }); + it("does not collect copy or fork content across an adjacent tagged turn", () => { + const chronological: StreamItem[] = [ + { ...assistantMessage("a1", "first turn", 1), turnId: "turn-1" }, + { ...toolCall("tool", 2), turnId: "turn-2" }, + { ...assistantMessage("a2", "second turn", 3), turnId: "turn-2" }, + ]; + const strategy = resolveStreamRenderStrategy({ platform: "web", isMobileBreakpoint: false }); + + expect( + collectAssistantTurnContentForStreamRenderStrategy({ + strategy, + items: chronological, + startIndex: 2, + }), + ).toBe("second turn"); + }); + it("returns undefined neighbor when index would be out of bounds", () => { const forward = resolveStreamRenderStrategy({ platform: "web", diff --git a/packages/app/src/agent-stream/strategy.ts b/packages/app/src/agent-stream/strategy.ts index 4077bb025..9684500ff 100644 --- a/packages/app/src/agent-stream/strategy.ts +++ b/packages/app/src/agent-stream/strategy.ts @@ -1,6 +1,7 @@ import type { ComponentType, ReactElement, ReactNode, RefObject } from "react"; import type { StyleProp, ViewStyle } from "react-native"; import type { StreamItem } from "@/types/stream"; +import { continuesTurn } from "./turn-membership"; import type { StreamHistoryBoundary, StreamRenderSegments } from "./model"; import type { BottomAnchorLocalRequest, @@ -165,18 +166,20 @@ export function createStreamStrategy(config: StreamStrategyConfig): StreamStrate }, collectAssistantTurnContent: (items, startIndex) => { const messages: string[] = []; + let laterItem: StreamItem | null = null; for ( let index = startIndex; index >= 0 && index < items.length; index += config.assistantTurnTraversalStep ) { const currentItem = items[index]; - if (currentItem.kind === "user_message") { + if (!currentItem || (laterItem && !continuesTurn(currentItem, laterItem))) { break; } if (currentItem.kind === "assistant_message") { messages.push(currentItem.text); } + laterItem = currentItem; } return messages.toReversed().join("\n\n"); }, diff --git a/packages/app/src/agent-stream/turn-membership.test.ts b/packages/app/src/agent-stream/turn-membership.test.ts new file mode 100644 index 000000000..cb2992a00 --- /dev/null +++ b/packages/app/src/agent-stream/turn-membership.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import type { StreamItem } from "@/types/stream"; +import { buildAgentStreamRenderModel } from "./model"; +import { layoutStream } from "./layout"; +import { resolveStreamRenderStrategy } from "./strategy-resolver"; +import { continuesTurn } from "./turn-membership"; + +function at(second: number): Date { + return new Date(`2026-01-01T00:00:${second.toString().padStart(2, "0")}.000Z`); +} + +function user(id: string, second: number, turnId: string): StreamItem { + return { kind: "user_message", id, text: id, timestamp: at(second), turnId }; +} + +function assistant(id: string, second: number, turnId: string): StreamItem { + return { kind: "assistant_message", id, text: id, timestamp: at(second), turnId }; +} + +function runningTool(id: string, second: number, turnId: string): StreamItem { + return { + kind: "tool_call", + id, + timestamp: at(second), + turnId, + payload: { + source: "agent", + data: { + provider: "claude", + callId: id, + name: "Bash", + status: "running", + error: null, + detail: { type: "unknown", input: null, output: null }, + }, + }, + }; +} + +function layoutFor(items: StreamItem[], isTurnActive: boolean) { + const model = buildAgentStreamRenderModel({ + isTurnActive, + activeTurnStartedAt: isTurnActive ? at(1) : null, + tail: items, + head: [], + platform: "web", + isMobileBreakpoint: false, + }); + return { + model, + layout: layoutStream({ + strategy: resolveStreamRenderStrategy({ platform: "web", isMobileBreakpoint: false }), + isTurnActive, + history: model.segments.historyMounted, + liveHead: model.segments.liveHead, + timingByAssistantId: model.turnTiming.byAssistantId, + }), + }; +} + +function completedFooterIds(layout: ReturnType["layout"]): string[] { + return [ + ...layout.history.flatMap((row) => (row.completedFooter ? [row.completedFooter.itemId] : [])), + ...layout.liveHead.flatMap((row) => (row.completedFooter ? [row.completedFooter.itemId] : [])), + ...(layout.auxiliaryTurnFooter ? [layout.auxiliaryTurnFooter.itemId] : []), + ]; +} + +describe("canonical turn membership", () => { + it("stops tagged traversal at every mismatching turn ID", () => { + expect(continuesTurn(assistant("first", 1, "turn-1"), runningTool("next", 2, "turn-2"))).toBe( + false, + ); + expect(continuesTurn(runningTool("first", 1, "turn-1"), assistant("next", 2, "turn-1"))).toBe( + true, + ); + }); + + it.each([ + [ + "Claude", + (turnId: string) => [ + user("prompt", 1, turnId), + runningTool("sleep", 2, turnId), + user("hello", 3, turnId), + ], + ], + [ + "Codex", + (turnId: string) => [ + user("prompt", 1, turnId), + assistant("preface", 2, turnId), + runningTool("sleep", 3, turnId), + user("hello", 4, turnId), + ], + ], + ] as const)("keeps %s-shaped active steers in one visible turn", (_provider, build) => { + const turnId = "turn-1"; + const active = layoutFor(build(turnId), true); + + expect(completedFooterIds(active.layout)).toEqual([]); + expect(active.model.turnTiming.runningStartedAt).toEqual(at(1)); + + const completedItems = [...build(turnId), assistant("done", 9, turnId)]; + const completed = layoutFor(completedItems, false); + expect(completedFooterIds(completed.layout)).toEqual(["done"]); + expect(completed.model.turnTiming.byAssistantId.get("done")).toMatchObject({ + startedAt: at(1), + completedAt: at(9), + durationMs: 8000, + }); + }); +}); diff --git a/packages/app/src/agent-stream/turn-membership.ts b/packages/app/src/agent-stream/turn-membership.ts new file mode 100644 index 000000000..e2d01d6d6 --- /dev/null +++ b/packages/app/src/agent-stream/turn-membership.ts @@ -0,0 +1,26 @@ +import type { StreamItem } from "@/types/stream"; + +/** + * Canonical turn IDs take precedence. Timelines without them retain the legacy + * user-message boundary rule so old daemons and persisted rows need no rewrite. + */ +export function continuesTurn(previous: StreamItem | null, next: StreamItem | null): boolean { + if (!previous || !next) return false; + if (previous.turnId !== undefined && next.turnId !== undefined) { + return previous.turnId === next.turnId; + } + return next.kind !== "user_message"; +} + +export function isTurnBoundary(previous: StreamItem | null, next: StreamItem | null): boolean { + return previous !== null && next !== null && !continuesTurn(previous, next); +} + +/** Whether `item` begins a new chronological turn after `previous`. */ +export function startsNewTurn(item: StreamItem, previous: StreamItem | null): boolean { + return previous === null || isTurnBoundary(previous, item); +} + +export function belongsToTurn(item: StreamItem, turnId: string | null): boolean { + return turnId !== null && item.turnId === turnId; +} diff --git a/packages/app/src/composer/actions.test.ts b/packages/app/src/composer/actions.test.ts index 4e66d8ef2..9727d73e1 100644 --- a/packages/app/src/composer/actions.test.ts +++ b/packages/app/src/composer/actions.test.ts @@ -179,6 +179,7 @@ interface FakeSendCall { text: string; options: { messageId: string; + activeTurnBehavior?: "interrupt" | "steer"; images: Array<{ data: string; mimeType: string }>; attachments: AgentAttachment[]; }; @@ -417,6 +418,50 @@ describe("pickAndPersistImages", () => { }); describe("dispatchComposerAgentMessage", () => { + it("forwards the configured active-turn intent without provider capability checks", async () => { + const client = createFakeSendClient(); + const stream = createFakeStream(); + + await dispatchComposerAgentMessage({ + client, + agentId: "agent", + text: "steer this turn", + attachments: [], + encodeImages: async () => [], + submission: stream, + activeTurnBehavior: "steer", + }); + + expect(client.calls[0]?.options.activeTurnBehavior).toBe("steer"); + }); + + it("stamps only a steer optimistic row with the daemon active turn ID", async () => { + const client = createFakeSendClient(); + const stream = createFakeStream(); + await dispatchComposerAgentMessage({ + client, + agentId: "agent", + text: "hello", + attachments: [], + encodeImages: async () => [], + submission: stream, + activeTurnBehavior: "steer", + activeTurnId: "turn-1", + }); + expect(stream.tail.get("agent")?.[0]).toMatchObject({ turnId: "turn-1" }); + const legacy = createFakeStream(); + await dispatchComposerAgentMessage({ + client, + agentId: "legacy", + text: "hello", + attachments: [], + encodeImages: async () => [], + submission: legacy, + activeTurnBehavior: "steer", + }); + expect(legacy.tail.get("legacy")?.[0]?.turnId).toBeUndefined(); + }); + it("removes the submitted prompt when the host rejects it", async () => { const rejection = new Error("Host rejected prompt"); const client = createFakeSendClient({ rejection }); @@ -477,6 +522,28 @@ describe("dispatchComposerAgentMessage", () => { ).rejects.toBe(transportError); }); + it("surfaces an ambiguous failure even when a concurrent canonical echo was observed", async () => { + const transportError = new Error("Connection lost after delivery may have occurred"); + const client = createFakeSendClient({ rejection: transportError }); + const submission: MessageSubmissionWriter = { + begin: () => {}, + accept: () => {}, + reject: () => "accepted", + }; + + await expect( + dispatchComposerAgentMessage({ + client, + agentId: "agent", + text: "ambiguous steer", + attachments: [], + encodeImages: passthroughEncodeImages, + submission, + activeTurnBehavior: "steer", + }), + ).rejects.toBe(transportError); + }); + it("sends text + image data + structured attachments and appends user_message to the tail when head is empty", async () => { const client = createFakeSendClient(); const stream = createFakeStream(); diff --git a/packages/app/src/composer/actions.ts b/packages/app/src/composer/actions.ts index 55f0578cb..adf331dfe 100644 --- a/packages/app/src/composer/actions.ts +++ b/packages/app/src/composer/actions.ts @@ -1,4 +1,5 @@ import type { ForgeSearchItem } from "@getpaseo/protocol/messages"; +import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages"; import type { AttachmentMetadata, ComposerAttachment, @@ -48,6 +49,7 @@ export interface ComposerSendClient { text: string, options: { messageId: string; + activeTurnBehavior?: ActiveTurnBehavior; images: Array<{ data: string; mimeType: string }>; attachments: ReturnType["attachments"]; }, @@ -176,6 +178,8 @@ export interface DispatchComposerAgentMessageInput { images: AttachmentMetadata[], ) => Promise | undefined>; submission: MessageSubmissionWriter; + activeTurnBehavior?: ActiveTurnBehavior; + activeTurnId?: string; } export async function dispatchComposerAgentMessage( @@ -191,19 +195,22 @@ export async function dispatchComposerAgentMessage( timestamp: new Date(), images: wirePayload.images, attachments: wirePayload.attachments, + ...(input.activeTurnBehavior === "steer" && input.activeTurnId + ? { turnId: input.activeTurnId } + : {}), }); input.submission.begin(input.agentId, userMessage); try { const imagesData = await input.encodeImages(wirePayload.images); await input.client.sendAgentMessage(input.agentId, input.text, { messageId: clientMessageId, + ...(input.activeTurnBehavior ? { activeTurnBehavior: input.activeTurnBehavior } : {}), images: imagesData ?? [], attachments: wirePayload.attachments, }); input.submission.accept(input.agentId, clientMessageId); } catch (error) { - const outcome = input.submission.reject(input.agentId, clientMessageId); - if (outcome === "accepted") return; + input.submission.reject(input.agentId, clientMessageId); throw error; } } diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 92bfa0905..635fe6b10 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -1324,12 +1324,6 @@ function ComposerContentImpl({ autocompleteOnKeyPressRef.current = autocomplete.onKeyPress; // Clear send error when user edits the input - useEffect(() => { - if (sendError && userInput) { - setSendError(null); - } - }, [userInput, sendError]); - useEffect(() => { setCursorIndex((current) => Math.min(current, userInput.length)); }, [userInput.length]); @@ -1338,7 +1332,13 @@ function ComposerContentImpl({ const { pickFiles } = useFilePicker(); const agentIdRef = useRef(agentId); const sendAgentMessageRef = useRef< - ((agentId: string, text: string, attachments: ComposerAttachment[]) => Promise) | null + | (( + agentId: string, + text: string, + attachments: ComposerAttachment[], + activeTurnBehavior: "interrupt" | "steer", + ) => Promise) + | null >(null); const onSubmitMessageRef = useRef(onSubmitMessage); @@ -1399,9 +1399,14 @@ function ComposerContentImpl({ if (!sendAgentMessageRef.current) { throw new Error(t("workspace.terminal.hostDisconnected")); } - await sendAgentMessageRef.current(agentIdRef.current, text, submitAttachments); + await sendAgentMessageRef.current( + agentIdRef.current, + text, + submitAttachments, + appSettings.sendBehavior === "steer" ? "steer" : "interrupt", + ); }, - [cwd, onMessageSent, t], + [appSettings.sendBehavior, cwd, onMessageSent, t], ); useEffect(() => { @@ -1413,6 +1418,7 @@ function ComposerContentImpl({ targetAgentId: string, text: string, sendAttachments: ComposerAttachment[], + activeTurnBehavior: "interrupt" | "steer", ) => { if (!client) { throw new Error(t("workspace.terminal.hostDisconnected")); @@ -1427,10 +1433,16 @@ function ComposerContentImpl({ }), encodeImages, submission: createMessageSubmissionWriter(serverId), + activeTurnBehavior, + activeTurnId: + activeTurnBehavior === "steer" + ? (useSessionStore.getState().sessions[serverId]?.agents.get(targetAgentId)?.activeTurn + ?.turnId ?? undefined) + : undefined, }); onAttentionPromptSend?.(); }; - }, [client, onAttentionPromptSend, serverId, supportsForgeSearch, t]); + }, [appSettings.sendBehavior, client, onAttentionPromptSend, serverId, supportsForgeSearch, t]); useEffect(() => { onSubmitMessageRef.current = onSubmitMessage; @@ -2185,7 +2197,12 @@ function ComposerContentImpl({ const messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint; const submitLoadingPressHandler = isAgentRunning ? handleCancelAgent : undefined; const sendErrorNode = useMemo( - () => (sendError ? {sendError} : null), + () => + sendError ? ( + + {sendError} + + ) : null, [sendError], ); const githubEmptyText = githubSearchResultsQuery.isFetching diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index d4cd4c26d..36945fe20 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -131,7 +131,7 @@ export interface MessageInputProps { isAgentRunning?: boolean; /** Controls what the default send action (Enter, send button, dictation) does * when the agent is running. "interrupt" sends immediately, "queue" queues. */ - defaultSendBehavior?: "interrupt" | "queue"; + defaultSendBehavior?: "interrupt" | "steer" | "queue"; /** Callback for queue button when agent is running */ onQueue?: (payload: MessagePayload) => void; /** Optional handler used when submit button is in loading state. */ @@ -1002,7 +1002,7 @@ interface SendButtonStateInput { isSubmitDisabled: boolean; isSubmitLoading: boolean; onSubmitLoadingPress: (() => void) | undefined; - defaultSendBehavior: "interrupt" | "queue"; + defaultSendBehavior: "interrupt" | "steer" | "queue"; isAgentRunning: boolean; } @@ -1052,7 +1052,7 @@ interface ResolvedMessageInputProps { voiceServerId: string | undefined; voiceAgentId: string | undefined; isAgentRunning: boolean; - defaultSendBehavior: "interrupt" | "queue"; + defaultSendBehavior: "interrupt" | "steer" | "queue"; onQueue: ((payload: MessagePayload) => void) | undefined; onSubmitLoadingPress: (() => void) | undefined; onKeyPressCallback: ((event: { key: string; preventDefault: () => void }) => boolean) | undefined; @@ -1608,6 +1608,7 @@ export const MessageInput = forwardRef( submitButtonAccessibilityLabel, canPressLoadingButton, defaultActionQueues, + defaultSendBehavior, isAgentRunning, t, }); diff --git a/packages/app/src/composer/input/labels.test.ts b/packages/app/src/composer/input/labels.test.ts index 7a5492c94..15aa16b8e 100644 --- a/packages/app/src/composer/input/labels.test.ts +++ b/packages/app/src/composer/input/labels.test.ts @@ -10,6 +10,7 @@ const translations: Record = { "composer.input.interruptAgent": "Interrupt agent", "composer.input.queueMessage": "Queue message", "composer.input.sendAndInterrupt": "Send and interrupt", + "composer.input.sendAndSteer": "Send and steer", "composer.input.sendMessage": "Send message", "composer.input.queue": "Queue", "composer.input.send": "Send", @@ -31,6 +32,7 @@ describe("composer input labels", () => { submitButtonAccessibilityLabel: undefined, canPressLoadingButton: true, defaultActionQueues: false, + defaultSendBehavior: "interrupt", isAgentRunning: true, t, }), @@ -40,6 +42,7 @@ describe("composer input labels", () => { submitButtonAccessibilityLabel: undefined, canPressLoadingButton: false, defaultActionQueues: true, + defaultSendBehavior: "queue", isAgentRunning: true, t, }), @@ -49,6 +52,7 @@ describe("composer input labels", () => { submitButtonAccessibilityLabel: undefined, canPressLoadingButton: false, defaultActionQueues: false, + defaultSendBehavior: "interrupt", isAgentRunning: true, t, }), @@ -58,6 +62,17 @@ describe("composer input labels", () => { submitButtonAccessibilityLabel: undefined, canPressLoadingButton: false, defaultActionQueues: false, + defaultSendBehavior: "steer", + isAgentRunning: true, + t, + }), + ).toBe("Send and steer"); + expect( + resolveSubmitAccessibilityLabel({ + submitButtonAccessibilityLabel: undefined, + canPressLoadingButton: false, + defaultActionQueues: false, + defaultSendBehavior: "interrupt", isAgentRunning: false, t, }), @@ -70,6 +85,7 @@ describe("composer input labels", () => { submitButtonAccessibilityLabel: "Run now", canPressLoadingButton: false, defaultActionQueues: false, + defaultSendBehavior: "interrupt", isAgentRunning: false, t, }), diff --git a/packages/app/src/composer/input/labels.ts b/packages/app/src/composer/input/labels.ts index 250b24c7b..0820cd7ec 100644 --- a/packages/app/src/composer/input/labels.ts +++ b/packages/app/src/composer/input/labels.ts @@ -1,16 +1,24 @@ import type { TFunction } from "i18next"; +import type { SendBehavior } from "@/hooks/use-settings/storage"; export function resolveSubmitAccessibilityLabel(input: { submitButtonAccessibilityLabel: string | undefined; canPressLoadingButton: boolean; defaultActionQueues: boolean; + defaultSendBehavior: SendBehavior; isAgentRunning: boolean; t: TFunction; }): string { if (input.submitButtonAccessibilityLabel) return input.submitButtonAccessibilityLabel; if (input.canPressLoadingButton) return input.t("composer.input.interruptAgent"); if (input.defaultActionQueues) return input.t("composer.input.queueMessage"); - if (input.isAgentRunning) return input.t("composer.input.sendAndInterrupt"); + if (input.isAgentRunning) { + return input.t( + input.defaultSendBehavior === "steer" + ? "composer.input.sendAndSteer" + : "composer.input.sendAndInterrupt", + ); + } return input.t("composer.input.sendMessage"); } diff --git a/packages/app/src/composer/input/state.test.ts b/packages/app/src/composer/input/state.test.ts index 2b0c216a5..aede454e8 100644 --- a/packages/app/src/composer/input/state.test.ts +++ b/packages/app/src/composer/input/state.test.ts @@ -221,6 +221,29 @@ describe("composer send behavior", () => { expect(alternateAction.calls).toEqual(["queue"]); }); + it("uses Enter to steer and Mod+Enter to queue when steer is selected", () => { + const defaultAction = actions(); + runDefaultSendAction({ + defaultSendBehavior: "steer", + isAgentRunning: true, + onQueue: defaultAction.onQueue, + handleSendMessage: defaultAction.handleSendMessage, + handleQueueMessage: defaultAction.handleQueueMessage, + }); + + const alternateAction = actions(); + runAlternateSendAction({ + defaultSendBehavior: "steer", + isAgentRunning: true, + onQueue: alternateAction.onQueue, + handleSendMessage: alternateAction.handleSendMessage, + handleQueueMessage: alternateAction.handleQueueMessage, + }); + + expect(defaultAction.calls).toEqual(["send"]); + expect(alternateAction.calls).toEqual(["queue"]); + }); + it("uses Enter to queue and Mod+Enter to submit when queue is selected", () => { const defaultAction = actions(); runDefaultSendAction({ diff --git a/packages/app/src/composer/input/state.ts b/packages/app/src/composer/input/state.ts index 15ea7199e..6239fd822 100644 --- a/packages/app/src/composer/input/state.ts +++ b/packages/app/src/composer/input/state.ts @@ -1,8 +1,9 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages"; import type { MessagePayload } from "@/composer/types"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; -export type SendBehavior = "interrupt" | "queue"; +export type SendBehavior = ActiveTurnBehavior | "queue"; interface ComposerSurfaceState { opacity: 0 | 1; diff --git a/packages/app/src/composer/submit.test.ts b/packages/app/src/composer/submit.test.ts index bf510fd7e..9a3012e55 100644 --- a/packages/app/src/composer/submit.test.ts +++ b/packages/app/src/composer/submit.test.ts @@ -184,6 +184,39 @@ describe("submitAgentInput", () => { expect(clearDraft).not.toHaveBeenCalled(); }); + it("restores a steered active-turn draft after an ambiguous immediate-send error", async () => { + const error = new Error("connection lost after delivery"); + const setUserInput = vi.fn(); + const setAttachments = vi.fn(); + const setSendError = vi.fn(); + const setIsProcessing = vi.fn(); + + await expect( + submitAgentInput({ + message: " steer this turn ", + attachments: [{ id: "img-1" }], + forceSend: true, + isAgentRunning: true, + canSubmit: true, + queueMessage: vi.fn(), + submitMessage: async () => { + throw error; + }, + clearDraft: vi.fn(), + setUserInput, + setAttachments, + setSendError, + setIsProcessing, + }), + ).resolves.toBe("failed"); + + expect(setUserInput).toHaveBeenNthCalledWith(1, ""); + expect(setUserInput).toHaveBeenNthCalledWith(2, "steer this turn"); + expect(setAttachments).toHaveBeenNthCalledWith(1, []); + expect(setAttachments).toHaveBeenNthCalledWith(2, [{ id: "img-1" }]); + expect(setSendError).toHaveBeenLastCalledWith("connection lost after delivery"); + }); + it("submits when empty submit is explicitly allowed", async () => { const queueMessage = vi.fn(); const submitMessage = vi.fn(async () => {}); diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index 87fdd300f..0bce6a7a9 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -35,6 +35,14 @@ function makeDeps( } describe("loadAppSettingsFromStorage", () => { + it("preserves a persisted steer send behavior", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + "@paseo:app-settings": JSON.stringify({ sendBehavior: "steer" }), + }), + }); + expect((await loadAppSettingsFromStorage(deps)).sendBehavior).toBe("steer"); + }); it("defaults theme to auto when storage is empty", async () => { const deps = makeDeps(); diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index 7c606377f..43a09ec87 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -1,4 +1,5 @@ import { isSyntaxThemeId, type SyntaxThemeId } from "@getpaseo/highlight"; +import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages"; import type { QueryClient } from "@tanstack/react-query"; import type { DesktopSettings } from "@/desktop/settings/desktop-settings"; import { parseAppLanguage, type AppLanguage } from "@/i18n/locales"; @@ -21,7 +22,7 @@ export const APP_SETTINGS_KEY = "@paseo:app-settings"; export const APP_SETTINGS_QUERY_KEY = ["app-settings"]; const LEGACY_SETTINGS_KEY = "@paseo:settings"; -export type SendBehavior = "interrupt" | "queue"; +export type SendBehavior = ActiveTurnBehavior | "queue"; export type ReleaseChannel = "stable" | "beta"; export type ServiceUrlBehavior = "ask" | "in-app" | "external"; export type WorkspaceTitleSource = "title" | "branch"; @@ -92,7 +93,7 @@ const StoredAppSettingsSchema = z.strictObject({ language: z .enum(["system", "ar", "en", "es", "fr", "ja", "ko", "pt-BR", "ru", "zh-CN"]) .optional(), - sendBehavior: z.enum(["interrupt", "queue"]).optional(), + sendBehavior: z.enum(["interrupt", "steer", "queue"]).optional(), serviceUrlBehavior: z.enum(["ask", "in-app", "external"]).optional(), terminalScrollbackLines: z.union([z.number(), z.string()]).optional(), useLegacyTerminalRenderer: z.boolean().optional(), @@ -296,7 +297,11 @@ function pickEnumAppSettings(stored: StoredAppSettings): Partial { if (typeof stored.theme === "string" && VALID_THEMES.has(stored.theme)) { result.theme = stored.theme; } - if (stored.sendBehavior === "interrupt" || stored.sendBehavior === "queue") { + if ( + stored.sendBehavior === "interrupt" || + stored.sendBehavior === "steer" || + stored.sendBehavior === "queue" + ) { result.sendBehavior = stored.sendBehavior; } if ( diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 003ecd92c..5533ed0fc 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -97,6 +97,7 @@ export const ar: TranslationResources = { interruptAgent: "عامل المقاطعة", queueMessage: "رسالة قائمة الانتظار", sendAndInterrupt: "إرسال ومقاطعة", + sendAndSteer: "إرسال وتوجيه", sendMessage: "أرسل رسالة", queue: "طابور", send: "يرسل", @@ -1826,10 +1827,13 @@ export const ar: TranslationResources = { descriptions: { interrupt: "عند تشغيل الوكيل، يوقف Enter التشغيل. Command/Ctrl+Enter يضيف إلى قائمة الانتظار.", + steer: + "عند تشغيل الوكيل، يوجّه Enter الجولة النشطة. يضيف Command/Ctrl+Enter إلى قائمة الانتظار.", queue: "عند تشغيل الوكيل، يضيف Enter إلى قائمة الانتظار. Command/Ctrl+Enter يرسل.", }, options: { interrupt: "مقاطعة", + steer: "توجيه", queue: "طابور", }, }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 8b40f2f2b..49feb3e83 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -94,6 +94,7 @@ export const en = { interruptAgent: "Interrupt agent", queueMessage: "Queue message", sendAndInterrupt: "Send and interrupt", + sendAndSteer: "Send and steer", sendMessage: "Send message", queue: "Queue", send: "Send", @@ -1890,10 +1891,13 @@ export const en = { label: "Default send", descriptions: { interrupt: "When the agent is running, Enter interrupts. Command/Ctrl+Enter queues.", + steer: + "When the agent is running, Enter steers the active turn. Command/Ctrl+Enter queues.", queue: "When the agent is running, Enter queues. Command/Ctrl+Enter submits.", }, options: { interrupt: "Interrupt", + steer: "Steer", queue: "Queue", }, }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 09f9eff74..daa2f058d 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -97,6 +97,7 @@ export const es: TranslationResources = { interruptAgent: "agente de interrupción", queueMessage: "mensaje de cola", sendAndInterrupt: "Enviar e interrumpir", + sendAndSteer: "Enviar y orientar", sendMessage: "enviar mensaje", queue: "Cola", send: "Enviar", @@ -1871,11 +1872,14 @@ export const es: TranslationResources = { descriptions: { interrupt: "Cuando el agente se está ejecutando, Enter interrumpe. Command/Ctrl+Enter pone en cola.", + steer: + "Cuando el agente se está ejecutando, Enter dirige el turno activo. Command/Ctrl+Enter pone en cola.", queue: "Cuando el agente se está ejecutando, Enter pone en cola. Command/Ctrl+Enter envía.", }, options: { interrupt: "Interrumpir", + steer: "Dirigir", queue: "Cola", }, }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 754d2c67e..172d26358 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -99,6 +99,7 @@ export const fr: TranslationResources = { interruptAgent: "Agent d'interruption", queueMessage: "Message de file d'attente", sendAndInterrupt: "Envoyer et interrompre", + sendAndSteer: "Envoyer et guider", sendMessage: "Envoyer un message", queue: "File d'attente", send: "Envoyer", @@ -1874,11 +1875,14 @@ export const fr: TranslationResources = { descriptions: { interrupt: "Lorsque l'agent est en cours d'exécution, Entrée interrompt. Commande/Ctrl+Entrée met en file d'attente.", + steer: + "Lorsque l’agent est en cours d’exécution, Entrée oriente le tour actif. Commande/Ctrl+Entrée met en file d’attente.", queue: "Lorsque l'agent est en cours d'exécution, Entrée met en file d'attente. Commande/Ctrl+Entrée envoie.", }, options: { interrupt: "Interrompre", + steer: "Orienter", queue: "File d'attente", }, }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 88695d494..15b74d7de 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -97,6 +97,7 @@ export const ja: TranslationResources = { interruptAgent: "エージェントを中断", queueMessage: "メッセージをキューに追加", sendAndInterrupt: "送信して中断", + sendAndSteer: "送信して指示を追加", sendMessage: "メッセージを送信", queue: "キュー", send: "送信", @@ -1841,10 +1842,13 @@ export const ja: TranslationResources = { label: "デフォルトの送信", descriptions: { interrupt: "エージェント実行中、Enterで中断します。Command/Ctrl+Enterでキューに追加。", + steer: + "エージェント実行中、Enterで現在のターンに指示を送ります。Command/Ctrl+Enterでキューに追加。", queue: "エージェント実行中、Enterでキューに追加します。Command/Ctrl+Enterで送信。", }, options: { interrupt: "中断", + steer: "指示", queue: "キュー", }, }, diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 334c4ef92..2876ad5ff 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -97,6 +97,7 @@ export const ko: TranslationResources = { interruptAgent: "에이전트 중단", queueMessage: "메시지 대기열에 추가", sendAndInterrupt: "보내고 중단", + sendAndSteer: "보내고 지시 추가", sendMessage: "메시지 보내기", queue: "대기열", send: "보내기", @@ -1836,11 +1837,14 @@ export const ko: TranslationResources = { descriptions: { interrupt: "에이전트가 실행 중일 때 Enter는 중단합니다. Command/Ctrl+Enter는 대기열에 추가합니다.", + steer: + "에이전트가 실행 중일 때 Enter는 현재 턴에 지시합니다. Command/Ctrl+Enter는 대기열에 추가합니다.", queue: "에이전트가 실행 중일 때 Enter는 대기열에 추가합니다. Command/Ctrl+Enter는 제출합니다.", }, options: { interrupt: "중단", + steer: "지시", queue: "대기열", }, }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 1f5a8e27d..af0533349 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -97,6 +97,7 @@ export const ptBR: TranslationResources = { interruptAgent: "Interromper agente", queueMessage: "Enfileirar mensagem", sendAndInterrupt: "Enviar e interromper", + sendAndSteer: "Enviar e orientar", sendMessage: "Enviar mensagem", queue: "Fila", send: "Enviar", @@ -1856,10 +1857,13 @@ export const ptBR: TranslationResources = { descriptions: { interrupt: "Quando o agente está em execução, Enter interrompe. Command/Ctrl+Enter enfileira.", + steer: + "Quando o agente está em execução, Enter orienta o turno ativo. Command/Ctrl+Enter enfileira.", queue: "Quando o agente está em execução, Enter enfileira. Command/Ctrl+Enter envia.", }, options: { interrupt: "Interromper", + steer: "Orientar", queue: "Fila", }, }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 2fdaebf7b..db0735c0f 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -97,6 +97,7 @@ export const ru: TranslationResources = { interruptAgent: "Агент прерываний", queueMessage: "Сообщение в очереди", sendAndInterrupt: "Отправить и прервать", + sendAndSteer: "Отправить и направить", sendMessage: "Отправить сообщение", queue: "Очередь", send: "Отправлять", @@ -1860,10 +1861,13 @@ export const ru: TranslationResources = { label: "Отправка по умолчанию", descriptions: { interrupt: "Когда агент работает, Enter прерывает. Command/Ctrl+Enter ставит в очередь.", + steer: + "Когда агент работает, Enter направляет текущий ход. Command/Ctrl+Enter ставит в очередь.", queue: "Когда агент работает, Enter ставит в очередь. Command/Ctrl+Enter отправляет.", }, options: { interrupt: "Прерывать", + steer: "Направить", queue: "Очередь", }, }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 381f6ec45..c9c8507ec 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -97,6 +97,7 @@ export const zhCN: TranslationResources = { interruptAgent: "中断 Agent", queueMessage: "消息排队", sendAndInterrupt: "发送并中断", + sendAndSteer: "发送并引导", sendMessage: "发送消息", queue: "排队", send: "发送", @@ -1804,10 +1805,12 @@ export const zhCN: TranslationResources = { label: "默认发送", descriptions: { interrupt: "Agent 运行时,Enter 会中断。Command/Ctrl+Enter 会排队。", + steer: "Agent 运行时,Enter 会引导当前回合。Command/Ctrl+Enter 会排队。", queue: "Agent 运行时,Enter 会排队。Command/Ctrl+Enter 会提交。", }, options: { interrupt: "中断", + steer: "引导", queue: "排队", }, }, diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index 301d3e1c9..a48a49c95 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -278,6 +278,43 @@ describe("ReplicaCache", () => { }); }); + it("restores canonical turn membership without downgrading tagged rows", async () => { + const storage = new MemoryStorage(); + const writer = new ReplicaCache(storage); + writer.setHosts([SERVER_ID]); + seedSession(); + const initial: StreamItem = { + kind: "user_message", + id: "initial", + text: "initial", + timestamp: new Date(1), + turnId: "turn-1", + }; + const hello: StreamItem = { + kind: "user_message", + id: "hello", + text: "hello", + timestamp: new Date(2), + turnId: "turn-1", + clientMessageId: "hello-client", + messageId: "hello-client", + }; + useSessionStore + .getState() + .setAgentStreamTail( + SERVER_ID, + new Map([["agent-1", [initial, message("assistant", "done"), hello]]]), + ); + await writer.flush(); + useSessionStore.getState().clearSession(SERVER_ID); + const reader = new ReplicaCache(storage); + reader.setHosts([SERVER_ID]); + await reader.restore(); + const tail = + useSessionStore.getState().sessions[SERVER_ID]?.agentStreamTail.get("agent-1") ?? []; + expect(tail.find((item) => item.id === "hello")?.turnId).toBe("turn-1"); + }); + it("restores tool calls inside an authoritative cached window", async () => { const storage = new MemoryStorage(); const writer = new ReplicaCache(storage); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 961930fe2..b18514d50 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -31,6 +31,8 @@ const TimelinePositionSchema = z.strictObject({ const TimelineItemBaseShape = { id: z.string(), timelineCursor: TimelinePositionSchema.optional(), + // COMPAT(active-turn-membership): absent on caches written before turn membership. + turnId: z.string().optional(), timestamp: IsoDateSchema, }; @@ -320,6 +322,7 @@ function timelineBase(item: StreamItem) { return { id: item.id, ...(item.timelineCursor ? { timelineCursor: item.timelineCursor } : {}), + ...(item.turnId ? { turnId: item.turnId } : {}), timestamp: item.timestamp.toISOString(), }; } @@ -394,6 +397,7 @@ function deserializeTimelineItem(item: StoredTimelineItem): StreamItem { const base = { id: item.id, ...(item.timelineCursor ? { timelineCursor: item.timelineCursor } : {}), + ...(item.turnId ? { turnId: item.turnId } : {}), timestamp: new Date(item.timestamp), }; switch (item.kind) { diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 3fca4f5c9..8b27b5ab8 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -249,6 +249,7 @@ function selectedSidebarItemStyle({ hovered }: PressableStateCallbackType & { ho function getSendBehaviorOptions(t: TFunction) { return [ { value: "interrupt" as const, label: t("settings.general.defaultSend.options.interrupt") }, + { value: "steer" as const, label: t("settings.general.defaultSend.options.steer") }, { value: "queue" as const, label: t("settings.general.defaultSend.options.queue") }, ]; } @@ -340,10 +341,7 @@ function GeneralSection({ const { t, i18n } = useTranslation(); const activeLocale = getActiveLocale(i18n.language); const sendBehaviorOptions = useMemo(() => getSendBehaviorOptions(t), [t]); - const sendBehaviorDescriptionKey = - settings.sendBehavior === "interrupt" - ? "settings.general.defaultSend.descriptions.interrupt" - : "settings.general.defaultSend.descriptions.queue"; + const sendBehaviorDescriptionKey = `settings.general.defaultSend.descriptions.${settings.sendBehavior}`; const selectedLanguageOption = LANGUAGE_OPTIONS.find( (option) => option.value === settings.language, ); diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index a0daa3db1..b9e9e9158 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -197,6 +197,32 @@ describe("deriveAgentStreamTurnLiveness", () => { }); }); +describe("timeline turn membership compatibility", () => { + it("hydrates tagged rows while retaining legacy rows without a turn ID", () => { + const hydrated = hydrateStreamState([ + { + event: { + type: "timeline", + provider: "claude", + turnId: "turn-1", + item: { type: "user_message", text: "prompt", clientMessageId: "prompt-id" }, + }, + timestamp: new Date(1000), + }, + { + event: { + type: "timeline", + provider: "claude", + item: { type: "assistant_message", text: "legacy" }, + }, + timestamp: new Date(2000), + }, + ]); + + expect(hydrated.map((item) => item.turnId)).toEqual(["turn-1", undefined]); + }); +}); + describe("detached timeline windows", () => { it("does not apply or catch up live events while viewing an older window", () => { const currentTail = [makeAssistantItem("older window")]; @@ -285,8 +311,17 @@ describe("processTimelineResponse", () => { text: "local prompt", timestamp: new Date(1000), timelineCursor: { epoch: "epoch-1", seq: 1 }, + turnId: "turn-1", }); - const currentTail = [canonical, makeAssistantItem("existing tail", "existing-tail")]; + const hello = createUserMessage({ + id: "hello", + clientMessageId: "hello-client", + text: "hello", + timestamp: new Date(1500), + timelineCursor: { epoch: "epoch-1", seq: 2 }, + turnId: "turn-1", + }); + const currentTail = [canonical, makeAssistantItem("existing tail", "existing-tail"), hello]; const currentHead = [makeAssistantItem("existing head", "existing-head")]; const result = processTimelineResponse({ @@ -314,6 +349,7 @@ describe("processTimelineResponse", () => { ], }, }); + expect(result.tail.find((item) => item.id === "hello")?.turnId).toBe("turn-1"); expect(result.commit).toBe("discard"); expect(result.tail).toBe(currentTail); diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index 4d6515ada..263d92974 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -126,6 +126,7 @@ interface TimelineResponseEntry { sourceSeqRanges?: TimelineSeqRange[]; collapsed?: string[]; provider: string; + turnId?: string; item: Record; timestamp: string; } @@ -1269,6 +1270,7 @@ export function processTimelineResponse( type: "timeline", provider: entry.provider, item: entry.item, + ...(entry.turnId ? { turnId: entry.turnId } : {}), } as AgentStreamEventPayload, timestamp: new Date(entry.timestamp), })); diff --git a/packages/app/src/timeline/turn-time.ts b/packages/app/src/timeline/turn-time.ts index 3cb8d1824..f090e7bfe 100644 --- a/packages/app/src/timeline/turn-time.ts +++ b/packages/app/src/timeline/turn-time.ts @@ -1,4 +1,5 @@ import type { StreamItem } from "@/types/stream"; +import { startsNewTurn } from "@/agent-stream/turn-membership"; export interface TurnTiming { startedAt: Date; @@ -21,6 +22,7 @@ export function deriveStreamTurnTiming(params: { let currentUserAt: Date | null = null; let currentLastItemAt: Date | null = null; let currentAssistantIds: string[] = []; + let previousItem: StreamItem | null = null; const flushCompletedTurn = () => { if (!currentUserAt || !currentLastItemAt || currentAssistantIds.length === 0) { @@ -37,20 +39,21 @@ export function deriveStreamTurnTiming(params: { }; const visitItem = (item: StreamItem) => { - if (item.kind === "user_message") { + if (startsNewTurn(item, previousItem)) { flushCompletedTurn(); - currentUserAt = item.timestamp; + currentUserAt = item.kind === "user_message" ? item.timestamp : null; currentLastItemAt = null; currentAssistantIds = []; - return; } if (!currentUserAt) { + previousItem = item; return; } currentLastItemAt = item.timestamp; if (item.kind === "assistant_message") { currentAssistantIds.push(item.id); } + previousItem = item; }; for (const item of params.tail) { diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index c3594b9d7..1208368b5 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -22,6 +22,77 @@ import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display" type CanonicalToolStatus = "running" | "completed" | "failed" | "canceled"; describe("user message identity", () => { + it("replaces provisional optimistic turn membership with canonical membership", () => { + const optimistic = createUserMessage({ + clientMessageId: "hello-client", + text: "hello", + timestamp: new Date("2026-08-15T10:00:00Z"), + turnId: "turn-a", + }); + + const result = applyStreamEvent({ + tail: [optimistic], + head: [], + event: { + type: "timeline", + provider: "codex", + turnId: "turn-b", + item: { + type: "user_message", + text: "hello", + clientMessageId: "hello-client", + messageId: "provider-hello", + }, + }, + timestamp: new Date("2026-08-15T10:00:01Z"), + }); + + expect(result.tail).toHaveLength(1); + expect(result.tail[0]).toEqual( + expect.objectContaining({ + kind: "user_message", + clientMessageId: "hello-client", + messageId: "provider-hello", + turnId: "turn-b", + }), + ); + }); + + it("clears provisional optimistic turn membership for a legacy canonical row", () => { + const optimistic = createUserMessage({ + clientMessageId: "hello-client", + text: "hello", + timestamp: new Date("2026-08-15T10:00:00Z"), + turnId: "turn-a", + }); + + const result = applyStreamEvent({ + tail: [optimistic], + head: [], + event: { + type: "timeline", + provider: "codex", + item: { + type: "user_message", + text: "hello", + clientMessageId: "hello-client", + messageId: "provider-hello", + }, + }, + timestamp: new Date("2026-08-15T10:00:01Z"), + }); + + expect(result.tail).toHaveLength(1); + expect(result.tail[0]).toEqual( + expect.objectContaining({ + kind: "user_message", + clientMessageId: "hello-client", + messageId: "provider-hello", + }), + ); + expect(result.tail[0]).not.toHaveProperty("turnId"); + }); + it("adds provider identity without replacing local presentation", () => { const timestamp = new Date("2026-07-26T10:00:00.000Z"); const local = createUserMessage({ diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 557279767..5a741af17 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -88,6 +88,7 @@ export interface UserMessageItem { id: string; clientMessageId?: string; messageId?: string; + turnId?: string; timelineCursor?: TimelinePosition; text: string; timestamp: Date; @@ -99,6 +100,7 @@ export interface UserMessageInput { id?: string; clientMessageId?: string; messageId?: string; + turnId?: string; timelineCursor?: TimelinePosition; text: string; timestamp: Date; @@ -116,6 +118,7 @@ export function createUserMessage(input: UserMessageInput): UserMessageItem { id, ...(input.clientMessageId ? { clientMessageId: input.clientMessageId } : {}), ...(input.messageId ? { messageId: input.messageId } : {}), + ...(input.turnId ? { turnId: input.turnId } : {}), ...(input.timelineCursor ? { timelineCursor: input.timelineCursor } : {}), text: input.text, timestamp: input.timestamp, @@ -676,6 +679,7 @@ export interface AssistantMessageItem { kind: "assistant_message"; id: string; messageId?: string; + turnId?: string; timelineCursor?: TimelinePosition; text: string; timestamp: Date; @@ -694,6 +698,7 @@ export interface ThoughtItem { kind: "thought"; id: string; timelineCursor?: TimelinePosition; + turnId?: string; text: string; timestamp: Date; status: ThoughtStatus; @@ -729,6 +734,7 @@ export interface ToolCallItem { kind: "tool_call"; id: string; timelineCursor?: TimelinePosition; + turnId?: string; timestamp: Date; payload: ToolCallPayload; } @@ -747,6 +753,7 @@ export interface ActivityLogItem { kind: "activity_log"; id: string; timelineCursor?: TimelinePosition; + turnId?: string; timestamp: Date; activityType: ActivityLogType; message: string; @@ -757,6 +764,7 @@ export interface CompactionItem { kind: "compaction"; id: string; timelineCursor?: TimelinePosition; + turnId?: string; timestamp: Date; status: "loading" | "completed"; trigger?: "auto" | "manual"; @@ -779,6 +787,7 @@ export interface TodoListItem { kind: "todo_list"; id: string; timelineCursor?: TimelinePosition; + turnId?: string; timestamp: Date; provider: AgentProvider; items: TodoEntry[]; @@ -839,6 +848,7 @@ function appendUserMessage( messageId?: string, clientMessageId?: string, timelineCursor?: TimelinePosition, + turnId?: string, ): StreamItem[] { const { chunk, hasContent } = normalizeChunk(text); if (!hasContent) { @@ -851,6 +861,7 @@ function appendUserMessage( clientMessageId, messageId, timelineCursor, + turnId, text: chunk, timestamp, }); @@ -1414,6 +1425,7 @@ function reduceTimelineEvent( item.messageId, item.clientMessageId, timelineCursor, + event.turnId, ), ); case "assistant_message": @@ -1478,13 +1490,16 @@ export function reduceStreamUpdate( const source = options?.source ?? "live"; switch (event.type) { case "timeline": - return reduceTimelineEvent( - state, + return applyTimelineTurnId( + reduceTimelineEvent( + state, + event, + timestamp, + source, + options?.reservedItemIds, + options?.timelineCursor, + ), event, - timestamp, - source, - options?.reservedItemIds, - options?.timelineCursor, ); case "thread_started": case "turn_started": @@ -1500,6 +1515,51 @@ export function reduceStreamUpdate( } } +function applyTimelineTurnId( + items: StreamItem[], + event: Extract, +): StreamItem[] { + const clientMessageId = + event.item.type === "user_message" ? event.item.clientMessageId : undefined; + if (clientMessageId) { + return reconcileCanonicalUserTurnMembership(items, clientMessageId, event.turnId); + } + + if (!event.turnId || items.length === 0) return items; + const index = items.length - 1; + const last = items[index]; + if (!last || last.turnId === event.turnId) return items; + return [ + ...items.slice(0, index), + { ...last, turnId: event.turnId } as StreamItem, + ...items.slice(index + 1), + ]; +} + +function reconcileCanonicalUserTurnMembership( + items: StreamItem[], + clientMessageId: string, + turnId: string | undefined, +): StreamItem[] { + const index = items.findIndex( + (item) => item.kind === "user_message" && item.clientMessageId === clientMessageId, + ); + const matched = items[index]; + if (!matched || matched.kind !== "user_message" || matched.turnId === turnId) { + return items; + } + + // A canonical user row is authoritative for membership. This replaces a + // provisional optimistic turn and clears it for daemons that do not emit IDs. + const next = turnId + ? { ...matched, turnId } + : (() => { + const { turnId: _, ...withoutTurnId } = matched; + return withoutTurnId; + })(); + return [...items.slice(0, index), next, ...items.slice(index + 1)]; +} + /** * Hydrate stream state from a batch of AgentManager stream events */ @@ -1790,6 +1850,7 @@ function applyCanonicalUserMessageEvent(params: { createUniqueTimelineId([...tail, ...head], "user", normalized.chunk.trim(), timestamp), messageId: event.item.messageId, clientMessageId: event.item.clientMessageId, + turnId: event.turnId, timelineCursor, text: normalized.chunk, timestamp, @@ -1802,11 +1863,25 @@ function applyCanonicalUserMessageEvent(params: { insert: normalized.hasContent ? "head" : "none", presentation: "existing", }); + const reconciledTail = canonical.clientMessageId + ? reconcileCanonicalUserTurnMembership( + reconciled.tail, + canonical.clientMessageId, + event.turnId, + ) + : reconciled.tail; + const reconciledHead = canonical.clientMessageId + ? reconcileCanonicalUserTurnMembership( + reconciled.head, + canonical.clientMessageId, + event.turnId, + ) + : reconciled.head; return { - tail: reconciled.tail, - head: reconciled.head, - changedTail: reconciled.changedTail, - changedHead: reconciled.changedHead, + tail: reconciledTail, + head: reconciledHead, + changedTail: reconciled.changedTail || reconciledTail !== reconciled.tail, + changedHead: reconciled.changedHead || reconciledHead !== reconciled.head, acknowledgedClientMessageIds: reconciled.location?.matched && reconciled.location.message.clientMessageId ? [reconciled.location.message.clientMessageId] @@ -1814,10 +1889,17 @@ function applyCanonicalUserMessageEvent(params: { }; } const reconciled = placeCanonicalUserMessageAtTail(flushedTail, canonical, normalized.hasContent); + const reconciledTail = canonical.clientMessageId + ? reconcileCanonicalUserTurnMembership( + reconciled.items, + canonical.clientMessageId, + event.turnId, + ) + : reconciled.items; return { - tail: reconciled.items, + tail: reconciledTail, head: flushedHead, - changedTail: flushedTail !== tail || reconciled.items !== flushedTail, + changedTail: flushedTail !== tail || reconciledTail !== flushedTail, changedHead: flushedHead !== head, acknowledgedClientMessageIds: reconciled.matched && reconciled.message.clientMessageId diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index ca8b3356e..55a3a7299 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -13,6 +13,7 @@ import { ShutdownRequestedStatusPayloadSchema, DaemonUpdateResponseSchema, SessionInboundMessageSchema, + type ActiveTurnBehavior, type ServerInfoStatusPayload, } from "@getpaseo/protocol/messages"; import { validateWSOutboundMessage } from "@getpaseo/protocol/validation/ws-outbound"; @@ -336,6 +337,7 @@ export interface DaemonClientTrace { export interface SendMessageOptions { messageId?: string; + activeTurnBehavior?: ActiveTurnBehavior; images?: Array<{ data: string; mimeType: string }>; attachments?: SendAgentMessageRequest["attachments"]; } @@ -2947,6 +2949,7 @@ export class DaemonClient { agentId, text, ...(messageId ? { messageId } : {}), + ...(options?.activeTurnBehavior ? { activeTurnBehavior: options.activeTurnBehavior } : {}), ...(options?.images ? { images: options.images } : {}), ...(options?.attachments ? { attachments: options.attachments } : {}), }); diff --git a/packages/protocol/src/messages.active-turn-behavior.test.ts b/packages/protocol/src/messages.active-turn-behavior.test.ts new file mode 100644 index 000000000..729e3ade4 --- /dev/null +++ b/packages/protocol/src/messages.active-turn-behavior.test.ts @@ -0,0 +1,87 @@ +import { + AgentStreamEventPayloadSchema, + AgentTimelineEntryPayloadSchema, + SendAgentMessageRequestSchema, +} from "./messages"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +describe("send_agent_message_request active-turn behavior", () => { + it("accepts an optional steer intent while retaining interrupt compatibility", () => { + expect( + SendAgentMessageRequestSchema.parse({ + type: "send_agent_message_request", + requestId: "request-1", + agentId: "agent-1", + text: "Follow this instruction", + activeTurnBehavior: "steer", + }).activeTurnBehavior, + ).toBe("steer"); + + expect( + SendAgentMessageRequestSchema.parse({ + type: "send_agent_message_request", + requestId: "request-2", + agentId: "agent-1", + text: "Keep the old behavior", + }).activeTurnBehavior, + ).toBeUndefined(); + }); +}); + +describe("canonical timeline turn ID compatibility", () => { + it("accepts both new turn-tagged wire rows and legacy rows without IDs", () => { + const entry = { + provider: "codex" as const, + item: { type: "assistant_message" as const, text: "done" }, + timestamp: "2026-01-01T00:00:00.000Z", + seqStart: 1, + seqEnd: 1, + sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }], + collapsed: [], + }; + expect(AgentTimelineEntryPayloadSchema.parse(entry).turnId).toBeUndefined(); + expect(AgentTimelineEntryPayloadSchema.parse({ ...entry, turnId: "turn-1" }).turnId).toBe( + "turn-1", + ); + + const timeline = { + type: "timeline" as const, + provider: "codex" as const, + item: { type: "assistant_message" as const, text: "done" }, + }; + expect(AgentStreamEventPayloadSchema.parse(timeline).turnId).toBeUndefined(); + expect(AgentStreamEventPayloadSchema.parse({ ...timeline, turnId: "turn-1" }).turnId).toBe( + "turn-1", + ); + }); +}); + +describe("legacy daemon send request schema compatibility", () => { + const LegacySendAgentMessageRequestSchema = z.object({ + type: z.literal("send_agent_message_request"), + requestId: z.string(), + agentId: z.string(), + text: z.string(), + messageId: z.string().optional(), + }); + + it("ignores a new client's active-turn intent and retains legacy interrupt dispatch", () => { + const newClientRequest = SendAgentMessageRequestSchema.parse({ + type: "send_agent_message_request", + requestId: "request-legacy", + agentId: "agent-1", + text: "replace the turn", + activeTurnBehavior: "steer", + }); + const legacyRequest = LegacySendAgentMessageRequestSchema.parse(newClientRequest); + + expect(legacyRequest).toEqual({ + type: "send_agent_message_request", + requestId: "request-legacy", + agentId: "agent-1", + text: "replace the turn", + }); + expect("activeTurnBehavior" in legacyRequest).toBe(false); + }); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 8ba38eac6..80c02e16e 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -748,6 +748,7 @@ export const AgentStreamEventPayloadSchema = z.discriminatedUnion("type", [ type: z.literal("timeline"), provider: AgentProviderSchema, item: AgentTimelineItemPayloadSchema, + turnId: z.string().optional(), }), z.object({ type: z.literal("permission_requested"), @@ -1138,11 +1139,15 @@ const ImageAttachmentSchema = z.object({ mimeType: z.string(), // e.g., "image/jpeg", "image/png" }); +export const ActiveTurnBehaviorSchema = z.enum(["interrupt", "steer"]); +export type ActiveTurnBehavior = z.infer; + export const SendAgentMessageSchema = z.object({ type: z.literal("send_agent_message"), agentId: z.string(), text: z.string(), messageId: z.string().optional(), // Client-provided ID for deduplication + activeTurnBehavior: ActiveTurnBehaviorSchema.optional(), images: z.array(ImageAttachmentSchema).optional(), attachments: AgentAttachmentsSchema, }); @@ -1295,6 +1300,7 @@ export const SendAgentMessageRequestSchema = z.object({ agentId: z.string(), text: z.string(), messageId: z.string().optional(), // Client-provided ID for deduplication + activeTurnBehavior: ActiveTurnBehaviorSchema.optional(), images: z.array(ImageAttachmentSchema).optional(), attachments: AgentAttachmentsSchema, }); @@ -4035,6 +4041,7 @@ const AgentTimelineSeqRangeSchema = z.object({ export const AgentTimelineEntryPayloadSchema = z.object({ provider: AgentProviderSchema, item: AgentTimelineItemPayloadSchema, + turnId: z.string().optional(), timestamp: z.string(), seqStart: z.number().int().nonnegative(), seqEnd: z.number().int().nonnegative(), diff --git a/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts b/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts index 882c0751e..6d3be2f22 100644 --- a/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts +++ b/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts @@ -438,7 +438,7 @@ describe("target coalesced behavior", () => { }); session.setHistory([timelineEvent(toolCall({ status: "completed", output }))]); - await harness.manager.hydrateTimelineFromProvider(agentId); + await harness.manager.hydrateTimelineFromProvider(agentId, { force: true }); expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([ expectedItem, @@ -1209,7 +1209,7 @@ describe("target coalesced behavior", () => { const { agentId, session } = await createManagedSession(harness); session.setHistory([assistant("a1"), assistant("a2"), reasoning("r1"), reasoning("r2")]); - await harness.manager.hydrateTimelineFromProvider(agentId); + await harness.manager.hydrateTimelineFromProvider(agentId, { force: true }); const rows = await harness.manager.getTimelineRows(agentId); expect(getTimelineItems(rows)).toEqual([ diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 821828f4a..2f4bf7000 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -14,11 +14,19 @@ import { type ManagedAgent, } from "./agent-manager.js"; import { AgentStorage } from "./agent-storage.js"; +import { FileAgentTimelineStore } from "./file-agent-timeline-store.js"; +import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js"; import { toAgentPayload } from "./agent-projections.js"; import { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { formatSystemNotificationPrompt, startAgentRun } from "./agent-prompt.js"; import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent-loading.js"; import type { StoredAgentRecord } from "./agent-storage.js"; +import type { + AgentTimelineFetchOptions, + AgentTimelineFetchResult, + AgentTimelineRow, + AgentTimelineStore, +} from "./agent-timeline-store-types.js"; import type { AgentClient, AgentCreateSessionOptions, @@ -44,6 +52,627 @@ import type { ProviderDefinition } from "./provider-registry.js"; const DESKTOP_OPEN_AGENT_TAB_LABEL = getOpenAgentTabLabel("desktop-client"); const MOBILE_OPEN_AGENT_TAB_LABEL = getOpenAgentTabLabel("mobile-client"); +test("marks a fresh agent canonical timeline complete", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-fresh-complete-")); + const timelineDirectory = join(workdir, "agent-timelines"); + const manager = new AgentManager({ + clients: { codex: new TestAgentClient() }, + durableTimelineStore: new FileAgentTimelineStore(timelineDirectory), + logger, + }); + let agentId: string | null = null; + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + agentId = agent.id; + expect( + await new FileAgentTimelineStore(timelineDirectory).getCommittedSnapshot(agent.id), + ).toEqual({ rows: [], historyComplete: true }); + } finally { + if (agentId) await manager.closeAgent(agentId).catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("restores durable canonical turn membership when an agent is reopened", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-durable-turn-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const durablePath = join(workdir, "agent-timelines"); + const first = new AgentManager({ + clients: { codex: new TestAgentClient() }, + registry: storage, + durableTimelineStore: new FileAgentTimelineStore(durablePath), + logger, + }); + try { + const created = await first.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + const durable = new FileAgentTimelineStore(durablePath); + await durable.appendCommitted( + created.id, + { type: "user_message", text: "initial", clientMessageId: "initial-client" }, + { turnId: "turn-1" }, + ); + await durable.appendCommitted( + created.id, + { + type: "user_message", + text: "hello", + clientMessageId: "hello-client", + messageId: "hello-client", + }, + { turnId: "turn-1" }, + ); + await first.closeAgent(created.id); + await storage.flush(); + const second = new AgentManager({ + clients: { codex: new TestAgentClient() }, + registry: storage, + durableTimelineStore: new FileAgentTimelineStore(durablePath), + logger, + }); + await ensureAgentLoaded(created.id, { agentManager: second, agentStorage: storage, logger }); + const hello = second + .fetchTimeline(created.id, { limit: 0 }) + .rows.filter( + (row) => row.item.type === "user_message" && row.item.clientMessageId === "hello-client", + ); + expect(hello).toHaveLength(1); + expect(hello[0]).toMatchObject({ + turnId: "turn-1", + item: { messageId: "hello-client", clientMessageId: "hello-client" }, + }); + await second.closeAgent(created.id); + } finally { + await storage.flush().catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("reconciles provider history whenever a persisted agent starts a new runtime", async () => { + for (const scenario of [{ historyComplete: true }, { historyComplete: false }]) { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-completeness-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const timelineDirectory = join(workdir, "agent-timelines"); + const first = new AgentManager({ + clients: { codex: new TestAgentClient() }, + registry: storage, + durableTimelineStore: new FileAgentTimelineStore(timelineDirectory), + logger, + }); + let agentId: string | null = null; + let second: AgentManager | null = null; + try { + const created = await first.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + agentId = created.id; + await first.closeAgent(created.id); + await storage.flush(); + + const canonicalRow: AgentTimelineRow = { + seq: 1, + timestamp: "2026-01-01T00:00:00.000Z", + item: { type: "user_message", text: "initial", clientMessageId: "initial-client" }, + turnId: "turn-1", + providerMessageId: "provider-initial", + }; + await new FileAgentTimelineStore(timelineDirectory).replaceCommittedSnapshot(created.id, { + rows: [canonicalRow], + historyComplete: scenario.historyComplete, + }); + + let historyCalls = 0; + class HistorySession extends TestAgentSession { + override async *streamHistory(): AsyncGenerator { + historyCalls += 1; + yield { + type: "timeline", + provider: "codex", + item: { type: "user_message", text: "initial" }, + }; + yield { + type: "timeline", + provider: "codex", + item: { type: "assistant_message", text: "tail" }, + }; + } + } + class HistoryClient extends TestAgentClient { + override async resumeSession( + _handle: AgentPersistenceHandle, + config?: Partial, + ): Promise { + return new HistorySession({ provider: "codex", cwd: config?.cwd ?? workdir }); + } + } + second = new AgentManager({ + clients: { codex: new HistoryClient() }, + registry: storage, + durableTimelineStore: new FileAgentTimelineStore(timelineDirectory), + logger, + }); + await ensureAgentLoaded(created.id, { agentManager: second, agentStorage: storage, logger }); + + const rows = second.fetchTimeline(created.id, { limit: 0 }).rows; + expect(historyCalls).toBe(1); + const textRows = rows.flatMap((row) => { + if (row.item.type === "user_message" || row.item.type === "assistant_message") { + return [row.item.text]; + } + return []; + }); + expect(textRows).toEqual(["initial", "tail"]); + expect( + rows.filter((row) => row.item.type === "user_message" && row.item.text === "initial"), + ).toEqual([canonicalRow]); + const reopened = await new FileAgentTimelineStore(timelineDirectory).getCommittedSnapshot( + created.id, + ); + expect(reopened.historyComplete).toBe(true); + expect( + reopened.rows.filter( + (row) => row.item.type === "assistant_message" && row.item.text === "tail", + ), + ).toHaveLength(1); + } finally { + if (second && agentId) await second.closeAgent(agentId).catch(() => undefined); + await storage.flush().catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } + } +}); + +test("retains a canonical steer when incomplete provider history lags on reopen", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-lagging-history-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const timelineDirectory = join(workdir, "agent-timelines"); + const first = new AgentManager({ + clients: { codex: new TestAgentClient() }, + registry: storage, + durableTimelineStore: new FileAgentTimelineStore(timelineDirectory), + logger, + }); + let second: AgentManager | null = null; + let agentId: string | null = null; + try { + const created = await first.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + agentId = created.id; + await first.closeAgent(created.id); + await storage.flush(); + await new FileAgentTimelineStore(timelineDirectory).replaceCommittedSnapshot(created.id, { + historyComplete: false, + rows: [ + { + seq: 1, + timestamp: "2026-01-01T00:00:00.000Z", + item: { type: "user_message", text: "initial", clientMessageId: "initial" }, + turnId: "turn-1", + }, + { + seq: 2, + timestamp: "2026-01-02T00:00:00.000Z", + item: { type: "user_message", text: "hello", clientMessageId: "hello" }, + turnId: "turn-1", + }, + ], + }); + + class LaggingHistorySession extends TestAgentSession { + override async *streamHistory(): AsyncGenerator { + yield { + type: "timeline", + provider: "codex", + item: { type: "assistant_message", text: "provider prefix" }, + }; + yield { + type: "timeline", + provider: "codex", + item: { type: "user_message", text: "initial" }, + }; + } + } + class LaggingHistoryClient extends TestAgentClient { + override async resumeSession( + _handle: AgentPersistenceHandle, + config?: Partial, + ): Promise { + return new LaggingHistorySession({ provider: "codex", cwd: config?.cwd ?? workdir }); + } + } + second = new AgentManager({ + clients: { codex: new LaggingHistoryClient() }, + registry: storage, + durableTimelineStore: new FileAgentTimelineStore(timelineDirectory), + logger, + }); + const events: AgentManagerEvent[] = []; + second.subscribe((event) => events.push(event), { + agentId: created.id, + replayState: false, + }); + await ensureAgentLoaded(created.id, { + agentManager: second, + agentStorage: storage, + logger, + broadcastTimeline: true, + }); + + expect(second.fetchTimeline(created.id, { limit: 0 }).rows).toMatchObject([ + { item: { type: "assistant_message", text: "provider prefix" } }, + { item: { text: "initial", clientMessageId: "initial" }, turnId: "turn-1" }, + { item: { text: "hello", clientMessageId: "hello" }, turnId: "turn-1" }, + ]); + expect( + events.flatMap((event) => { + if (event.type !== "agent_stream" || event.event.type !== "timeline") return []; + return [ + { + seq: event.seq, + item: event.event.item, + turnId: event.event.turnId, + }, + ]; + }), + ).toMatchObject([ + { seq: 1, item: { type: "assistant_message", text: "provider prefix" } }, + { + seq: 2, + item: { type: "user_message", text: "initial", clientMessageId: "initial" }, + turnId: "turn-1", + }, + { + seq: 3, + item: { type: "user_message", text: "hello", clientMessageId: "hello" }, + turnId: "turn-1", + }, + ]); + expect( + await new FileAgentTimelineStore(timelineDirectory).getCommittedSnapshot(created.id), + ).toMatchObject({ historyComplete: true }); + } finally { + if (second && agentId) await second.closeAgent(agentId).catch(() => undefined); + await storage.flush().catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("retries an interrupted initial history hydration after reopen without a durable prefix", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-retry-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const timelineDirectory = join(workdir, "agent-timelines"); + let agentId: string | null = null; + let first: AgentManager | null = null; + let second: AgentManager | null = null; + try { + class InterruptedHistorySession extends TestAgentSession { + override async *streamHistory(): AsyncGenerator { + yield { + type: "timeline", + provider: "codex", + item: { type: "user_message", text: "prefix" }, + }; + throw new Error("history interrupted"); + } + } + class InterruptedHistoryClient extends TestAgentClient { + override async resumeSession( + _handle: AgentPersistenceHandle, + config?: Partial, + ): Promise { + return new InterruptedHistorySession({ + provider: "codex", + cwd: config?.cwd ?? workdir, + }); + } + } + first = new AgentManager({ + clients: { codex: new InterruptedHistoryClient() }, + registry: storage, + durableTimelineStore: new FileAgentTimelineStore(timelineDirectory), + logger, + }); + const created = await first.resumeAgentFromPersistence( + { + provider: "codex", + sessionId: "interrupted-history", + metadata: { cwd: workdir }, + }, + { cwd: workdir }, + ); + agentId = created.id; + await expect(first.hydrateTimelineFromProvider(created.id)).rejects.toThrow( + "history interrupted", + ); + expect( + await new FileAgentTimelineStore(timelineDirectory).getCommittedSnapshot(created.id), + ).toEqual({ + rows: [], + historyComplete: false, + }); + await first.closeAgent(created.id); + first = null; + await storage.flush(); + + let historyCalls = 0; + class CompleteHistorySession extends TestAgentSession { + override async *streamHistory(): AsyncGenerator { + historyCalls += 1; + yield { + type: "timeline", + provider: "codex", + item: { type: "user_message", text: "prefix" }, + }; + yield { + type: "timeline", + provider: "codex", + item: { type: "assistant_message", text: "tail" }, + }; + } + } + class CompleteHistoryClient extends TestAgentClient { + override async resumeSession( + _handle: AgentPersistenceHandle, + config?: Partial, + ): Promise { + return new CompleteHistorySession({ provider: "codex", cwd: config?.cwd ?? workdir }); + } + } + second = new AgentManager({ + clients: { codex: new CompleteHistoryClient() }, + registry: storage, + durableTimelineStore: new FileAgentTimelineStore(timelineDirectory), + logger, + }); + await ensureAgentLoaded(created.id, { agentManager: second, agentStorage: storage, logger }); + + expect(historyCalls).toBe(1); + const rows = second.fetchTimeline(created.id, { limit: 0 }).rows; + expect(rows.map((row) => row.item)).toEqual([ + { type: "user_message", text: "prefix" }, + { type: "assistant_message", text: "tail" }, + ]); + expect(rows.every((row) => row.turnId === undefined)).toBe(true); + expect( + await new FileAgentTimelineStore(timelineDirectory).getCommittedSnapshot(created.id), + ).toEqual({ + rows, + historyComplete: true, + }); + } finally { + if (first && agentId) await first.closeAgent(agentId).catch(() => undefined); + if (second && agentId) await second.closeAgent(agentId).catch(() => undefined); + await storage.flush().catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("force hydration preserves a completed steered turn's canonical membership across durable reopen", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-force-steer-history-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const timelineDirectory = join(workdir, "timelines"); + const first = new AgentManager({ + clients: { codex: new TestAgentClient() }, + registry: storage, + logger, + }); + let agentId: string | null = null; + let second: AgentManager | null = null; + try { + const created = await first.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + agentId = created.id; + await first.closeAgent(created.id); + await storage.flush(); + const rows: AgentTimelineRow[] = [ + { + seq: 1, + timestamp: "2026-01-01T00:00:00.000Z", + item: { type: "user_message", text: "initial", clientMessageId: "initial" }, + turnId: "turn-1", + }, + { + seq: 2, + timestamp: "2026-01-01T00:00:01.000Z", + item: { type: "assistant_message", text: "working" }, + turnId: "turn-1", + }, + { + seq: 3, + timestamp: "2026-01-01T00:00:02.000Z", + item: { type: "user_message", text: "hello", clientMessageId: "hello", messageId: "hello" }, + turnId: "turn-1", + providerMessageId: "provider-hello", + }, + { + seq: 4, + timestamp: "2026-01-01T00:00:03.000Z", + item: { type: "assistant_message", text: "done" }, + turnId: "turn-1", + }, + ]; + await new FileAgentTimelineStore(timelineDirectory).replaceCommittedSnapshot(created.id, { + rows, + historyComplete: true, + }); + class HistorySession extends TestAgentSession { + override async *streamHistory(): AsyncGenerator { + yield { + type: "timeline", + provider: "codex", + item: { type: "user_message", text: "initial" }, + }; + yield { + type: "timeline", + provider: "codex", + item: { type: "assistant_message", text: "working" }, + }; + yield { + type: "timeline", + provider: "codex", + item: { type: "user_message", text: "hello" }, + }; + yield { + type: "timeline", + provider: "codex", + item: { type: "assistant_message", text: "done" }, + }; + } + } + class HistoryClient extends TestAgentClient { + override async resumeSession( + _handle: AgentPersistenceHandle, + config?: Partial, + ): Promise { + return new HistorySession({ provider: "codex", cwd: config?.cwd ?? workdir }); + } + } + second = new AgentManager({ + clients: { codex: new HistoryClient() }, + registry: storage, + durableTimelineStore: new FileAgentTimelineStore(timelineDirectory), + logger, + }); + await ensureAgentLoaded(created.id, { agentManager: second, agentStorage: storage, logger }); + await second.hydrateTimelineFromProvider(created.id, { force: true }); + await second.flush(); + const reopened = await new FileAgentTimelineStore(timelineDirectory).getCommittedSnapshot( + created.id, + ); + const hello = reopened.rows.filter( + (row) => row.item.type === "user_message" && row.item.text === "hello", + ); + expect(reopened.historyComplete).toBe(true); + const textRows = reopened.rows.flatMap((row) => { + if (row.item.type === "user_message" || row.item.type === "assistant_message") { + return [row.item.text]; + } + return []; + }); + expect(textRows).toEqual(["initial", "working", "hello", "done"]); + expect(hello).toEqual([ + expect.objectContaining({ + turnId: "turn-1", + providerMessageId: "provider-hello", + item: expect.objectContaining({ clientMessageId: "hello", messageId: "hello" }), + }), + ]); + } finally { + if (second && agentId) await second.closeAgent(agentId).catch(() => undefined); + await storage.flush().catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("forced rewind history removes omitted rows while retaining canonical steer identity", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-force-rewind-history-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const timelines = join(workdir, "timelines"); + const first = new AgentManager({ + clients: { codex: new TestAgentClient() }, + registry: storage, + logger, + }); + let agentId: string | null = null; + let manager: AgentManager | null = null; + try { + const created = await first.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + agentId = created.id; + await first.closeAgent(created.id); + await storage.flush(); + await new FileAgentTimelineStore(timelines).replaceCommittedSnapshot(created.id, { + historyComplete: true, + rows: [ + { + seq: 1, + timestamp: "2026-01-01T00:00:00.000Z", + item: { type: "user_message", text: "initial", clientMessageId: "initial" }, + turnId: "turn-1", + }, + { + seq: 2, + timestamp: "2026-01-01T00:00:01.000Z", + item: { + type: "user_message", + text: "hello", + clientMessageId: "hello", + messageId: "hello", + }, + turnId: "turn-1", + providerMessageId: "provider-hello", + }, + { + seq: 3, + timestamp: "2026-01-01T00:00:02.000Z", + item: { type: "assistant_message", text: "removed later" }, + turnId: "turn-2", + }, + ], + }); + class RewoundSession extends TestAgentSession { + override async *streamHistory(): AsyncGenerator { + yield { + type: "timeline", + provider: "codex", + item: { type: "user_message", text: "initial" }, + }; + yield { + type: "timeline", + provider: "codex", + item: { type: "user_message", text: "hello" }, + }; + } + } + class RewoundClient extends TestAgentClient { + override async resumeSession( + _handle: AgentPersistenceHandle, + config?: Partial, + ): Promise { + return new RewoundSession({ provider: "codex", cwd: config?.cwd ?? workdir }); + } + } + manager = new AgentManager({ + clients: { codex: new RewoundClient() }, + registry: storage, + durableTimelineStore: new FileAgentTimelineStore(timelines), + logger, + }); + await ensureAgentLoaded(created.id, { agentManager: manager, agentStorage: storage, logger }); + await manager.hydrateTimelineFromProvider(created.id, { force: true }); + await manager.flush(); + const reopened = await new FileAgentTimelineStore(timelines).getCommittedSnapshot(created.id); + expect(reopened.historyComplete).toBe(true); + const userTexts = reopened.rows.flatMap((row) => { + if (row.item.type === "user_message") return [row.item.text]; + return []; + }); + expect(userTexts).toEqual(["initial", "hello"]); + expect(reopened.rows).not.toContainEqual( + expect.objectContaining({ item: { text: "removed later" } }), + ); + expect( + reopened.rows.filter((row) => row.item.type === "user_message" && row.item.text === "hello"), + ).toEqual([ + expect.objectContaining({ + turnId: "turn-1", + providerMessageId: "provider-hello", + item: expect.objectContaining({ clientMessageId: "hello", messageId: "hello" }), + }), + ]); + } finally { + if (manager && agentId) await manager.closeAgent(agentId).catch(() => undefined); + await storage.flush().catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + interface Deferred { promise: Promise; resolve: (value: T) => void; @@ -459,6 +1088,817 @@ class CloseRecordingTestAgentSession extends TestAgentSession { } } +class SteeringTestSession extends TestAgentSession { + interruptCount = 0; + startCount = 0; + steerCount = 0; + steerResult: "accepted" | "unavailable" | Error = "accepted"; + startPrompts: AgentPromptInput[] = []; + + override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> { + this.startPrompts.push(prompt); + const turnId = `active-turn-${++this.startCount}`; + setTimeout(() => this.pushEvent({ type: "turn_started", provider: this.provider, turnId }), 0); + return { turnId }; + } + + override async interrupt(): Promise { + this.interruptCount += 1; + this.pushEvent({ + type: "turn_canceled", + provider: this.provider, + turnId: `active-turn-${this.startCount}`, + }); + } + + async steerActiveTurn( + prompt: AgentPromptInput, + options: import("./agent-sdk-types.js").SteerActiveTurnOptions, + ): Promise { + this.steerCount += 1; + if (options.expectedTurnId !== `active-turn-${this.startCount}`) { + return { status: "unavailable" }; + } + if (this.steerResult instanceof Error) throw this.steerResult; + if (this.steerResult === "unavailable") return { status: "unavailable" }; + this.pushEvent({ + type: "timeline", + provider: this.provider, + item: { + type: "user_message", + text: typeof prompt === "string" ? prompt : "structured", + clientMessageId: options.clientMessageId, + messageId: `provider-${options.clientMessageId}`, + }, + }); + return { status: "accepted" }; + } +} + +class UnsupportedSteeringSession extends TestAgentSession { + interruptCount = 0; + startCount = 0; + + override async startTurn(): Promise<{ turnId: string }> { + const turnId = `unsupported-turn-${++this.startCount}`; + setTimeout(() => this.pushEvent({ type: "turn_started", provider: this.provider, turnId }), 0); + return { turnId }; + } + + override async interrupt(): Promise { + this.interruptCount += 1; + this.pushEvent({ + type: "turn_canceled", + provider: this.provider, + turnId: `unsupported-turn-${this.startCount}`, + }); + } +} + +async function startAndSteerThroughManager( + session: AgentSession, + behavior: "steer" | "interrupt" = "steer", +): Promise<{ manager: AgentManager; agentId: string; workdir: string }> { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-dispatch-")); + const client = new (class extends TestAgentClient { + override async createSession(): Promise { + return session; + } + })(); + const manager = new AgentManager({ clients: { codex: client }, logger }); + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + const run = manager.streamAgent(agent.id, "initial"); + void (async () => { + for await (const _event of run) { + } + })(); + await manager.waitForAgentRunStart(agent.id); + await startAgentRun(manager, agent.id, "replacement", logger, { + replaceRunning: true, + activeTurnBehavior: behavior, + runOptions: { clientMessageId: "replacement-client" }, + }); + return { manager, agentId: agent.id, workdir }; +} + +test("unavailable steer interrupts once and starts one replacement turn", async () => { + const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() }); + session.steerResult = "unavailable"; + const { manager, agentId, workdir } = await startAndSteerThroughManager(session); + try { + expect(session.interruptCount).toBe(1); + expect(session.startCount).toBe(2); + expect(manager.getTimeline(agentId)).toContainEqual( + expect.objectContaining({ type: "user_message", clientMessageId: "replacement-client" }), + ); + } finally { + await manager.closeAgent(agentId); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("surfaces an ambiguous persistence error after one accepted steer without fallback", async () => { + const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() }); + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-persist-failure-")); + let durableWriteCount = 0; + const manager = new AgentManager({ + clients: { + codex: new (class extends TestAgentClient { + override async createSession(): Promise { + return session; + } + })(), + }, + durableTimelineStore: new FileAgentTimelineStore(join(workdir, "timelines"), { + writeJson: async () => { + durableWriteCount += 1; + if (durableWriteCount > 1) throw new Error("disk full"); + }, + }), + logger, + }); + let agentId: string | null = null; + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + agentId = agent.id; + const run = manager.streamAgent(agent.id, "initial"); + void (async () => { + for await (const _event of run) { + } + })(); + await manager.waitForAgentRunStart(agent.id); + + await expect( + manager.steerAgentRun(agent.id, "hello", { clientMessageId: "hello-client" }), + ).rejects.toThrow("Steering was accepted but its canonical message could not be persisted"); + expect(session.steerCount).toBe(1); + expect(session.interruptCount).toBe(0); + expect(session.startCount).toBe(1); + expect( + manager + .getTimeline(agent.id) + .filter((item) => item.type === "user_message" && item.clientMessageId === "hello-client"), + ).toHaveLength(1); + } finally { + if (agentId) await manager.closeAgent(agentId).catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("persists an immediate provider echo behind its queued canonical row", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-durable-echo-")); + const timelineDirectory = join(workdir, "timelines"); + const session = new SteeringTestSession({ provider: "codex", cwd: workdir }); + const manager = new AgentManager({ + clients: { + codex: new (class extends TestAgentClient { + override async createSession(): Promise { + return session; + } + })(), + }, + durableTimelineStore: new FileAgentTimelineStore(timelineDirectory), + durableTimelineCoalesceWindowMs: 60_000, + durableTimelineTimers: { + setTimeout: () => 0 as unknown as ReturnType, + clearTimeout: () => undefined, + }, + logger, + }); + let agentId: string | null = null; + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + agentId = agent.id; + const run = manager.streamAgent(agent.id, "initial"); + void (async () => { + for await (const _event of run) { + } + })(); + await manager.waitForAgentRunStart(agent.id); + await manager.steerAgentRun(agent.id, "hello", { clientMessageId: "hello-client" }); + await manager.flush(); + + const rows = await new FileAgentTimelineStore(timelineDirectory).getCommittedRows(agent.id); + const hello = rows.filter( + (row) => row.item.type === "user_message" && row.item.clientMessageId === "hello-client", + ); + expect(hello).toEqual([ + expect.objectContaining({ + turnId: "active-turn-1", + providerMessageId: "provider-hello-client", + item: expect.objectContaining({ clientMessageId: "hello-client" }), + }), + ]); + } finally { + if (agentId) await manager.closeAgent(agentId).catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("does not resurrect a deleted pending timeline and permits fresh agent-id reuse", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-durable-delete-")); + const timelineDirectory = join(workdir, "timelines"); + const manager = new AgentManager({ + clients: { codex: new TestAgentClient() }, + durableTimelineStore: new FileAgentTimelineStore(timelineDirectory), + durableTimelineCoalesceWindowMs: 60_000, + durableTimelineTimers: { + setTimeout: () => 0 as unknown as ReturnType, + clearTimeout: () => undefined, + }, + logger, + }); + const agentId = "00000000-0000-4000-8000-000000000201"; + try { + await manager.createAgent({ provider: "codex", cwd: workdir }, agentId, { + workspaceId: undefined, + }); + await manager.appendTimelineItem(agentId, { type: "assistant_message", text: "deleted" }); + await manager.deleteCommittedTimeline(agentId); + await manager.flush(); + expect(await new FileAgentTimelineStore(timelineDirectory).getCommittedRows(agentId)).toEqual( + [], + ); + + await manager.closeAgent(agentId); + await manager.createAgent({ provider: "codex", cwd: workdir }, agentId, { + workspaceId: undefined, + }); + await manager.appendTimelineItem(agentId, { type: "assistant_message", text: "fresh" }); + await manager.flush(); + expect(await new FileAgentTimelineStore(timelineDirectory).getCommittedRows(agentId)).toEqual([ + expect.objectContaining({ item: { type: "assistant_message", text: "fresh" } }), + ]); + } finally { + await manager.closeAgent(agentId).catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("flush drains a pending durable timeline batch before its scheduler fires", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-durable-flush-")); + const timelineDirectory = join(workdir, "timelines"); + const manager = new AgentManager({ + clients: { codex: new TestAgentClient() }, + durableTimelineStore: new FileAgentTimelineStore(timelineDirectory), + durableTimelineCoalesceWindowMs: 60_000, + durableTimelineTimers: { + setTimeout: () => 0 as unknown as ReturnType, + clearTimeout: () => undefined, + }, + logger, + }); + let agentId: string | null = null; + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + agentId = agent.id; + await manager.appendTimelineItem(agent.id, { type: "assistant_message", text: "pending" }); + await manager.flush(); + + expect(await new FileAgentTimelineStore(timelineDirectory).getCommittedRows(agent.id)).toEqual([ + expect.objectContaining({ item: { type: "assistant_message", text: "pending" } }), + ]); + } finally { + if (agentId) await manager.closeAgent(agentId).catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("waits for durable canonical steer commit before resolving accepted", async () => { + const entered = deferred(); + const release = deferred(); + class HeldStore implements AgentTimelineStore { + private readonly memory = new InMemoryAgentTimelineStore(); + + private ensure(agentId: string): void { + if (!this.memory.has(agentId)) { + this.memory.initialize(agentId); + } + } + + async appendCommitted( + agentId: string, + item: AgentTimelineItem, + options?: { timestamp?: string; turnId?: string }, + ): Promise { + this.ensure(agentId); + return this.memory.append(agentId, item, options); + } + + async fetchCommitted( + agentId: string, + options?: AgentTimelineFetchOptions, + ): Promise { + this.ensure(agentId); + return this.memory.fetch(agentId, options); + } + + async getLatestCommittedSeq(agentId: string): Promise { + return this.memory.has(agentId) ? (this.memory.getRows(agentId).at(-1)?.seq ?? 0) : 0; + } + + async getCommittedRows(agentId: string): Promise { + return this.memory.has(agentId) ? this.memory.getRows(agentId) : []; + } + + async getCommittedSnapshot(agentId: string) { + return { rows: await this.getCommittedRows(agentId), historyComplete: false }; + } + + async getLastItem(agentId: string): Promise { + return this.memory.has(agentId) ? this.memory.getLastItem(agentId) : null; + } + + async getLastAssistantMessage(agentId: string): Promise { + return this.memory.has(agentId) ? this.memory.getLastAssistantMessage(agentId) : null; + } + + async deleteAgent(agentId: string): Promise { + this.memory.delete(agentId); + } + + override async bulkInsert(agentId: string, rows: readonly AgentTimelineRow[]): Promise { + entered.resolve(); + await release.promise; + this.ensure(agentId); + for (const row of rows) { + this.memory.append(agentId, row.item, { timestamp: row.timestamp, turnId: row.turnId }); + } + } + + async updateCommittedRow(agentId: string, row: AgentTimelineRow): Promise { + this.ensure(agentId); + const rows = this.memory.getRows(agentId); + const index = rows.findIndex((candidate) => candidate.seq === row.seq); + if (index < 0) { + this.memory.append(agentId, row.item, { timestamp: row.timestamp, turnId: row.turnId }); + } + } + async replaceCommittedSnapshot( + agentId: string, + snapshot: { rows: AgentTimelineRow[] }, + ): Promise { + this.ensure(agentId); + this.memory.initialize(agentId, { rows: snapshot.rows }); + } + } + const store = new HeldStore(); + const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() }); + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-held-durable-")); + const client = new (class extends TestAgentClient { + override async createSession() { + return session; + } + })(); + const manager = new AgentManager({ + clients: { codex: client }, + durableTimelineStore: store, + logger, + }); + let agentId: string | null = null; + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + agentId = agent.id; + const run = manager.streamAgent(agent.id, "initial"); + void (async () => { + for await (const _ of run) { + } + })(); + await manager.waitForAgentRunStart(agent.id); + const steer = manager.steerAgentRun(agent.id, "hello", { clientMessageId: "hello-client" }); + await entered.promise; + let settled = false; + void steer.finally(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + release.resolve(); + await expect(steer).resolves.toEqual({ status: "accepted" }); + expect( + manager + .getTimeline(agent.id) + .filter((item) => item.type === "user_message" && item.clientMessageId === "hello-client"), + ).toHaveLength(1); + } finally { + release.resolve(); + if (agentId) { + await manager.closeAgent(agentId).catch(() => undefined); + } + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("orders an accepted steer before output emitted while acknowledgement is pending", async () => { + const entered = deferred(); + const release = deferred(); + class HeldAcceptedSteerSession extends SteeringTestSession { + override async steerActiveTurn( + _prompt: AgentPromptInput, + options: import("./agent-sdk-types.js").SteerActiveTurnOptions, + ): Promise { + this.steerCount += 1; + expect(options.expectedTurnId).toBe("active-turn-1"); + entered.resolve(); + await release.promise; + return { status: "accepted" }; + } + } + const session = new HeldAcceptedSteerSession({ provider: "codex", cwd: process.cwd() }); + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-order-")); + const manager = new AgentManager({ + clients: { + codex: new (class extends TestAgentClient { + override async createSession(): Promise { + return session; + } + })(), + }, + logger, + }); + let agentId: string | null = null; + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + agentId = agent.id; + const run = manager.streamAgent(agent.id, "initial"); + const consume = (async () => { + for await (const _event of run) { + } + })(); + await manager.waitForAgentRunStart(agent.id); + + const steer = manager.steerAgentRun(agent.id, "hello", { + clientMessageId: "hello-client", + }); + await entered.promise; + session.pushEvent({ + type: "timeline", + provider: "codex", + turnId: "active-turn-1", + item: { type: "assistant_message", text: "terminal output" }, + }); + session.pushEvent({ + type: "turn_completed", + provider: "codex", + turnId: "active-turn-1", + }); + expect(manager.getTimeline(agent.id)).not.toContainEqual( + expect.objectContaining({ type: "assistant_message", text: "terminal output" }), + ); + + release.resolve(); + await expect(steer).resolves.toEqual({ status: "accepted" }); + await consume; + const rows = manager.fetchTimeline(agent.id, { limit: 0 }).rows.filter((row) => { + return ( + (row.item.type === "user_message" && row.item.text === "hello") || + (row.item.type === "assistant_message" && row.item.text === "terminal output") + ); + }); + expect(rows).toMatchObject([ + { item: { type: "user_message", text: "hello" }, turnId: "active-turn-1" }, + { + item: { type: "assistant_message", text: "terminal output" }, + turnId: "active-turn-1", + }, + ]); + } finally { + release.resolve(); + if (agentId) await manager.closeAgent(agentId).catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("orders buffered pre-steer output before an immediately accepted steer", async () => { + const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() }); + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-buffer-order-")); + const manager = new AgentManager({ + clients: { + codex: new (class extends TestAgentClient { + override async createSession(): Promise { + return session; + } + })(), + }, + agentStreamCoalesceWindowMs: 60_000, + logger, + }); + let agentId: string | null = null; + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + agentId = agent.id; + const run = manager.streamAgent(agent.id, "initial"); + void (async () => { + for await (const _event of run) { + } + })(); + await manager.waitForAgentRunStart(agent.id); + + session.pushEvent({ + type: "timeline", + provider: "codex", + turnId: "active-turn-1", + item: { type: "assistant_message", text: "pre-steer output" }, + }); + await expect( + manager.steerAgentRun(agent.id, "hello", { clientMessageId: "hello-client" }), + ).resolves.toEqual({ status: "accepted" }); + + const rows = manager.fetchTimeline(agent.id, { limit: 0 }).rows.filter((row) => { + return ( + (row.item.type === "assistant_message" && row.item.text === "pre-steer output") || + (row.item.type === "user_message" && row.item.text === "hello") + ); + }); + expect(rows).toMatchObject([ + { + item: { type: "assistant_message", text: "pre-steer output" }, + turnId: "active-turn-1", + }, + { item: { type: "user_message", text: "hello" }, turnId: "active-turn-1" }, + ]); + } finally { + if (agentId) await manager.closeAgent(agentId).catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("orders a concurrent replacement after a pending accepted steer", async () => { + const entered = deferred(); + const release = deferred(); + class HeldAcceptedSteerSession extends SteeringTestSession { + override async steerActiveTurn( + _prompt: AgentPromptInput, + _options: import("./agent-sdk-types.js").SteerActiveTurnOptions, + ): Promise { + this.steerCount += 1; + entered.resolve(); + await release.promise; + return { status: "accepted" }; + } + } + const session = new HeldAcceptedSteerSession({ provider: "codex", cwd: process.cwd() }); + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-replace-order-")); + const manager = new AgentManager({ + clients: { + codex: new (class extends TestAgentClient { + override async createSession(): Promise { + return session; + } + })(), + }, + logger, + }); + let agentId: string | null = null; + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + agentId = agent.id; + const initial = manager.streamAgent(agent.id, "initial"); + const consumeInitial = (async () => { + for await (const _event of initial) { + } + })(); + await manager.waitForAgentRunStart(agent.id); + + const steer = manager.steerAgentRun(agent.id, "hello", { + clientMessageId: "hello-client", + }); + await entered.promise; + const replacement = manager.replaceAgentRun(agent.id, "replacement", { + clientMessageId: "replacement-client", + }); + await Promise.resolve(); + expect(session.interruptCount).toBe(0); + + release.resolve(); + await expect(steer).resolves.toEqual({ status: "accepted" }); + const replacementRun = await replacement; + void (async () => { + for await (const _event of replacementRun) { + } + })(); + await consumeInitial; + await vi.waitFor(() => expect(session.startCount).toBe(2)); + + expect(session.interruptCount).toBe(1); + expect( + manager + .fetchTimeline(agent.id, { limit: 0 }) + .rows.filter((row) => row.item.type === "user_message") + .map((row) => row.item), + ).toMatchObject([ + { text: "hello", clientMessageId: "hello-client" }, + { text: "replacement", clientMessageId: "replacement-client" }, + ]); + } finally { + release.resolve(); + if (agentId) await manager.closeAgent(agentId).catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("does not replace a newer foreground turn after unavailable steer fallback is admitted", async () => { + const entered = deferred(); + const release = deferred(); + const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() }); + session.steerResult = "unavailable"; + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stale-steer-")); + const client = new (class extends TestAgentClient { + override async createSession() { + return session; + } + })(); + const manager = new AgentManager({ + clients: { codex: client }, + beforeSteerUnavailableFallback: async () => { + entered.resolve(); + await release.promise; + }, + logger, + }); + let agentId: string | null = null; + let consumeB: Promise | null = null; + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + agentId = agent.id; + const a = manager.streamAgent(agent.id, "A"); + const consumeA = (async () => { + for await (const _ of a) { + } + })(); + await manager.waitForAgentRunStart(agent.id); + const send = startAgentRun(manager, agent.id, "hello", logger, { + replaceRunning: true, + activeTurnBehavior: "steer", + runOptions: { clientMessageId: "hello-client" }, + }); + const rejected = expect(send).rejects.toThrow( + "Active turn changed before steering could be delivered", + ); + await entered.promise; + session.pushEvent({ type: "turn_completed", provider: "codex", turnId: "active-turn-1" }); + await consumeA; + const b = manager.streamAgent(agent.id, "B"); + consumeB = (async () => { + for await (const _ of b) { + } + })(); + await manager.waitForAgentRunStart(agent.id); + release.resolve(); + await rejected; + expect(manager.getAgent(agent.id)?.activeForegroundTurnId).toBe("active-turn-2"); + expect(session.interruptCount).toBe(0); + expect(session.startPrompts).not.toContain("hello"); + expect( + manager + .getTimeline(agent.id) + .some((item) => item.type === "user_message" && item.clientMessageId === "hello-client"), + ).toBe(false); + } finally { + release.resolve(); + if (agentId) await manager.closeAgent(agentId).catch(() => undefined); + await Promise.race([ + consumeB ?? Promise.resolve(), + new Promise((resolve) => setTimeout(resolve, 100)), + ]); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("isolated rewind falls back from steering to the normal replacement path", async () => { + const session = new SteeringTestSession({ provider: "claude", cwd: process.cwd() }); + session.steerResult = "unavailable"; + const { manager, agentId, workdir } = await startAndSteerThroughManager(session); + try { + await startAgentRun(manager, agentId, "/rewind submitted-message-id", logger, { + replaceRunning: true, + activeTurnBehavior: "steer", + runOptions: { clientMessageId: "rewind-client" }, + }); + expect(session.interruptCount).toBe(2); + expect(session.startPrompts).toContain("/rewind submitted-message-id"); + expect(manager.getTimeline(agentId)).toContainEqual( + expect.objectContaining({ type: "user_message", clientMessageId: "rewind-client" }), + ); + } finally { + await manager.closeAgent(agentId); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("missing steer operation interrupts once and starts one replacement turn", async () => { + const session = new UnsupportedSteeringSession({ provider: "codex", cwd: process.cwd() }); + const { manager, agentId, workdir } = await startAndSteerThroughManager(session); + try { + expect(session.interruptCount).toBe(1); + expect(session.startCount).toBe(2); + expect(manager.getTimeline(agentId)).toContainEqual( + expect.objectContaining({ type: "user_message", clientMessageId: "replacement-client" }), + ); + } finally { + await manager.closeAgent(agentId); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("ambiguous steer failure leaves the active turn untouched", async () => { + const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() }); + session.steerResult = new Error("connection lost after delivery"); + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-ambiguous-")); + const client = new (class extends TestAgentClient { + override async createSession(): Promise { + return session; + } + })(); + const manager = new AgentManager({ clients: { codex: client }, logger }); + let agentId: string | null = null; + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + agentId = agent.id; + const run = manager.streamAgent(agent.id, "initial"); + void (async () => { + for await (const _event of run) { + } + })(); + await manager.waitForAgentRunStart(agent.id); + await expect( + startAgentRun(manager, agent.id, "must not resend", logger, { + replaceRunning: true, + activeTurnBehavior: "steer", + runOptions: { clientMessageId: "ambiguous-client" }, + }), + ).rejects.toThrow("connection lost after delivery"); + expect(session.interruptCount).toBe(0); + expect(session.startCount).toBe(1); + expect(manager.getTimeline(agent.id)).not.toContainEqual( + expect.objectContaining({ clientMessageId: "ambiguous-client" }), + ); + } finally { + if (agentId) await manager.closeAgent(agentId); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("steering records concurrent early echoes as canonical submitted prompts", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-")); + const session = new SteeringTestSession({ provider: "codex", cwd: workdir }); + const client = new (class extends TestAgentClient { + override async createSession(): Promise { + return session; + } + })(); + const manager = new AgentManager({ clients: { codex: client }, logger }); + let agentId: string | null = null; + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + agentId = agent.id; + const stream = manager.streamAgent(agent.id, "start"); + void (async () => { + for await (const _event of stream) { + } + })(); + await manager.waitForAgentRunStart(agent.id); + await Promise.all([ + manager.steerAgentRun(agent.id, "one", { clientMessageId: "client-one" }), + manager.steerAgentRun(agent.id, "two", { clientMessageId: "client-two" }), + ]); + const rows = manager.getTimeline(agent.id).filter((item) => item.type === "user_message"); + expect(rows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ messageId: "client-one", clientMessageId: "client-one" }), + expect.objectContaining({ messageId: "client-two", clientMessageId: "client-two" }), + ]), + ); + expect(rows.filter((item) => item.clientMessageId === "client-one")).toHaveLength(1); + expect(rows.filter((item) => item.clientMessageId === "client-two")).toHaveLength(1); + } finally { + if (agentId) await manager.closeAgent(agentId); + rmSync(workdir, { recursive: true, force: true }); + } +}); + class McpCapableTestAgentClient extends TestAgentClient { override readonly capabilities = { ...TEST_CAPABILITIES, @@ -3208,7 +4648,7 @@ test("hydrateTimelineFromProvider restores and broadcasts provider children from replayState: false, }); - await manager.hydrateTimelineFromProvider(snapshot.id, { broadcast: true }); + await manager.hydrateTimelineFromProvider(snapshot.id, { force: true, broadcast: true }); expect(manager.listProviderSubagents(snapshot.id)).toEqual([ expect.objectContaining({ @@ -4475,7 +5915,7 @@ test("hydrateTimeline preserves assistant chunk, reasoning, and tool timeline hi { workspaceId: undefined }, ); - await manager.hydrateTimelineFromProvider(snapshot.id); + await manager.hydrateTimelineFromProvider(snapshot.id, { force: true }); expect(manager.getTimeline(snapshot.id)).toEqual([ { type: "assistant_message", text: "chunk one " }, @@ -4558,7 +5998,7 @@ test("hydrateTimeline preserves reasoning between assistant chunks", async () => { workspaceId: undefined }, ); - await manager.hydrateTimelineFromProvider(snapshot.id); + await manager.hydrateTimelineFromProvider(snapshot.id, { force: true }); expect(manager.getTimeline(snapshot.id)).toEqual([ { @@ -8495,7 +9935,7 @@ test("hydrateTimeline keeps provider user_message items when no canonical user h { workspaceId: undefined }, ); - await manager.hydrateTimelineFromProvider(snapshot.id); + await manager.hydrateTimelineFromProvider(snapshot.id, { force: true }); const timeline = manager.getTimeline(snapshot.id); const userMessages = timeline.filter((item) => item.type === "user_message"); @@ -8560,7 +10000,7 @@ test("hydrateTimeline preserves provider replay timestamps and marks missing one { workspaceId: undefined }, ); - await manager.hydrateTimelineFromProvider(snapshot.id); + await manager.hydrateTimelineFromProvider(snapshot.id, { force: true }); const timeline = manager.fetchTimeline(snapshot.id, { direction: "tail", limit: 0 }).rows; expect(timeline).toHaveLength(2); @@ -8768,6 +10208,7 @@ test("canonical submitted prompt keeps wire identity while rewind resolves provi seq: 1, timestamp: expect.any(String), providerMessageId: "provider-message-1", + turnId: "turn-submitted-user-message", item: { type: "user_message", text: "hello from composer", @@ -8778,6 +10219,7 @@ test("canonical submitted prompt keeps wire identity while rewind resolves provi { seq: 2, timestamp: expect.any(String), + turnId: "turn-submitted-user-message", item: { type: "assistant_message", text: "output before provider echo" }, }, ]); @@ -9167,7 +10609,7 @@ test("user_message events wrapping a paseo-system envelope are not restored duri workspaceId: undefined, }); - await manager.hydrateTimelineFromProvider(snapshot.id); + await manager.hydrateTimelineFromProvider(snapshot.id, { force: true }); const timeline = manager.getTimeline(snapshot.id); const userMessages = timeline.filter((item) => item.type === "user_message"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index f3db4f385..eb71c2205 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -38,6 +38,7 @@ import { type AgentRunResult, type AgentSession, type AgentSessionConfig, + type SteerResult, type AgentStreamEvent, type AgentTimelineItem, type AgentUsage, @@ -59,6 +60,8 @@ import type { AgentTimelineRow, AgentTimelineStore, } from "./agent-timeline-store-types.js"; +import { DurableTimelineBuffer, type DurableTimelineTimers } from "./durable-timeline-buffer.js"; +import { reconcileProviderHistory } from "./history-reconciliation.js"; import { AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS, AgentStreamCoalescer, @@ -277,10 +280,20 @@ export interface AgentManagerOptions { paseoToolCatalogFactory?: PaseoToolCatalogFactory; appendSystemPrompt?: string; agentStreamCoalesceWindowMs?: number; + durableTimelineCoalesceWindowMs?: number; + durableTimelineTimers?: DurableTimelineTimers; rescueTimeouts?: AgentManagerRescueTimeouts; + beforeSteerUnavailableFallback?: (input: { + agentId: string; + expectedTurnId: string; + }) => Promise; logger: Logger; } +export type ActiveTurnSteerDispatchResult = + | { status: "inactive" | "steered" } + | { status: "replaced"; iterator: AsyncGenerator }; + export interface WaitForAgentOptions { signal?: AbortSignal; waitForActive?: boolean; @@ -452,6 +465,12 @@ function attachManagedTurnIdentity( const turnId = agent.activeForegroundTurnId ?? agent.activeTurnId ?? undefined; return turnId ? { event: { ...event, turnId }, turnId } : { event, turnId }; } + case "timeline": { + // Live provider items belong to the foreground turn that owns their dispatch. + // History deliberately keeps absent IDs so old persisted rows retain legacy grouping. + const turnId = agent.activeForegroundTurnId ?? agent.activeTurnId ?? undefined; + return turnId ? { event: { ...event, turnId }, turnId } : { event, turnId }; + } default: return { event, turnId: undefined }; } @@ -496,6 +515,10 @@ interface SubscriptionRecord { agentId: string | null; } +interface SteerEventBarrier { + events: AgentStreamEvent[]; +} + const BUSY_STATUSES: Set = new Set(["initializing", "running"]); const AgentIdSchema = z.guid(); @@ -645,11 +668,14 @@ export class AgentManager { private readonly providerSubagents = new ProviderSubagentStore(); private readonly agentsAwaitingInitialSnapshotPersist = new Set(); private readonly sessionEventTails = new Map>(); + private readonly steerEventBarriers = new Map(); + private readonly foregroundMutationTails = new Map>(); private readonly runs = new AgentRunState(); private readonly subscribers = new Set(); private readonly idFactory: () => string; private readonly registry?: AgentStorage; private readonly durableTimelineStore?: AgentTimelineStore; + private readonly durableTimelineBuffer?: DurableTimelineBuffer; private readonly previousStatuses = new Map(); private readonly backgroundTasks = new Set>(); private readonly agentRegistrationTasks = new Set>(); @@ -666,6 +692,7 @@ export class AgentManager { private onWorkspaceStateMayHaveChanged?: (params: { cwd: string }) => void; private logger: Logger; private readonly rescueTimeouts: Required; + private readonly beforeSteerUnavailableFallback?: AgentManagerOptions["beforeSteerUnavailableFallback"]; private acceptingAgentRegistrations = true; constructor(options: AgentManagerOptions) { @@ -679,12 +706,14 @@ export class AgentManager { this.configurePaseoTools(options); this.appendSystemPrompt = options.appendSystemPrompt ?? ""; this.logger = options.logger.child({ module: "agent", component: "agent-manager" }); + this.durableTimelineBuffer = this.createDurableTimelineBuffer(options); this.rescueTimeouts = { reloadSessionCloseMs: options.rescueTimeouts?.reloadSessionCloseMs ?? RELOAD_SESSION_CLOSE_TIMEOUT_MS, interruptSessionMs: options.rescueTimeouts?.interruptSessionMs ?? INTERRUPT_SESSION_TIMEOUT_MS, }; + this.beforeSteerUnavailableFallback = options.beforeSteerUnavailableFallback; this.agentStreamCoalescer = new AgentStreamCoalescer({ windowMs: options.agentStreamCoalesceWindowMs ?? AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS, timers: { setTimeout, clearTimeout }, @@ -699,6 +728,20 @@ export class AgentManager { }); } + private createDurableTimelineBuffer( + options: AgentManagerOptions, + ): DurableTimelineBuffer | undefined { + if (!this.durableTimelineStore) return undefined; + return new DurableTimelineBuffer( + this.durableTimelineStore, + options.durableTimelineCoalesceWindowMs ?? 25, + options.durableTimelineTimers ?? { setTimeout, clearTimeout }, + (agentId, error) => { + this.logger.error({ err: error, agentId }, "Failed to persist queued timeline rows"); + }, + ); + } + private configurePaseoTools(options: AgentManagerOptions): void { this.paseoToolsEnabled = options.paseoToolsEnabled ?? true; this.paseoToolCatalogFactory = options.paseoToolCatalogFactory ?? null; @@ -1134,6 +1177,7 @@ export class AgentManager { initialTitle: options.initialTitle, workspaceId: options.workspaceId, owner: options.owner, + historyPrimed: true, }); } @@ -2199,6 +2243,7 @@ export class AgentManager { if (options?.clientMessageId) { this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, { messageId: options.clientMessageId, + turnId, providerMessageId: stagedSubmittedPromptEcho?.item.type === "user_message" ? stagedSubmittedPromptEcho.item.messageId @@ -2347,6 +2392,169 @@ export class AgentManager { } } + async steerAgentRun( + agentId: string, + prompt: AgentPromptInput, + options?: AgentRunOptions, + ): Promise { + const agent = this.requireSessionAgent(agentId); + const expectedTurnId = agent.activeForegroundTurnId; + if (!expectedTurnId || !this.runs.hasRun(agentId) || !agent.session.steerActiveTurn) { + return { status: "unavailable" }; + } + const result = await this.runSteerAdmission(agent, expectedTurnId, async () => { + const admission = await agent.session.steerActiveTurn!(prompt, { + ...options, + expectedTurnId, + }); + if (admission.status === "accepted") { + await this.recordAcceptedSteer(agent, prompt, options?.clientMessageId, expectedTurnId); + } + return admission; + }); + // An unavailable answer is only safe to fall back from while this admission + // still owns the foreground. Never let an A admission replace a later B. + if (result.status === "unavailable" && agent.activeForegroundTurnId !== expectedTurnId) { + throw new Error("Active turn changed before steering could be delivered"); + } + return result; + } + + async steerOrReplaceActiveTurn( + agentId: string, + prompt: AgentPromptInput, + options?: AgentRunOptions, + ): Promise { + const agent = this.requireSessionAgent(agentId); + const expectedTurnId = agent.activeForegroundTurnId; + if (!expectedTurnId || !this.runs.hasRun(agentId)) { + return { status: "inactive" }; + } + + const result = agent.session.steerActiveTurn + ? await this.runSteerAdmission(agent, expectedTurnId, async () => { + const admission = await agent.session.steerActiveTurn!(prompt, { + ...options, + expectedTurnId, + }); + if (admission.status === "accepted") { + await this.recordAcceptedSteer(agent, prompt, options?.clientMessageId, expectedTurnId); + } + return admission; + }) + : { status: "unavailable" as const }; + if (result.status === "accepted") { + return { status: "steered" }; + } + + await this.beforeSteerUnavailableFallback?.({ agentId, expectedTurnId }); + this.assertSteerAdmissionOwnsForeground(agent, expectedTurnId); + return { + status: "replaced", + iterator: await this.replaceAdmittedForegroundTurn(agent, expectedTurnId, prompt, options), + }; + } + + private assertSteerAdmissionOwnsForeground( + agent: ActiveManagedAgent, + expectedTurnId: string, + ): void { + if (agent.activeForegroundTurnId !== expectedTurnId || !this.runs.hasRun(agent.id)) { + throw new Error("Active turn changed before steering could be delivered"); + } + } + + private async runSteerAdmission( + agent: ActiveManagedAgent, + expectedTurnId: string, + operation: () => Promise, + ): Promise { + return this.runForegroundMutation(agent.id, async () => { + await this.drainSessionEvents(agent.id); + this.agentStreamCoalescer.flushFor(agent.id); + this.assertSteerAdmissionOwnsForeground(agent, expectedTurnId); + const barrier: SteerEventBarrier = { events: [] }; + this.steerEventBarriers.set(agent.id, barrier); + try { + return await operation(); + } finally { + if (this.steerEventBarriers.get(agent.id) === barrier) { + this.steerEventBarriers.delete(agent.id); + } + for (const event of barrier.events) { + this.enqueueSessionEvent(agent.id, event); + } + await this.drainSessionEvents(agent.id); + } + }); + } + + private async runForegroundMutation(agentId: string, operation: () => Promise): Promise { + const previous = this.foregroundMutationTails.get(agentId) ?? Promise.resolve(); + const run = previous.catch(() => undefined).then(operation); + const tail = run.then( + () => undefined, + () => undefined, + ); + this.foregroundMutationTails.set(agentId, tail); + try { + return await run; + } finally { + if (this.foregroundMutationTails.get(agentId) === tail) { + this.foregroundMutationTails.delete(agentId); + } + } + } + + private async replaceAdmittedForegroundTurn( + agent: ActiveManagedAgent, + expectedTurnId: string, + prompt: AgentPromptInput, + options?: AgentRunOptions, + ): Promise> { + this.assertSteerAdmissionOwnsForeground(agent, expectedTurnId); + agent.pendingReplacement = true; + agent.lifecycle = "running"; + this.touchUpdatedAt(agent); + this.emitState(agent); + + try { + await this.cancelAgentRunBefore(agent.id, "replace"); + return this.streamAgent(agent.id, prompt, options); + } catch (error) { + const latest = this.agents.get(agent.id); + if (latest) { + latest.pendingReplacement = false; + } + throw error; + } + } + + private async recordAcceptedSteer( + agent: ActiveManagedAgent, + prompt: AgentPromptInput, + clientMessageId: string | undefined, + expectedTurnId: string, + ): Promise { + if (!clientMessageId) { + return; + } + this.recordSubmittedPrompt(agent, prompt, clientMessageId, { + messageId: clientMessageId, + turnId: expectedTurnId, + }); + if (this.durableTimelineBuffer) { + try { + await this.durableTimelineBuffer.flush(agent.id); + } catch (error) { + throw new Error("Steering was accepted but its canonical message could not be persisted", { + cause: error, + }); + } + } + this.emitState(agent); + } + async waitForAgentRunStart(agentId: string, options?: WaitForAgentStartOptions): Promise { const snapshot = this.getAgent(agentId); if (!snapshot) { @@ -2489,6 +2697,10 @@ export class AgentManager { } async cancelAgentRun(agentId: string): Promise { + return this.runForegroundMutation(agentId, () => this.cancelAgentRunNow(agentId)); + } + + private async cancelAgentRunNow(agentId: string): Promise { const agent = this.requireSessionAgent(agentId); const run = this.runs.getRun(agentId) ?? @@ -2651,6 +2863,7 @@ export class AgentManager { if (!this.durableTimelineStore) { return; } + await this.durableTimelineBuffer?.discard(agentId); await this.durableTimelineStore.deleteAgent(agentId); } @@ -2935,7 +3148,7 @@ export class AgentManager { ); const now = new Date(); - const { durableTimelineHasRows } = await this.initializeAgentTimelineForRegister({ + const { durableHistoryComplete } = await this.initializeAgentTimelineForRegister({ agentId: resolvedAgentId, now, options, @@ -2946,10 +3159,12 @@ export class AgentManager { session, config, now, - durableTimelineHasRows, options, }); + if (managed.historyPrimed && !durableHistoryComplete) { + await this.commitCompleteHistorySnapshot(resolvedAgentId); + } this.assertAcceptingAgentRegistrations(); this.agents.set(resolvedAgentId, managed); registered = true; @@ -3029,7 +3244,7 @@ export class AgentManager { updatedAt?: Date; } | undefined; - }): Promise<{ durableTimelineHasRows: boolean }> { + }): Promise<{ durableHistoryComplete: boolean }> { const { agentId, now, options } = params; const timelineAlreadyPrimed = this.timelineStore.has(agentId); const explicitTimelineSeed = buildExplicitTimelineSeedForRegister(now, options); @@ -3040,9 +3255,8 @@ export class AgentManager { const durableTimelineSeed = shouldSeedFromDurable ? await this.loadCommittedTimelineSeed(agentId, now) : null; - const durableTimelineHasRows = - timelineAlreadyPrimed || - (durableTimelineSeed != null && (durableTimelineSeed.nextSeq ?? 1) > 1); + const durableHistoryComplete = + timelineAlreadyPrimed || durableTimelineSeed?.historyComplete === true; const timelineSeed = explicitTimelineSeed ?? durableTimelineSeed; if (timelineSeed || !this.timelineStore.has(agentId)) { this.timelineStore.initialize(agentId, timelineSeed ?? { timestamp: now.toISOString() }); @@ -3050,7 +3264,7 @@ export class AgentManager { if (options?.timelineRows?.length) { this.enqueueDurableTimelineBulkInsert(agentId, options.timelineRows); } - return { durableTimelineHasRows }; + return { durableHistoryComplete }; } private buildManagedAgentForRegister(params: { @@ -3058,7 +3272,6 @@ export class AgentManager { session: AgentSession; config: AgentSessionConfig; now: Date; - durableTimelineHasRows: boolean; options: | { createdAt?: Date; @@ -3075,7 +3288,7 @@ export class AgentManager { } | undefined; }): ActiveManagedAgent { - const { resolvedAgentId, session, config, now, durableTimelineHasRows, options } = params; + const { resolvedAgentId, session, config, now, options } = params; return { id: resolvedAgentId, provider: config.provider, @@ -3105,7 +3318,7 @@ export class AgentManager { options?.persistence ?? session.describePersistence(), config.cwd, ), - historyPrimed: options?.historyPrimed ?? durableTimelineHasRows, + historyPrimed: options?.historyPrimed ?? false, lastUserMessageAt: options?.lastUserMessageAt ?? null, lastUsage: options?.lastUsage, lastError: options?.lastError, @@ -3118,14 +3331,18 @@ export class AgentManager { private async loadCommittedTimelineSeed( agentId: string, now: Date, - ): Promise { + ): Promise { if (!this.durableTimelineStore) { return { timestamp: now.toISOString() }; } + const snapshot = await this.durableTimelineStore.getCommittedSnapshot(agentId); + const rows = snapshot.rows; return { - nextSeq: (await this.durableTimelineStore.getLatestCommittedSeq(agentId)) + 1, + rows, + nextSeq: rows.length > 0 ? rows[rows.length - 1]!.seq + 1 : 1, timestamp: now.toISOString(), + historyComplete: snapshot.historyComplete, }; } @@ -3196,6 +3413,11 @@ export class AgentManager { }, "agent.manager.enqueue", ); + const steerBarrier = this.steerEventBarriers.get(agentId); + if (steerBarrier) { + steerBarrier.events.push(event); + return; + } const pendingRun = this.runs.getPendingRun(agentId); if (pendingRun && !pendingRun.started) { pendingRun.stagedEvents.push(event); @@ -3424,7 +3646,8 @@ export class AgentManager { ): Promise { const historyEvents: Extract[] = []; const providerSubagentEvents: Extract[] = []; - for await (const event of agent.session.streamHistory()) { + for await (const rawEvent of agent.session.streamHistory()) { + const event = limitAgentStreamEventContent(rawEvent); if (event.type === "timeline") { if (event.item.type === "user_message" && isSystemInjectedEnvelope(event.item.text)) { continue; @@ -3435,11 +3658,17 @@ export class AgentManager { } } + const reconciledRows = reconcileProviderHistory( + this.timelineStore.getRows(agent.id), + historyEvents.map((event) => ({ item: event.item, timestamp: event.timestamp })), + { mode: "force" }, + ); this.agentStreamCoalescer.flushAndDiscard(agent.id); + await this.durableTimelineBuffer?.flush(agent.id); await this.deleteCommittedTimeline(agent.id); this.timelineStore.delete(agent.id); - this.timelineStore.initialize(agent.id, { timestamp: new Date().toISOString() }); - agent.historyPrimed = true; + this.timelineStore.initialize(agent.id, { rows: reconciledRows }); + agent.historyPrimed = false; for (const event of this.providerSubagents.deleteParent(agent.id)) { if (broadcast) { @@ -3452,20 +3681,11 @@ export class AgentManager { this.dispatch({ type: "provider_subagent", event: update }); } } - for (const event of historyEvents) { - const row = this.recordTimeline( - agent.id, - event.item, - event.timestamp ? { timestamp: event.timestamp } : undefined, - ); - if (broadcast) { - this.dispatchStream(agent.id, event, { - seq: row.seq, - epoch: this.timelineStore.getEpoch(agent.id), - timestamp: row.timestamp, - }); - } + if (broadcast) { + this.dispatchReconciledTimelineRows(agent, reconciledRows); } + await this.commitCompleteHistorySnapshot(agent.id); + agent.historyPrimed = true; this.touchUpdatedAt(agent); this.emitState(agent); } @@ -3475,14 +3695,13 @@ export class AgentManager { broadcast: boolean | (() => boolean), ): Promise { const deferredBroadcast = typeof broadcast === "function"; - const timelineEvents: Array<{ - event: Extract; - row: AgentTimelineRow; - }> = []; + let timelineRows: AgentTimelineRow[] = []; const providerSubagentEvents: AgentManagerEvent[] = []; - agent.historyPrimed = true; + const historyEvents: Extract[] = []; + agent.historyPrimed = false; try { - for await (const event of agent.session.streamHistory()) { + for await (const rawEvent of agent.session.streamHistory()) { + const event = limitAgentStreamEventContent(rawEvent); if (event.type === "provider_subagent") { const update = this.providerSubagents.apply(agent.id, event.provider, event.event); const managerEvent: AgentManagerEvent = { type: "provider_subagent", event: update }; @@ -3499,23 +3718,27 @@ export class AgentManager { if (event.item.type === "user_message" && isSystemInjectedEnvelope(event.item.text)) { continue; } - const row = this.recordTimeline( - agent.id, - event.item, - event.timestamp ? { timestamp: event.timestamp } : undefined, - ); - if (deferredBroadcast) { - timelineEvents.push({ event, row }); - } else if (broadcast) { - this.dispatchStream(agent.id, event, { - seq: row.seq, - epoch: this.timelineStore.getEpoch(agent.id), - timestamp: row.timestamp, - }); - } + historyEvents.push(event); } - } catch { - // ignore history failures + + const reconciledRows = reconcileProviderHistory( + this.timelineStore.getRows(agent.id), + historyEvents.map((event) => ({ item: event.item, timestamp: event.timestamp })), + ); + this.timelineStore.initialize(agent.id, { rows: reconciledRows }); + if (deferredBroadcast) { + timelineRows = reconciledRows; + } else if (broadcast) { + this.dispatchReconciledTimelineRows(agent, reconciledRows); + } + await this.commitCompleteHistorySnapshot(agent.id); + agent.historyPrimed = true; + } catch (error) { + this.logger.warn( + { err: error, agentId: agent.id }, + "Failed to commit provider history snapshot", + ); + throw error; } if (typeof broadcast !== "function" || !broadcast()) { @@ -3524,15 +3747,42 @@ export class AgentManager { for (const event of providerSubagentEvents) { this.dispatch(event); } - for (const { event, row } of timelineEvents) { - this.dispatchStream(agent.id, event, { - seq: row.seq, - epoch: this.timelineStore.getEpoch(agent.id), - timestamp: row.timestamp, - }); + this.dispatchReconciledTimelineRows(agent, timelineRows); + } + + private dispatchReconciledTimelineRows( + agent: ActiveManagedAgent, + rows: readonly AgentTimelineRow[], + ): void { + const epoch = this.timelineStore.getEpoch(agent.id); + for (const row of rows) { + this.dispatchStream( + agent.id, + { + type: "timeline", + provider: agent.provider, + item: row.item, + ...(row.turnId ? { turnId: row.turnId } : {}), + timestamp: row.timestamp, + }, + { + seq: row.seq, + epoch, + timestamp: row.timestamp, + }, + ); } } + private async commitCompleteHistorySnapshot(agentId: string): Promise { + if (!this.durableTimelineStore) return; + await this.durableTimelineBuffer?.flush(agentId); + await this.durableTimelineStore.replaceCommittedSnapshot(agentId, { + rows: this.timelineStore.getRows(agentId), + historyComplete: true, + }); + } + private notifyForegroundTurnWaiters(agentId: string, event: AgentStreamEvent): void { const turnId = getAgentStreamEventTurnId(event); if (turnId == null) { @@ -3812,7 +4062,7 @@ export class AgentManager { if ( event.item.type === "user_message" && event.item.clientMessageId && - this.reconcileSubmittedPromptEcho(agent, event.item) + this.reconcileSubmittedPromptEcho(agent, event.item, event.turnId) ) { flags.shouldDispatchEvent = false; flags.shouldNotifyWaiters = false; @@ -4045,7 +4295,7 @@ export class AgentManager { turnId?: string, options?: { providerMessageId?: string }, ): AgentStreamEvent { - const row = this.recordTimeline(agentId, item, options); + const row = this.recordTimeline(agentId, item, { ...options, turnId }); const event: AgentStreamEvent = { type: "timeline", item, @@ -4077,7 +4327,7 @@ export class AgentManager { agent: ActiveManagedAgent, prompt: AgentPromptInput, clientMessageId: string, - options?: { messageId?: string; providerMessageId?: string }, + options?: { messageId?: string; providerMessageId?: string; turnId?: string }, ): void { if (this.timelineStore.getSubmittedUserMessage(agent.id, clientMessageId)) { return; @@ -4090,16 +4340,25 @@ export class AgentManager { clientMessageId, ...(options?.messageId ? { messageId: options.messageId } : {}), }; - this.recordAndDispatchTimelineItem(agent.id, item, agent.provider, undefined, options); + this.recordAndDispatchTimelineItem(agent.id, item, agent.provider, options?.turnId, options); } private reconcileSubmittedPromptEcho( agent: ActiveManagedAgent, item: Extract, + turnId?: string, ): AgentTimelineRow | null { const { clientMessageId, messageId } = item; if (!clientMessageId) return null; - const existing = this.timelineStore.getSubmittedUserMessage(agent.id, clientMessageId); + let existing = this.timelineStore.getSubmittedUserMessage(agent.id, clientMessageId); + if (!existing) { + this.recordSubmittedPrompt(agent, item.text, clientMessageId, { + messageId: clientMessageId, + ...(messageId ? { providerMessageId: messageId } : {}), + ...(turnId ? { turnId } : {}), + }); + existing = this.timelineStore.getSubmittedUserMessage(agent.id, clientMessageId); + } if (!existing || existing.item.type !== "user_message") return null; if (messageId) { const enriched = this.timelineStore.enrichSubmittedUserMessage( @@ -4169,11 +4428,18 @@ export class AgentManager { private recordTimeline( agentId: string, item: AgentTimelineItem, - options?: { timestamp?: string; providerMessageId?: string }, + options?: { + timestamp?: string; + providerMessageId?: string; + turnId?: string; + persist?: boolean; + }, ): AgentTimelineRow { item = limitAgentTimelineItemContent(item); const row = this.timelineStore.append(agentId, item, options); - this.enqueueDurableTimelineAppend(agentId, row); + if (options?.persist !== false) { + this.enqueueDurableTimelineAppend(agentId, row); + } return row; } @@ -4260,19 +4526,10 @@ export class AgentManager { } private enqueueDurableTimelineAppend(agentId: string, row: AgentTimelineRow): void { - if (!this.durableTimelineStore) { + if (!this.durableTimelineBuffer) { return; } - const task = this.durableTimelineStore - .bulkInsert(agentId, [row]) - .then(() => undefined) - .catch((err) => { - this.logger.error( - { err, agentId, seq: row.seq, itemType: row.item.type }, - "Failed to append timeline row to durable store", - ); - }); - this.trackBackgroundTask(task); + this.durableTimelineBuffer.enqueue(agentId, row); } private enqueueDurableTimelineBulkInsert( @@ -4282,18 +4539,15 @@ export class AgentManager { if (!this.durableTimelineStore || rows.length === 0) { return; } - const task = this.durableTimelineStore.bulkInsert(agentId, rows).catch((err) => { - this.logger.error( - { err, agentId, rowCount: rows.length }, - "Failed to seed durable timeline store", - ); - }); - this.trackBackgroundTask(task); + for (const row of rows) this.durableTimelineBuffer?.enqueue(agentId, row); } private enqueueDurableTimelineUpdate(agentId: string, row: AgentTimelineRow): void { if (!this.durableTimelineStore) return; - const task = this.durableTimelineStore.updateCommittedRow(agentId, row).catch((err) => { + const task = (async () => { + await this.durableTimelineBuffer?.flush(agentId); + await this.durableTimelineStore?.updateCommittedRow(agentId, row); + })().catch((err) => { this.logger.error( { err, agentId, seq: row.seq, itemType: row.item.type }, "Failed to enrich durable timeline row", @@ -4340,6 +4594,7 @@ export class AgentManager { private async flushTasks(options: { includeAgentRegistrations: boolean }): Promise { this.agentStreamCoalescer.flushAll(); + await this.durableTimelineBuffer?.flushAll(); // Drain tasks, including tasks spawned while awaiting. while ( this.backgroundTasks.size > 0 || diff --git a/packages/server/src/server/agent/agent-prompt.test.ts b/packages/server/src/server/agent/agent-prompt.test.ts index cc84c88d6..af291256e 100644 --- a/packages/server/src/server/agent/agent-prompt.test.ts +++ b/packages/server/src/server/agent/agent-prompt.test.ts @@ -7,7 +7,6 @@ import { AgentStorage } from "./agent-storage.js"; import { formatSystemNotificationPrompt, isSystemInjectedEnvelope, - sendPromptToAgent, setupFinishNotification, } from "./agent-prompt.js"; import type { AgentManagerEvent, ManagedAgent } from "./agent-manager.js"; @@ -245,45 +244,6 @@ test("isSystemInjectedEnvelope matches the envelope formatSystemNotificationProm expect(isSystemInjectedEnvelope("hello world")).toBe(false); }); -test("sendPromptToAgent forwards the client message id as run options", async () => { - const agent: ManagedAgent = Object.create(null); - Reflect.set(agent, "id", "agent-1"); - Reflect.set(agent, "provider", "codex"); - - const streamAgentSpy = vi.fn(() => (async function* noop() {})()); - const agentManager: AgentManager = Object.create(AgentManager.prototype); - Reflect.set( - agentManager, - "getAgent", - vi.fn(() => agent), - ); - Reflect.set(agentManager, "tryRunOutOfBand", vi.fn().mockReturnValue(false)); - Reflect.set(agentManager, "hasInFlightRun", vi.fn().mockReturnValue(false)); - Reflect.set(agentManager, "streamAgent", streamAgentSpy); - - const agentStorage: AgentStorage = Object.create(AgentStorage.prototype); - Reflect.set( - agentStorage, - "get", - vi.fn(async () => null), - ); - - await sendPromptToAgent({ - agentManager, - agentStorage, - agentId: "agent-1", - prompt: "hello", - messageId: "msg-client-1", - runOptions: { outputSchema: { type: "object" } }, - logger: createTestLogger(), - }); - - expect(streamAgentSpy).toHaveBeenCalledWith("agent-1", "hello", { - outputSchema: { type: "object" }, - clientMessageId: "msg-client-1", - }); -}); - test("finish notifications tell the parent the child's last assistant message", async () => { const scenario = createFinishNotificationScenario({ childLastAssistantMessage: "Implemented the cleanup and all checks pass.", diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index d92b225b3..020a3e91c 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -9,26 +9,77 @@ import type { AgentManager, ManagedAgent } from "./agent-manager.js"; import type { AgentStorage } from "./agent-storage.js"; import { ensureAgentLoaded } from "./agent-loading.js"; import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels"; +import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages"; export type AgentUnarchiveController = Pick; export type AgentRunController = Pick< AgentManager, - "getAgent" | "tryRunOutOfBand" | "hasInFlightRun" | "replaceAgentRun" | "streamAgent" + | "getAgent" + | "tryRunOutOfBand" + | "hasInFlightRun" + | "replaceAgentRun" + | "steerOrReplaceActiveTurn" + | "streamAgent" >; export interface StartAgentRunOptions { replaceRunning?: boolean; + activeTurnBehavior?: ActiveTurnBehavior; runOptions?: AgentRunOptions; } +export type PromptDispatchDisposition = "out_of_band" | "steered" | "turn_started"; + +async function steerOrReplaceActiveRun( + agentManager: AgentRunController, + agentId: string, + prompt: AgentPromptInput, + options: StartAgentRunOptions | undefined, +): Promise< + | { disposition: "steered" } + | { + disposition: "turn_started"; + iterator: AsyncGenerator; + } + | null +> { + if (options?.activeTurnBehavior !== "steer") { + return null; + } + const result = await agentManager.steerOrReplaceActiveTurn(agentId, prompt, options.runOptions); + if (result.status === "steered") { + return { disposition: "steered" }; + } + if (result.status === "replaced") { + return { disposition: "turn_started", iterator: result.iterator }; + } + return null; +} + +async function startOrReplaceRun( + agentManager: AgentRunController, + agentId: string, + prompt: AgentPromptInput, + options: StartAgentRunOptions | undefined, +): Promise<{ + iterator: AsyncGenerator; + replaced: boolean; +}> { + const replaced = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId)); + const iterator = replaced + ? await agentManager.replaceAgentRun(agentId, prompt, options?.runOptions) + : agentManager.streamAgent(agentId, prompt, options?.runOptions); + return { iterator, replaced }; +} + export async function startAgentRun( agentManager: AgentRunController, agentId: string, prompt: AgentPromptInput, logger: Logger, options?: StartAgentRunOptions, -): Promise<{ outOfBand: boolean }> { +): Promise<{ disposition: PromptDispatchDisposition }> { const snapshot = agentManager.getAgent(agentId); logger.trace( { @@ -46,19 +97,21 @@ export async function startAgentRun( // in-flight turn — replaceAgentRun would interrupt the running turn. The // intercept lives at this layer so it covers every prompt entrypoint. if (agentManager.tryRunOutOfBand(agentId, prompt, options?.runOptions)) { - return { outOfBand: true }; + return { disposition: "out_of_band" }; } - const shouldReplace = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId)); - const runOptions = options?.runOptions; - const iterator = shouldReplace - ? await agentManager.replaceAgentRun(agentId, prompt, runOptions) - : agentManager.streamAgent(agentId, prompt, runOptions); + const steered = await steerOrReplaceActiveRun(agentManager, agentId, prompt, options); + if (steered?.disposition === "steered") { + return steered; + } + const { iterator, replaced } = steered + ? { iterator: steered.iterator, replaced: true } + : await startOrReplaceRun(agentManager, agentId, prompt, options); logger.trace( { agentId, provider: snapshot?.provider, providerSessionId: snapshot?.persistence?.sessionId ?? undefined, - shouldReplace, + shouldReplace: replaced, }, "agent.session.start_stream.iterator_returned", ); @@ -88,7 +141,7 @@ export async function startAgentRun( logger.error({ err: error, agentId }, "Agent stream failed"); } })(); - return { outOfBand: false }; + return { disposition: "turn_started" }; } /** @@ -130,6 +183,7 @@ export interface SendPromptToAgentParams { /** Prompt to dispatch to the provider (may include image blocks or wrapped text). */ prompt: AgentPromptInput; messageId?: string; + activeTurnBehavior?: ActiveTurnBehavior; runOptions?: AgentRunOptions; /** Optional mode to set on the agent before the run starts. */ sessionMode?: string; @@ -176,17 +230,17 @@ export async function waitForAgentRunStartWithTimeout( * drift between them. * * When `unarchive` is false and the agent is archived, the call is a silent - * no-op (returns `{ outOfBand: false }`) — the agent is not run. + * no-op (returns the normal turn-start disposition) — the agent is not run. */ export async function sendPromptToAgent( params: SendPromptToAgentParams, -): Promise<{ outOfBand: boolean }> { +): Promise<{ disposition: PromptDispatchDisposition }> { const unarchive = params.unarchive ?? true; const record = await params.agentStorage.get(params.agentId); if (record?.archivedAt) { if (!unarchive) { - return { outOfBand: false }; + return { disposition: "turn_started" }; } await unarchiveAgentState(params.agentStorage, params.agentManager, params.agentId); } @@ -207,6 +261,7 @@ export async function sendPromptToAgent( return await startAgentRun(params.agentManager, params.agentId, params.prompt, params.logger, { replaceRunning: true, + activeTurnBehavior: params.activeTurnBehavior, runOptions, }); } @@ -233,7 +288,7 @@ export async function startCreatedAgentInitialPrompt( }, ); - if (!dispatchResult.outOfBand) { + if (dispatchResult.disposition === "turn_started") { await waitForAgentRunStartWithTimeout(params.agentManager, params.agentId); } diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 686c1b24a..f8e5a3753 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -214,6 +214,12 @@ export interface AgentRunOptions { clientMessageId?: string; } +export type SteerResult = { status: "accepted" } | { status: "unavailable" }; + +export interface SteerActiveTurnOptions extends AgentRunOptions { + expectedTurnId: string; +} + export interface AgentUsage { inputTokens?: number; cachedInputTokens?: number; @@ -629,6 +635,7 @@ export interface AgentSession { readonly features?: AgentFeature[]; run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise; startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>; + steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise; subscribe(callback: (event: AgentStreamEvent) => void): () => void; streamHistory(): AsyncGenerator; getRuntimeInfo(): Promise; diff --git a/packages/server/src/server/agent/agent-timeline-store-types.ts b/packages/server/src/server/agent/agent-timeline-store-types.ts index 7440f9b14..13fe840f7 100644 --- a/packages/server/src/server/agent/agent-timeline-store-types.ts +++ b/packages/server/src/server/agent/agent-timeline-store-types.ts @@ -4,6 +4,7 @@ export interface AgentTimelineRow { seq: number; timestamp: string; item: AgentTimelineItem; + readonly turnId?: string; readonly providerMessageId?: string; } @@ -31,6 +32,12 @@ export interface AgentTimelineWindow { nextSeq: number; } +/** A committed canonical transcript and whether provider history is fully represented. */ +export interface AgentTimelineSnapshot { + rows: AgentTimelineRow[]; + historyComplete: boolean; +} + export interface AgentTimelineFetchResult { epoch: string; direction: AgentTimelineFetchDirection; @@ -47,7 +54,7 @@ export interface AgentTimelineStore { appendCommitted( agentId: string, item: AgentTimelineItem, - options?: { timestamp?: string }, + options?: { timestamp?: string; turnId?: string }, ): Promise; fetchCommitted( agentId: string, @@ -55,9 +62,11 @@ export interface AgentTimelineStore { ): Promise; getLatestCommittedSeq(agentId: string): Promise; getCommittedRows(agentId: string): Promise; + getCommittedSnapshot(agentId: string): Promise; getLastItem(agentId: string): Promise; getLastAssistantMessage(agentId: string): Promise; deleteAgent(agentId: string): Promise; bulkInsert(agentId: string, rows: readonly AgentTimelineRow[]): Promise; + replaceCommittedSnapshot(agentId: string, snapshot: AgentTimelineSnapshot): Promise; updateCommittedRow(agentId: string, row: AgentTimelineRow): Promise; } diff --git a/packages/server/src/server/agent/agent-timeline-store.ts b/packages/server/src/server/agent/agent-timeline-store.ts index 2500bc19a..e57080711 100644 --- a/packages/server/src/server/agent/agent-timeline-store.ts +++ b/packages/server/src/server/agent/agent-timeline-store.ts @@ -264,13 +264,14 @@ export class InMemoryAgentTimelineStore { append( agentId: string, item: AgentTimelineItem, - options?: { timestamp?: string; providerMessageId?: string }, + options?: { timestamp?: string; providerMessageId?: string; turnId?: string }, ): AgentTimelineRow { const state = this.requireState(agentId); const row: AgentTimelineRow = { seq: state.nextSeq, timestamp: options?.timestamp ?? new Date().toISOString(), item, + ...(options?.turnId ? { turnId: options.turnId } : {}), ...(options?.providerMessageId ? { providerMessageId: options.providerMessageId } : {}), }; state.nextSeq += 1; diff --git a/packages/server/src/server/agent/durable-timeline-buffer.test.ts b/packages/server/src/server/agent/durable-timeline-buffer.test.ts new file mode 100644 index 000000000..c6fc513a1 --- /dev/null +++ b/packages/server/src/server/agent/durable-timeline-buffer.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, test } from "vitest"; + +import { DurableTimelineBuffer } from "./durable-timeline-buffer.js"; +import type { + AgentTimelineFetchResult, + AgentTimelineRow, + AgentTimelineSnapshot, + AgentTimelineStore, +} from "./agent-timeline-store-types.js"; +import type { AgentTimelineItem } from "./agent-sdk-types.js"; + +const row = (seq: number): AgentTimelineRow => ({ + seq, + timestamp: "2026-01-01T00:00:00.000Z", + item: { type: "assistant_message", text: String(seq) }, +}); + +class RecordingStore implements AgentTimelineStore { + readonly batches: AgentTimelineRow[][] = []; + failuresRemaining = 0; + + async appendCommitted(): Promise { + throw new Error("not used"); + } + async fetchCommitted(): Promise { + throw new Error("not used"); + } + async getLatestCommittedSeq(): Promise { + return 0; + } + async getCommittedRows(): Promise { + return []; + } + async getCommittedSnapshot(): Promise { + return { rows: [], historyComplete: false }; + } + async getLastItem(): Promise { + return null; + } + async getLastAssistantMessage(): Promise { + return null; + } + async deleteAgent(): Promise {} + async bulkInsert(_agentId: string, rows: readonly AgentTimelineRow[]): Promise { + this.batches.push([...rows]); + if (this.failuresRemaining > 0) { + this.failuresRemaining -= 1; + throw new Error("disk full"); + } + } + async replaceCommittedSnapshot(): Promise {} + async updateCommittedRow(): Promise {} +} + +class ControlledTimers { + readonly delays: number[] = []; + private readonly callbacks = new Map void>(); + private nextId = 1; + + setTimeout = (callback: () => void, delayMs: number): ReturnType => { + const id = this.nextId++; + this.delays.push(delayMs); + this.callbacks.set(id, callback); + return id as unknown as ReturnType; + }; + + clearTimeout = (timer: ReturnType): void => { + this.callbacks.delete(timer as unknown as number); + }; + + async runNext(): Promise { + const next = this.callbacks.entries().next().value as [number, () => void] | undefined; + if (!next) throw new Error("Expected a scheduled timer"); + this.callbacks.delete(next[0]); + next[1](); + await Promise.resolve(); + await Promise.resolve(); + } + + get size(): number { + return this.callbacks.size; + } +} + +const timers = { + setTimeout: () => 0 as unknown as ReturnType, + clearTimeout: () => undefined, +}; + +describe("DurableTimelineBuffer", () => { + test("retries a failed prefix with later rows without a sequence hole", async () => { + const store = new RecordingStore(); + store.failuresRemaining = 1; + const buffer = new DurableTimelineBuffer(store, 25, timers, () => undefined); + + buffer.enqueue("agent", row(1)); + await expect(buffer.flush("agent")).rejects.toThrow("disk full"); + buffer.enqueue("agent", row(2)); + await buffer.flush("agent"); + + const batchSequences = store.batches.map(batchSequencesFor); + expect(batchSequences).toEqual([[1], [1, 2]]); + }); + + test("coalesces a burst into one bounded atomic write and flush drains it", async () => { + const store = new RecordingStore(); + const buffer = new DurableTimelineBuffer(store, 25, timers, () => undefined); + + for (let seq = 1; seq <= 50; seq += 1) buffer.enqueue("agent", row(seq)); + await buffer.flushAll(); + + expect(store.batches).toHaveLength(1); + expect(store.batches[0]?.map((entry) => entry.seq)).toEqual( + Array.from({ length: 50 }, (_, index) => index + 1), + ); + }); + + test("discard drops a pending batch so a later global flush cannot resurrect it", async () => { + const store = new RecordingStore(); + const buffer = new DurableTimelineBuffer(store, 25, timers, () => undefined); + + buffer.enqueue("agent", row(1)); + await buffer.discard("agent"); + await buffer.flushAll(); + buffer.enqueue("agent", row(1)); + await buffer.flushAll(); + + expect(store.batches).toEqual([[row(1)]]); + }); + + test("retries a final failed batch without another enqueue", async () => { + const store = new RecordingStore(); + store.failuresRemaining = 1; + const controlledTimers = new ControlledTimers(); + const errors: unknown[] = []; + const buffer = new DurableTimelineBuffer( + store, + 25, + controlledTimers, + (_agentId, error) => errors.push(error), + { baseDelayMs: 10, maxDelayMs: 40 }, + ); + + buffer.enqueue("agent", row(1)); + await controlledTimers.runNext(); + expect(store.batches.map(batchSequencesFor)).toEqual([[1]]); + expect(errors).toHaveLength(1); + expect(controlledTimers.delays).toEqual([25, 10]); + + await controlledTimers.runNext(); + expect(store.batches.map(batchSequencesFor)).toEqual([[1], [1]]); + expect(controlledTimers.size).toBe(0); + }); + + test("grows retry delays to a deterministic cap", async () => { + const store = new RecordingStore(); + store.failuresRemaining = 4; + const controlledTimers = new ControlledTimers(); + const buffer = new DurableTimelineBuffer(store, 25, controlledTimers, () => undefined, { + baseDelayMs: 10, + maxDelayMs: 40, + }); + + buffer.enqueue("agent", row(1)); + await controlledTimers.runNext(); + await controlledTimers.runNext(); + await controlledTimers.runNext(); + await controlledTimers.runNext(); + await controlledTimers.runNext(); + + expect(controlledTimers.delays).toEqual([25, 10, 20, 40, 40]); + expect(store.batches).toHaveLength(5); + }); + + test("joins a later enqueue behind the retained failed prefix", async () => { + const store = new RecordingStore(); + store.failuresRemaining = 1; + const controlledTimers = new ControlledTimers(); + const buffer = new DurableTimelineBuffer(store, 25, controlledTimers, () => undefined, { + baseDelayMs: 10, + }); + + buffer.enqueue("agent", row(1)); + await controlledTimers.runNext(); + buffer.enqueue("agent", row(2)); + await controlledTimers.runNext(); + + expect(store.batches.map(batchSequencesFor)).toEqual([[1], [1, 2]]); + }); + + test("discard cancels a retry after failure", async () => { + const store = new RecordingStore(); + store.failuresRemaining = 1; + const controlledTimers = new ControlledTimers(); + const buffer = new DurableTimelineBuffer(store, 25, controlledTimers, () => undefined, { + baseDelayMs: 10, + }); + + buffer.enqueue("agent", row(1)); + await controlledTimers.runNext(); + await buffer.discard("agent"); + + expect(controlledTimers.size).toBe(0); + expect(store.batches.map(batchSequencesFor)).toEqual([[1]]); + }); + + test("manual flush rejects while retaining an autonomous retry", async () => { + const store = new RecordingStore(); + store.failuresRemaining = 1; + const controlledTimers = new ControlledTimers(); + const buffer = new DurableTimelineBuffer(store, 25, controlledTimers, () => undefined, { + baseDelayMs: 10, + }); + + buffer.enqueue("agent", row(1)); + await expect(buffer.flush("agent")).rejects.toThrow("disk full"); + expect(controlledTimers.delays).toEqual([25, 10]); + + await controlledTimers.runNext(); + expect(store.batches.map(batchSequencesFor)).toEqual([[1], [1]]); + }); +}); + +function batchSequencesFor(batch: AgentTimelineRow[]): number[] { + return batch.map((entry) => entry.seq); +} diff --git a/packages/server/src/server/agent/durable-timeline-buffer.ts b/packages/server/src/server/agent/durable-timeline-buffer.ts new file mode 100644 index 000000000..31642ec22 --- /dev/null +++ b/packages/server/src/server/agent/durable-timeline-buffer.ts @@ -0,0 +1,118 @@ +import type { AgentTimelineRow, AgentTimelineStore } from "./agent-timeline-store-types.js"; + +export interface DurableTimelineTimers { + setTimeout(callback: () => void, delayMs: number): ReturnType; + clearTimeout(timer: ReturnType): void; +} + +export interface DurableTimelineRetryOptions { + baseDelayMs?: number; + maxDelayMs?: number; +} + +const DEFAULT_RETRY_MAX_DELAY_MS = 5_000; + +interface PendingAgentRows { + rows: AgentTimelineRow[]; + timer: ReturnType | null; + flushing: Promise | null; + retryAttempt: number; +} + +/** Keeps each agent's durable transcript contiguous while coalescing live writes. */ +export class DurableTimelineBuffer { + private readonly pending = new Map(); + + constructor( + private readonly store: AgentTimelineStore, + private readonly delayMs: number, + private readonly timers: DurableTimelineTimers, + private readonly onError: (agentId: string, error: unknown) => void, + private readonly retryOptions: DurableTimelineRetryOptions = {}, + ) {} + + enqueue(agentId: string, row: AgentTimelineRow): void { + const state = this.stateFor(agentId); + state.rows.push(row); + this.schedule(agentId, state, this.delayMs); + } + + async flush(agentId: string): Promise { + const state = this.pending.get(agentId); + if (!state) return; + if (state.timer !== null) { + this.timers.clearTimeout(state.timer); + state.timer = null; + } + if (state.flushing) { + await state.flushing; + return this.flush(agentId); + } + if (state.rows.length === 0) { + this.pending.delete(agentId); + return; + } + const batch = [...state.rows]; + state.flushing = this.store.bulkInsert(agentId, batch); + try { + await state.flushing; + state.rows.splice(0, batch.length); + state.retryAttempt = 0; + } catch (error) { + this.scheduleRetry(agentId, state); + throw error; + } finally { + state.flushing = null; + if (state.rows.length === 0 && state.timer === null) { + this.pending.delete(agentId); + } else if (state.rows.length > 0 && state.timer === null) { + this.schedule(agentId, state, this.delayMs); + } + } + } + + async flushAll(): Promise { + await Promise.all([...this.pending.keys()].map((agentId) => this.flush(agentId))); + } + + async discard(agentId: string): Promise { + const state = this.pending.get(agentId); + if (!state) return; + if (state.timer !== null) this.timers.clearTimeout(state.timer); + state.timer = null; + state.rows.length = 0; + await state.flushing?.catch(() => undefined); + this.pending.delete(agentId); + } + + private stateFor(agentId: string): PendingAgentRows { + let state = this.pending.get(agentId); + if (!state) { + state = { rows: [], timer: null, flushing: null, retryAttempt: 0 }; + this.pending.set(agentId, state); + } + return state; + } + + private schedule(agentId: string, state: PendingAgentRows, delayMs: number): void { + if (state.timer !== null || state.rows.length === 0) return; + state.timer = this.timers.setTimeout(() => { + state.timer = null; + void this.flush(agentId).catch((error) => this.onError(agentId, error)); + }, delayMs); + this.unref(state.timer); + } + + private scheduleRetry(agentId: string, state: PendingAgentRows): void { + const baseDelayMs = this.retryOptions.baseDelayMs ?? this.delayMs; + const maxDelayMs = this.retryOptions.maxDelayMs ?? DEFAULT_RETRY_MAX_DELAY_MS; + const delayMs = Math.min(baseDelayMs * 2 ** state.retryAttempt, maxDelayMs); + state.retryAttempt += 1; + this.schedule(agentId, state, delayMs); + } + + private unref(timer: ReturnType): void { + const candidate = timer as unknown as { unref?: () => void }; + candidate.unref?.(); + } +} diff --git a/packages/server/src/server/agent/file-agent-timeline-store.test.ts b/packages/server/src/server/agent/file-agent-timeline-store.test.ts new file mode 100644 index 000000000..4001e8d45 --- /dev/null +++ b/packages/server/src/server/agent/file-agent-timeline-store.test.ts @@ -0,0 +1,226 @@ +import { mkdtemp, readdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { writeJsonFileAtomic } from "../atomic-file.js"; +import { createTestLogger } from "../../test-utils/test-logger.js"; +import { AgentStorage } from "./agent-storage.js"; +import { FileAgentTimelineStore } from "./file-agent-timeline-store.js"; +import type { AgentTimelineRow } from "./agent-timeline-store-types.js"; + +const user = (text: string) => ({ type: "user_message" as const, text }); + +async function storeDirectory(): Promise { + return await mkdtemp(path.join(tmpdir(), "paseo-timeline-")); +} + +function filePath(directory: string, agentId: string): string { + return path.join(directory, `agent-${Buffer.from(agentId, "utf8").toString("base64url")}.json`); +} + +function document(rows: AgentTimelineRow[], nextSeq: number) { + return { version: 1, epoch: "epoch-1", nextSeq, rows }; +} + +describe("FileAgentTimelineStore", () => { + it("loads a missing agent file as an empty timeline", async () => { + const store = new FileAgentTimelineStore(await storeDirectory()); + expect(await store.getCommittedRows("agent")).toEqual([]); + }); + + it("loads legacy rows without turnId", async () => { + const directory = await storeDirectory(); + await writeJsonFileAtomic( + filePath(directory, "agent"), + document([{ seq: 1, timestamp: "2026-01-01T00:00:00.000Z", item: user("old") }], 2), + ); + + expect( + (await new FileAgentTimelineStore(directory).getCommittedRows("agent"))[0]?.turnId, + ).toBeUndefined(); + }); + + it("treats existing rows as incomplete until an atomic history snapshot commits", async () => { + const directory = await storeDirectory(); + const store = new FileAgentTimelineStore(directory); + const row = { seq: 1, timestamp: "2026-01-01T00:00:00.000Z", item: user("old") }; + + await store.bulkInsert("agent", [row]); + expect((await store.getCommittedSnapshot("agent")).historyComplete).toBe(false); + + await store.replaceCommittedSnapshot("agent", { rows: [row], historyComplete: true }); + expect(await new FileAgentTimelineStore(directory).getCommittedSnapshot("agent")).toEqual({ + rows: [row], + historyComplete: true, + }); + }); + + it("round-trips supplied canonical rows without changing sequence or identity", async () => { + const directory = await storeDirectory(); + const row: AgentTimelineRow = { + seq: 7, + timestamp: "2026-01-01T00:00:00.000Z", + item: { type: "user_message", text: "hello", clientMessageId: "hello-client" }, + turnId: "turn-1", + providerMessageId: "provider-hello", + }; + + await new FileAgentTimelineStore(directory).bulkInsert("agent", [row]); + const reopened = new FileAgentTimelineStore(directory); + expect(await reopened.getCommittedRows("agent")).toEqual([row]); + expect((await reopened.fetchCommitted("agent", { limit: 0 })).window.nextSeq).toBe(8); + }); + + it("treats duplicate supplied rows as idempotent and rejects conflicting sequence reuse", async () => { + const directory = await storeDirectory(); + let writeCount = 0; + const store = new FileAgentTimelineStore(directory, { + writeJson: async (file, value) => { + writeCount += 1; + await writeJsonFileAtomic(file, value); + }, + }); + const row: AgentTimelineRow = { + seq: 3, + timestamp: "2026-01-01T00:00:00.000Z", + item: user("hello"), + providerMessageId: "provider-hello", + turnId: "turn-1", + }; + + await store.bulkInsert("agent", [row]); + await store.bulkInsert("agent", [row]); + expect(writeCount).toBe(1); + await expect(store.bulkInsert("agent", [{ ...row, item: user("different") }])).rejects.toThrow( + "Conflicting timeline row sequence 3", + ); + expect(await store.getCommittedRows("agent")).toEqual([row]); + }); + + it("serializes concurrent mutations for one agent without losing rows", async () => { + const store = new FileAgentTimelineStore(await storeDirectory()); + await Promise.all([ + store.appendCommitted("agent", user("one")), + store.appendCommitted("agent", user("two")), + ]); + expect((await store.getCommittedRows("agent")).map((row) => row.seq)).toEqual([1, 2]); + }); + + it("updates an existing row exactly and rejects a missing row sequence", async () => { + const store = new FileAgentTimelineStore(await storeDirectory()); + await store.bulkInsert("agent", [ + { seq: 1, timestamp: "2026-01-01T00:00:00.000Z", item: user("before") }, + ]); + const replacement: AgentTimelineRow = { + seq: 1, + timestamp: "2026-01-01T00:00:01.000Z", + item: user("after"), + turnId: "turn-1", + providerMessageId: "provider-1", + }; + + await store.updateCommittedRow("agent", replacement); + expect(await store.getCommittedRows("agent")).toEqual([replacement]); + await expect(store.updateCommittedRow("agent", { ...replacement, seq: 2 })).rejects.toThrow( + "Cannot update missing timeline row sequence 2", + ); + }); + + it("recovers an agent mutation lane after a rejected write without committing the failed row", async () => { + const directory = await storeDirectory(); + let writeCount = 0; + const store = new FileAgentTimelineStore(directory, { + writeJson: async (file, value) => { + writeCount += 1; + if (writeCount === 1) throw new Error("disk full"); + await writeJsonFileAtomic(file, value); + }, + }); + + await expect(store.appendCommitted("agent", user("lost"))).rejects.toThrow("disk full"); + expect(await store.getCommittedRows("agent")).toEqual([]); + await expect(store.appendCommitted("agent", user("saved"))).resolves.toMatchObject({ seq: 1 }); + expect(await store.getCommittedRows("agent")).toEqual([ + expect.objectContaining({ seq: 1, item: user("saved") }), + ]); + }); + + it("isolates corrupt files and failed mutation lanes to their own agent", async () => { + const directory = await storeDirectory(); + await writeFile(filePath(directory, "broken"), "not json"); + const store = new FileAgentTimelineStore(directory, { + writeJson: async (file, value) => { + if (file === filePath(directory, "failing")) throw new Error("failing agent write"); + await writeJsonFileAtomic(file, value); + }, + }); + + await expect(store.getCommittedRows("broken")).rejects.toThrow(); + await expect(store.appendCommitted("valid", user("works"))).resolves.toMatchObject({ seq: 1 }); + await expect(store.appendCommitted("failing", user("fails"))).rejects.toThrow( + "failing agent write", + ); + await expect(store.appendCommitted("valid", user("still works"))).resolves.toMatchObject({ + seq: 2, + }); + }); + + it("does not serialize agent B behind a held agent A write", async () => { + const directory = await storeDirectory(); + let releaseA!: () => void; + const aWrite = new Promise((resolve) => { + releaseA = resolve; + }); + const store = new FileAgentTimelineStore(directory, { + writeJson: async (file, value) => { + if (file === filePath(directory, "agent-a")) await aWrite; + await writeJsonFileAtomic(file, value); + }, + }); + + const appendA = store.appendCommitted("agent-a", user("held")); + await expect(store.appendCommitted("agent-b", user("independent"))).resolves.toMatchObject({ + seq: 1, + }); + releaseA(); + await expect(appendA).resolves.toMatchObject({ seq: 1 }); + }); + + it.each([ + { + name: "invalid timeline item", + value: document([{ seq: 1, timestamp: "now", item: { type: "not-a-timeline-item" } }], 2), + }, + { + name: "out-of-order rows", + value: document( + [ + { seq: 2, timestamp: "now", item: user("two") }, + { seq: 1, timestamp: "now", item: user("one") }, + ], + 3, + ), + }, + { + name: "next sequence at the committed maximum", + value: document([{ seq: 1, timestamp: "now", item: user("one") }], 1), + }, + ])("rejects persisted $name", async ({ value }) => { + const directory = await storeDirectory(); + await writeJsonFileAtomic(filePath(directory, "agent"), value); + await expect(new FileAgentTimelineStore(directory).getCommittedRows("agent")).rejects.toThrow(); + }); + + it("writes outside AgentStorage discovery", async () => { + const root = await storeDirectory(); + const agentStorageDirectory = path.join(root, "agents"); + const timelineDirectory = path.join(root, "timelines"); + const store = new FileAgentTimelineStore(timelineDirectory); + + await store.appendCommitted("agent", user("hello")); + + expect(await readdir(timelineDirectory)).toEqual(["agent-YWdlbnQ.json"]); + expect(await new AgentStorage(agentStorageDirectory, createTestLogger()).list()).toEqual([]); + }); +}); diff --git a/packages/server/src/server/agent/file-agent-timeline-store.ts b/packages/server/src/server/agent/file-agent-timeline-store.ts new file mode 100644 index 000000000..2abafc4de --- /dev/null +++ b/packages/server/src/server/agent/file-agent-timeline-store.ts @@ -0,0 +1,274 @@ +import { randomUUID } from "node:crypto"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; +import { z } from "zod"; + +import { writeJsonFileAtomic } from "../atomic-file.js"; +import type { AgentTimelineItem } from "./agent-sdk-types.js"; +import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js"; +import { AgentTimelineItemPayloadSchema } from "@getpaseo/protocol/messages"; +import type { + AgentTimelineFetchOptions, + AgentTimelineFetchResult, + AgentTimelineRow, + AgentTimelineSnapshot, + AgentTimelineStore, +} from "./agent-timeline-store-types.js"; + +const TimelineRowSchema: z.ZodType = z.object({ + seq: z.number().int().positive(), + timestamp: z.string(), + item: AgentTimelineItemPayloadSchema, + turnId: z.string().optional(), + providerMessageId: z.string().optional(), +}); + +const TimelineDocumentSchema = z.object({ + version: z.literal(1), + epoch: z.string(), + nextSeq: z.number().int().positive(), + rows: z.array(TimelineRowSchema), + // Documents written before history completeness existed are intentionally incomplete. + historyComplete: z.boolean().optional().default(false), +}); + +type TimelineDocument = z.infer; + +export interface FileAgentTimelineStoreOptions { + writeJson?: (filePath: string, value: unknown) => Promise; +} + +function cloneRow(row: AgentTimelineRow): AgentTimelineRow { + return { ...row, item: structuredClone(row.item) }; +} + +function cloneDocument(document: TimelineDocument): TimelineDocument { + return { ...document, rows: document.rows.map(cloneRow) }; +} + +function validateDocument(value: unknown): TimelineDocument { + const document = TimelineDocumentSchema.parse(value); + let previousSeq = 0; + for (const row of document.rows) { + if (row.seq <= previousSeq) { + throw new Error("Timeline rows must have strictly increasing sequence numbers"); + } + previousSeq = row.seq; + } + if (document.nextSeq <= previousSeq) { + throw new Error("Timeline nextSeq must be greater than every row sequence number"); + } + return document; +} + +function emptyDocument(): TimelineDocument { + return { version: 1, epoch: randomUUID(), nextSeq: 1, rows: [], historyComplete: false }; +} + +function fileNameForAgent(agentId: string): string { + return `agent-${Buffer.from(agentId, "utf8").toString("base64url")}.json`; +} + +function selectRows( + document: TimelineDocument, + options?: AgentTimelineFetchOptions, +): AgentTimelineFetchResult { + const memory = new InMemoryAgentTimelineStore(); + memory.initialize("timeline", document); + return memory.fetch("timeline", options); +} + +/** Durable canonical timeline rows, isolated as one atomic document per agent. */ +export class FileAgentTimelineStore implements AgentTimelineStore { + private readonly documents = new Map(); + private readonly loading = new Map>(); + private readonly mutationTails = new Map>(); + private readonly writeJson: (filePath: string, value: unknown) => Promise; + + constructor( + private readonly directory: string, + options?: FileAgentTimelineStoreOptions, + ) { + this.writeJson = options?.writeJson ?? writeJsonFileAtomic; + } + + async appendCommitted( + agentId: string, + item: AgentTimelineItem, + options?: { timestamp?: string; turnId?: string }, + ): Promise { + return this.mutate(agentId, (document) => { + const row: AgentTimelineRow = { + seq: document.nextSeq, + timestamp: options?.timestamp ?? new Date().toISOString(), + item: structuredClone(item), + ...(options?.turnId ? { turnId: options.turnId } : {}), + }; + document.rows.push(row); + document.nextSeq += 1; + return cloneRow(row); + }); + } + + async fetchCommitted( + agentId: string, + options?: AgentTimelineFetchOptions, + ): Promise { + return selectRows(await this.getDocument(agentId), options); + } + + async getLatestCommittedSeq(agentId: string): Promise { + return (await this.getDocument(agentId)).rows.at(-1)?.seq ?? 0; + } + + async getCommittedRows(agentId: string): Promise { + return (await this.getDocument(agentId)).rows.map(cloneRow); + } + + async getCommittedSnapshot(agentId: string): Promise { + const document = await this.getDocument(agentId); + return { rows: document.rows.map(cloneRow), historyComplete: document.historyComplete }; + } + + async getLastItem(agentId: string): Promise { + const item = (await this.getDocument(agentId)).rows.at(-1)?.item; + return item ? structuredClone(item) : null; + } + + async getLastAssistantMessage(agentId: string): Promise { + const rows = (await this.getDocument(agentId)).rows; + const chunks: string[] = []; + for (let index = rows.length - 1; index >= 0; index -= 1) { + const item = rows[index]!.item; + if (item.type !== "assistant_message") { + if (chunks.length > 0) break; + continue; + } + chunks.push(item.text); + } + return chunks.length > 0 ? chunks.toReversed().join("") : null; + } + + async deleteAgent(agentId: string): Promise { + await this.runMutation(agentId, async () => { + await fs.rm(this.filePath(agentId), { force: true }); + this.documents.delete(agentId); + }); + } + + async bulkInsert(agentId: string, rows: readonly AgentTimelineRow[]): Promise { + await this.runMutation(agentId, async () => { + const current = await this.getDocument(agentId); + const proposed = cloneDocument(current); + let changed = false; + for (const row of rows) { + const parsed = TimelineRowSchema.parse(row); + const existing = proposed.rows.find((candidate) => candidate.seq === parsed.seq); + if (existing) { + if (!isDeepStrictEqual(existing, parsed)) { + throw new Error(`Conflicting timeline row sequence ${parsed.seq}`); + } + continue; + } + proposed.rows.push(cloneRow(parsed)); + changed = true; + } + if (!changed) return; + proposed.rows.sort((left, right) => left.seq - right.seq); + const maxSeq = proposed.rows.at(-1)?.seq ?? 0; + proposed.nextSeq = Math.max(proposed.nextSeq, maxSeq + 1); + validateDocument(proposed); + await this.writeJson(this.filePath(agentId), proposed); + this.documents.set(agentId, proposed); + }); + } + + async replaceCommittedSnapshot(agentId: string, snapshot: AgentTimelineSnapshot): Promise { + await this.runMutation(agentId, async () => { + const current = await this.getDocument(agentId); + const rows = snapshot.rows.map((row) => cloneRow(TimelineRowSchema.parse(row))); + const nextSeq = (rows.at(-1)?.seq ?? 0) + 1; + const proposed: TimelineDocument = { + version: 1, + epoch: current.epoch, + nextSeq, + rows, + historyComplete: snapshot.historyComplete, + }; + validateDocument(proposed); + await this.writeJson(this.filePath(agentId), proposed); + this.documents.set(agentId, proposed); + }); + } + + async updateCommittedRow(agentId: string, row: AgentTimelineRow): Promise { + await this.mutate(agentId, (document) => { + const parsed = TimelineRowSchema.parse(row); + const index = document.rows.findIndex((candidate) => candidate.seq === parsed.seq); + if (index < 0) { + throw new Error(`Cannot update missing timeline row sequence ${parsed.seq}`); + } + document.rows[index] = cloneRow(parsed); + }); + } + + private async mutate( + agentId: string, + mutation: (document: TimelineDocument) => T, + ): Promise { + return this.runMutation(agentId, async () => { + const current = await this.getDocument(agentId); + const proposed = cloneDocument(current); + const result = mutation(proposed); + validateDocument(proposed); + await this.writeJson(this.filePath(agentId), proposed); + this.documents.set(agentId, proposed); + return result; + }); + } + + private async getDocument(agentId: string): Promise { + const cached = this.documents.get(agentId); + if (cached) return cached; + let load = this.loading.get(agentId); + if (!load) { + load = this.loadDocument(agentId); + this.loading.set(agentId, load); + } + try { + const document = await load; + this.documents.set(agentId, document); + return document; + } finally { + if (this.loading.get(agentId) === load) this.loading.delete(agentId); + } + } + + private async loadDocument(agentId: string): Promise { + try { + return validateDocument(JSON.parse(await fs.readFile(this.filePath(agentId), "utf8"))); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return emptyDocument(); + throw error; + } + } + + private async runMutation(agentId: string, operation: () => Promise): Promise { + const previous = this.mutationTails.get(agentId) ?? Promise.resolve(); + const result = previous.catch(() => undefined).then(operation); + const tail = result.then( + () => undefined, + () => undefined, + ); + this.mutationTails.set(agentId, tail); + void tail.finally(() => { + if (this.mutationTails.get(agentId) === tail) this.mutationTails.delete(agentId); + }); + return result; + } + + private filePath(agentId: string): string { + return path.join(this.directory, fileNameForAgent(agentId)); + } +} diff --git a/packages/server/src/server/agent/history-reconciliation.test.ts b/packages/server/src/server/agent/history-reconciliation.test.ts new file mode 100644 index 000000000..8b14315c2 --- /dev/null +++ b/packages/server/src/server/agent/history-reconciliation.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from "vitest"; + +import { reconcileProviderHistory } from "./history-reconciliation.js"; + +const user = (text: string, id?: string) => ({ + type: "user_message" as const, + text, + ...(id ? { clientMessageId: id } : {}), +}); + +describe("reconcileProviderHistory", () => { + test("puts missing provider prefix before a newer durable suffix while preserving suffix metadata", () => { + const rows = reconcileProviderHistory( + [ + { + seq: 2, + timestamp: "2026-01-02T00:00:00.000Z", + item: user("suffix", "suffix"), + turnId: "turn-1", + providerMessageId: "p-suffix", + }, + ], + [ + { item: user("prefix"), timestamp: "2026-01-01T00:00:00.000Z" }, + { item: user("suffix"), timestamp: "2026-01-02T00:00:00.000Z" }, + ], + ); + expect(rows).toMatchObject([ + { seq: 1, item: { text: "prefix" } }, + { + seq: 2, + turnId: "turn-1", + providerMessageId: "p-suffix", + item: { text: "suffix", clientMessageId: "suffix" }, + }, + ]); + }); + + test("pairs repeated identical user text by ordered occurrence", () => { + const rows = reconcileProviderHistory( + [ + { + seq: 1, + timestamp: "2026-01-01T00:00:00.000Z", + item: user("same", "one"), + turnId: "turn-1", + }, + { + seq: 2, + timestamp: "2026-01-02T00:00:00.000Z", + item: user("same", "two"), + turnId: "turn-2", + }, + ], + [{ item: user("same") }, { item: user("same") }], + ); + expect( + rows.map((row) => [ + row.item.type === "user_message" ? row.item.clientMessageId : null, + row.turnId, + ]), + ).toEqual([ + ["one", "turn-1"], + ["two", "turn-2"], + ]); + }); + + test("retains a canonical suffix when provider history is lagging", () => { + const rows = reconcileProviderHistory( + [ + { + seq: 1, + timestamp: "2026-01-01T00:00:00.000Z", + item: user("initial", "initial"), + turnId: "turn-1", + }, + { + seq: 2, + timestamp: "2026-01-02T00:00:00.000Z", + item: user("hello", "hello"), + turnId: "turn-1", + }, + ], + [{ item: user("initial") }], + ); + + expect(rows).toMatchObject([ + { seq: 1, item: { text: "initial", clientMessageId: "initial" }, turnId: "turn-1" }, + { seq: 2, item: { text: "hello", clientMessageId: "hello" }, turnId: "turn-1" }, + ]); + }); + + test("does not transfer provider identity between ambiguous repeated prompts", () => { + const rows = reconcileProviderHistory( + [ + { + seq: 1, + timestamp: "2026-01-01T00:00:00.000Z", + item: user("same", "one"), + turnId: "turn-1", + }, + { + seq: 2, + timestamp: "2026-01-02T00:00:00.000Z", + item: user("same", "two"), + turnId: "turn-2", + }, + ], + [{ item: { type: "user_message", text: "same", messageId: "provider-two" } }], + ); + + expect(rows).toMatchObject([ + { item: { clientMessageId: "one" }, turnId: "turn-1" }, + { item: { clientMessageId: "two" }, turnId: "turn-2" }, + ]); + expect(rows.some((row) => row.providerMessageId === "provider-two")).toBe(false); + }); + + test("does not invent turn membership for provider-only rows", () => { + expect( + reconcileProviderHistory([], [{ item: { type: "assistant_message", text: "provider" } }])[0] + ?.turnId, + ).toBeUndefined(); + }); + + test("clears unmatched rows for an authoritative forced history", () => { + expect( + reconcileProviderHistory( + [{ seq: 1, timestamp: "now", item: user("old", "old"), turnId: "turn-1" }], + [], + { mode: "force" }, + ), + ).toEqual([]); + }); +}); diff --git a/packages/server/src/server/agent/history-reconciliation.ts b/packages/server/src/server/agent/history-reconciliation.ts new file mode 100644 index 000000000..327bc487f --- /dev/null +++ b/packages/server/src/server/agent/history-reconciliation.ts @@ -0,0 +1,157 @@ +import { isDeepStrictEqual } from "node:util"; + +import type { AgentTimelineItem } from "./agent-sdk-types.js"; +import type { AgentTimelineRow } from "./agent-timeline-store-types.js"; + +export interface ProviderHistoryTimelineEntry { + item: AgentTimelineItem; + timestamp?: string; +} + +/** Reconciles canonical metadata onto provider-ordered history without inventing membership. */ +export function reconcileProviderHistory( + canonicalRows: readonly AgentTimelineRow[], + providerEntries: readonly ProviderHistoryTimelineEntry[], + options?: { mode?: "incomplete" | "force" }, +): AgentTimelineRow[] { + if (providerEntries.length === 0) { + return options?.mode === "force" + ? [] + : canonicalRows.map((row, index) => ({ ...row, seq: index + 1 })); + } + const remaining = canonicalRows.map((row, canonicalIndex) => ({ + row, + canonicalIndex, + used: false, + })); + const structuralCounts = countStructuralOccurrences(canonicalRows, providerEntries); + const providerRows = providerEntries.map((entry) => { + const match = takeMatch(remaining, entry.item, structuralCounts); + return { entry, match }; + }); + const rows: AgentTimelineRow[] = []; + const emittedCanonicalIndexes = new Set(); + + for (const { entry, match } of providerRows) { + if (match) { + for (const candidate of remaining) { + if (candidate.canonicalIndex >= match.canonicalIndex) break; + if (!emittedCanonicalIndexes.has(candidate.canonicalIndex)) { + rows.push({ ...candidate.row }); + emittedCanonicalIndexes.add(candidate.canonicalIndex); + } + } + rows.push( + match.transferProviderIdentity ? mergeMatchedRow(match.row, entry) : { ...match.row }, + ); + emittedCanonicalIndexes.add(match.canonicalIndex); + continue; + } + rows.push({ + seq: 0, + timestamp: entry.timestamp ?? new Date(0).toISOString(), + item: entry.item, + }); + } + + if (options?.mode !== "force") { + for (const candidate of remaining) { + if (!emittedCanonicalIndexes.has(candidate.canonicalIndex)) { + rows.push({ ...candidate.row }); + } + } + } + rows.forEach((row, index) => { + row.seq = index + 1; + }); + return rows; +} + +function takeMatch( + remaining: Array<{ row: AgentTimelineRow; canonicalIndex: number; used: boolean }>, + provider: AgentTimelineItem, + structuralCounts: Map, +): { row: AgentTimelineRow; canonicalIndex: number; transferProviderIdentity: boolean } | null { + const strong = remaining.find( + (candidate) => !candidate.used && hasSharedIdentity(candidate.row, provider), + ); + const structural = + strong ?? + remaining.find( + (candidate) => !candidate.used && structurallyMatches(candidate.row.item, provider), + ); + if (!structural) return null; + structural.used = true; + const key = structuralKey(provider); + const counts = structuralCounts.get(key); + return { + row: structural.row, + canonicalIndex: structural.canonicalIndex, + transferProviderIdentity: + strong !== undefined || (counts?.canonical === 1 && counts.provider === 1), + }; +} + +function mergeMatchedRow( + canonical: AgentTimelineRow, + provider: ProviderHistoryTimelineEntry, +): AgentTimelineRow { + return { + ...canonical, + item: mergeCanonicalIdentity(canonical.item, provider.item), + }; +} + +function countStructuralOccurrences( + canonicalRows: readonly AgentTimelineRow[], + providerEntries: readonly ProviderHistoryTimelineEntry[], +): Map { + const counts = new Map(); + for (const row of canonicalRows) { + const key = structuralKey(row.item); + const count = counts.get(key) ?? { canonical: 0, provider: 0 }; + count.canonical += 1; + counts.set(key, count); + } + for (const entry of providerEntries) { + const key = structuralKey(entry.item); + const count = counts.get(key) ?? { canonical: 0, provider: 0 }; + count.provider += 1; + counts.set(key, count); + } + return counts; +} + +function structuralKey(item: AgentTimelineItem): string { + return item.type === "user_message" + ? `user:${item.text}` + : `${item.type}:${JSON.stringify(item)}`; +} + +function hasSharedIdentity(row: AgentTimelineRow, provider: AgentTimelineItem): boolean { + if (row.item.type !== "user_message" || provider.type !== "user_message") return false; + const identities = [row.item.clientMessageId, row.item.messageId, row.providerMessageId].filter( + Boolean, + ); + return identities.some( + (identity) => identity === provider.clientMessageId || identity === provider.messageId, + ); +} + +function structurallyMatches(left: AgentTimelineItem, right: AgentTimelineItem): boolean { + if (left.type === "user_message" && right.type === "user_message") + return left.text === right.text; + return isDeepStrictEqual(left, right); +} + +function mergeCanonicalIdentity( + canonical: AgentTimelineItem, + provider: AgentTimelineItem, +): AgentTimelineItem { + if (canonical.type !== "user_message" || provider.type !== "user_message") return provider; + return { + ...provider, + ...(canonical.clientMessageId ? { clientMessageId: canonical.clientMessageId } : {}), + ...(canonical.messageId ? { messageId: canonical.messageId } : {}), + }; +} diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 72652bee4..95ae40f09 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -829,6 +829,54 @@ describe("ClaudeAgentSession features", () => { await session.close(); }); + test("pushes a steer into the exact active Claude query without starting another turn", async () => { + const { queryFactory } = createQueryMock(); + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ provider: "claude", cwd: process.cwd() }); + const events: AgentStreamEvent[] = []; + const unsubscribe = session.subscribe((event) => events.push(event)); + try { + const { turnId } = await session.startTurn("first turn"); + const input = queryFactory.mock.calls[0]?.[0].prompt as AsyncIterable; + const iterator = input[Symbol.asyncIterator](); + await expect(iterator.next()).resolves.toMatchObject({ value: { type: "user" } }); + + await expect( + session.steerActiveTurn?.("same turn follow-up", { + expectedTurnId: turnId, + clientMessageId: "steer-client-id", + }), + ).resolves.toEqual({ status: "accepted" }); + await expect(iterator.next()).resolves.toMatchObject({ + value: { + type: "user", + priority: "next", + message: { content: [{ type: "text", text: "same turn follow-up" }] }, + }, + }); + expect(events.filter((event) => event.type === "turn_started")).toHaveLength(1); + + await expect( + session.steerActiveTurn?.("/rewind submitted-message-id", { expectedTurnId: turnId }), + ).resolves.toEqual({ status: "unavailable" }); + let rewindReachedLiveInput = false; + void iterator.next().then(() => { + rewindReachedLiveInput = true; + return undefined; + }); + await Promise.resolve(); + await Promise.resolve(); + expect(rewindReachedLiveInput).toBe(false); + } finally { + unsubscribe(); + await session.close(); + } + }); + test.each([ ["supported model", "claude-opus-4-8", { type: "disabled" }, undefined], ["unsupported model", "claude-fable-5", { type: "adaptive" }, "high"], diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 22873f407..51797f264 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -110,6 +110,8 @@ import { type AgentSession, type AgentSessionConfig, type AgentSlashCommand, + type SteerActiveTurnOptions, + type SteerResult, type AgentStreamEvent, type AgentTimelineItem, type AgentUsage, @@ -2021,6 +2023,9 @@ class ClaudeAgentSession implements AgentSession { private query: Query | null = null; private childProcess: ChildProcess | null = null; private input: AsyncMessageInput | null = null; + /** The exact SDK query/input pair that owns the current foreground turn. */ + private activeForegroundQuery: Query | null = null; + private activeForegroundInput: AsyncMessageInput | null = null; private claudeSessionId: string | null; private persistence: AgentPersistenceHandle | null; private currentMode: PermissionMode; @@ -2236,6 +2241,8 @@ class ClaudeAgentSession implements AgentSession { if (!this.input) { throw new Error("Claude session input stream not initialized"); } + this.activeForegroundQuery = this.query; + this.activeForegroundInput = this.input; this.startQueryPump(); this.input.push(sdkMessage); setTimeout(() => { @@ -2252,6 +2259,39 @@ class ClaudeAgentSession implements AgentSession { return { turnId }; } + async steerActiveTurn( + prompt: AgentPromptInput, + options: SteerActiveTurnOptions, + ): Promise { + if (this.resolveSlashCommandInvocation(prompt)) { + return { status: "unavailable" }; + } + if (this.compacting || this.activeForegroundTurnId !== options.expectedTurnId) { + return { status: "unavailable" }; + } + + // Capture both ends of the live SDK stream before creating or delivering the message. There + // is deliberately no await below: a finished A cannot make this input point at a later B. + const query = this.activeForegroundQuery; + const input = this.activeForegroundInput; + if (!query || !input || this.query !== query || this.input !== input) { + return { status: "unavailable" }; + } + const message = this.toSdkUserMessage(prompt); + message.priority = "next"; + if ( + this.activeForegroundTurnId !== options.expectedTurnId || + this.activeForegroundQuery !== query || + this.activeForegroundInput !== input || + this.query !== query || + this.input !== input + ) { + return { status: "unavailable" }; + } + input.push(message); + return { status: "accepted" }; + } + subscribe(callback: (event: AgentStreamEvent) => void): () => void { this.subscribers.add(callback); return () => { @@ -2527,6 +2567,8 @@ class ClaudeAgentSession implements AgentSession { this.cancelCurrentTurn?.(); this.subscribers.clear(); this.activeForegroundTurnId = null; + this.activeForegroundQuery = null; + this.activeForegroundInput = null; this.autonomousTurn = null; this.cancelCurrentTurn = null; this.turnState = "idle"; @@ -2640,7 +2682,10 @@ class ClaudeAgentSession implements AgentSession { if (!parsed) { return null; } - return parsed.commandName === REWIND_COMMAND_NAME ? parsed : null; + return parsed.commandName === REWIND_COMMAND_NAME || + CLAUDE_ROOT_ONLY_COMMANDS.has(parsed.commandName) + ? parsed + : null; } private parseSlashCommandInput(text: string): SlashCommandInvocation | null { @@ -3410,6 +3455,8 @@ class ClaudeAgentSession implements AgentSession { } this.notifySubscribers(event); this.activeForegroundTurnId = null; + this.activeForegroundQuery = null; + this.activeForegroundInput = null; this.cancelCurrentTurn = null; this.activeTurnHasAssistantText = false; this.syncTurnState("foreground turn terminal"); @@ -3425,6 +3472,8 @@ class ClaudeAgentSession implements AgentSession { if (terminalSeen) { if (this.activeForegroundTurnId) { this.activeForegroundTurnId = null; + this.activeForegroundQuery = null; + this.activeForegroundInput = null; this.cancelCurrentTurn = null; this.activeTurnHasAssistantText = false; this.syncTurnState("foreground turn terminal"); @@ -3800,6 +3849,8 @@ class ClaudeAgentSession implements AgentSession { this.queryRestartNeeded = false; this.autonomousTurn = null; this.activeForegroundTurnId = null; + this.activeForegroundQuery = null; + this.activeForegroundInput = null; this.syncTurnState("missing resumed conversation"); return true; } diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 7806c5148..a3cc15b54 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -46,6 +46,7 @@ describe("mapCodexPlanUpdateToTodo", () => { }); }); }); + import { CodexAppServerClient } from "./codex/app-server-transport.js"; import { createFakeCodexAppServer, @@ -144,6 +145,144 @@ function createProviderWithFakeAppServer(appServer: FakeCodexAppServer): CodexAp return provider; } +async function startPublicSteeringSession( + appServer: FakeCodexAppServer, + resolveSlashCommandInvocation?: (prompt: AgentPromptInput) => Promise<{ + commandName: string; + args?: string; + } | null>, +): Promise<{ session: AgentSession; paseoTurnId: string }> { + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + null, + createTestLogger(), + async () => appServer.child, + { resolveSlashCommandInvocation }, + ); + const started = await session.startTurn("first"); + await appServer.waitForTurnStart(); + appServer.startsTurn({ threadId: "thread-1", turnId: "native-A" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + return { session, paseoTurnId: started.turnId }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +describe("Codex active-turn steering admission", () => { + test("does not steer B when A completes while command resolution is pending", async () => { + const commandResolution = deferred<{ commandName: string } | null>(); + const resolverEntered = deferred(); + const appServer = createFakeCodexAppServer(); + const { session, paseoTurnId } = await startPublicSteeringSession(appServer, async (prompt) => { + if (prompt !== "/held") return null; + resolverEntered.resolve(); + return commandResolution.promise; + }); + + const steer = session.steerActiveTurn!("/held", { + expectedTurnId: paseoTurnId, + clientMessageId: "steer-A", + }); + await resolverEntered.promise; + appServer.completeTurn({ threadId: "thread-1" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const startedB = await session.startTurn("second"); + appServer.startsTurn({ threadId: "thread-1", turnId: "native-B" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + commandResolution.resolve(null); + + await expect(steer).resolves.toEqual({ status: "unavailable" }); + expect(startedB.turnId).not.toBe(paseoTurnId); + expect(appServer.requests().filter((request) => request.method === "turn/steer")).toEqual([]); + await session.close(); + appServer.assertNoErrors(); + }); + + test.each([ + ["method unavailable", -32601, "method not found", undefined, "unavailable"], + ["no active turn", -32600, "no active turn to steer", undefined, "unavailable"], + [ + "expected turn mismatch", + -32600, + "expected active turn id `native-A` but found `native-B`", + undefined, + "unavailable", + ], + [ + "output schema mismatch", + -32600, + "active turn uses a different output schema", + undefined, + "unavailable", + ], + [ + "review turn", + -32600, + "cannot steer a review turn", + { codexErrorInfo: { activeTurnNotSteerable: { turnKind: "review" } } }, + "unavailable", + ], + ["unknown invalid request", -32600, "input must not be empty", undefined, "throws"], + ] as const)("classifies JSON-RPC $0", async (_name, code, message, data, expected) => { + const appServer = createFakeCodexAppServer({ + "turn/steer": () => ({ __jsonRpcError: { code, message, ...(data ? { data } : {}) } }), + }); + const { session, paseoTurnId } = await startPublicSteeringSession(appServer); + const steer = session.steerActiveTurn!("follow up", { + expectedTurnId: paseoTurnId, + clientMessageId: "steer-frame", + }); + if (expected === "unavailable") { + await expect(steer).resolves.toEqual({ status: "unavailable" }); + } else { + await expect(steer).rejects.toThrow(message); + } + await session.close(); + appServer.assertNoErrors(); + }); + + test("leaves a JSON-RPC server error ambiguous", async () => { + const appServer = createFakeCodexAppServer({ + "turn/steer": () => ({ + __jsonRpcError: { code: -32000, message: "connection lost" }, + }), + }); + const { session, paseoTurnId } = await startPublicSteeringSession(appServer); + await expect( + session.steerActiveTurn!("follow up", { + expectedTurnId: paseoTurnId, + clientMessageId: "steer-transport", + }), + ).rejects.toThrow("connection lost"); + await session.close(); + }); + + test("rejects an in-flight steer when the app-server transport disconnects", async () => { + const appServer = createFakeCodexAppServer({ + "turn/steer": () => new Promise(() => undefined), + }); + const { session, paseoTurnId } = await startPublicSteeringSession(appServer); + const steer = session.steerActiveTurn!("follow up", { + expectedTurnId: paseoTurnId, + clientMessageId: "steer-disconnect", + }); + await appServer.waitForRequest("turn/steer"); + appServer.disconnect(); + + await expect(steer).rejects.toThrow("Codex app-server exited"); + expect(appServer.requests().filter((request) => request.method === "turn/steer")).toHaveLength( + 1, + ); + await session.close(); + }); +}); + async function startCompactionTurnTest(): Promise<{ appServer: FakeCodexAppServer; session: AgentSession; 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 1a5143430..18df2d8e5 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 @@ -22,6 +22,8 @@ import { type AgentRuntimeInfo, type AgentSession, type AgentSessionConfig, + type SteerActiveTurnOptions, + type SteerResult, type AgentSlashCommand, type AgentStreamEvent, type AgentTimelineItem, @@ -73,6 +75,7 @@ import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mappe import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js"; import { CodexAppServerClient, + CodexAppServerRpcError, parseCodexThreadForkResponse, parseCodexThreadRollbackResponse, type CodexThreadForkParams, @@ -261,6 +264,9 @@ interface CodexAppServerAgentDeps { logger: Logger, getTraceContext: () => CodexAppServerTraceContext, ) => CodexAppServerClientLike; + resolveSlashCommandInvocation?: ( + prompt: AgentPromptInput, + ) => Promise<{ commandName: string; args?: string } | null>; } interface CodexModePreset { @@ -833,6 +839,24 @@ function toObjectRecord(value: unknown): Record | undefined { return isRecord(value) ? value : undefined; } +function isDefinitiveCodexSteerRejection(error: unknown): boolean { + if (!(error instanceof CodexAppServerRpcError)) return false; + if (error.code === -32601) return true; + if (error.code !== -32600) return false; + + const data = toObjectRecord(error.data); + if (data && isRecord(toObjectRecord(data.codexErrorInfo)?.activeTurnNotSteerable)) return true; + + // These app-server invalid-request messages describe requests that reached + // Codex but could not have submitted input. Keep this exact: a generic + // invalid-request, timeout, disconnect, or unknown error is ambiguous. + return ( + error.message === "no active turn to steer" || + /^expected active turn id `[^`]+` but found `[^`]+`$/.test(error.message) || + error.message === "active turn uses a different output schema" + ); +} + // Codex app-server API response types interface CodexReasoningEffortEntry { reasoningEffort?: string; @@ -1603,10 +1627,14 @@ function mapCodexThreadUserMessageItem( } const text = extractUserText(normalizedItem.content) ?? ""; const messageId = nonEmptyString(normalizedItem.id); + const clientMessageId = nonEmptyString( + normalizedItem.clientId ?? normalizedItem.client_id ?? normalizedItem.clientUserMessageId, + ); return { type: "user_message", text, ...(messageId ? { messageId } : {}), + ...(clientMessageId ? { clientMessageId } : {}), }; } @@ -3808,6 +3836,9 @@ export class CodexAppServerAgentSession implements AgentSession { private async resolveSlashCommandInvocation( prompt: AgentPromptInput, ): Promise<{ commandName: string; args?: string } | null> { + if (this.deps.resolveSlashCommandInvocation) { + return this.deps.resolveSlashCommandInvocation(prompt); + } if (typeof prompt !== "string") { return null; } @@ -4096,6 +4127,63 @@ export class CodexAppServerAgentSession implements AgentSession { } } + async steerActiveTurn( + prompt: AgentPromptInput, + options: SteerActiveTurnOptions, + ): Promise { + const client = this.client; + const threadId = this.currentThreadId; + const nativeTurnId = this.currentTurnId; + const foregroundTurnId = this.activeForegroundTurnId; + if (!client || !threadId || !nativeTurnId || foregroundTurnId !== options.expectedTurnId) { + return { status: "unavailable" }; + } + if (await this.resolveSlashCommandInvocation(prompt)) return { status: "unavailable" }; + if (!this.matchesSteerAdmission({ client, threadId, nativeTurnId, foregroundTurnId })) { + return { status: "unavailable" }; + } + const input = await this.buildUserInput(prompt); + if (!this.matchesSteerAdmission({ client, threadId, nativeTurnId, foregroundTurnId })) { + return { status: "unavailable" }; + } + try { + const response = await client.request( + "turn/steer", + { + threadId, + expectedTurnId: nativeTurnId, + input, + ...(options.clientMessageId ? { clientUserMessageId: options.clientMessageId } : {}), + }, + TURN_START_TIMEOUT_MS, + ); + const record = toObjectRecord(response); + const turn = record ? toObjectRecord(record.turn) : null; + const acknowledgedTurnId = nonEmptyString(record?.turnId) ?? nonEmptyString(turn?.id); + if (acknowledgedTurnId !== nativeTurnId) { + throw new Error("Codex returned an invalid steer acknowledgement"); + } + return { status: "accepted" }; + } catch (error) { + if (isDefinitiveCodexSteerRejection(error)) return { status: "unavailable" }; + throw error; + } + } + + private matchesSteerAdmission(admission: { + client: CodexAppServerClientLike; + threadId: string; + nativeTurnId: string; + foregroundTurnId: string; + }): boolean { + return ( + this.client === admission.client && + this.currentThreadId === admission.threadId && + this.currentTurnId === admission.nativeTurnId && + this.activeForegroundTurnId === admission.foregroundTurnId + ); + } + private rememberCodexUserMessageTurn(messageId: string | null | undefined): boolean { if (typeof messageId !== "string" || messageId.length === 0) { return false; @@ -6231,9 +6319,8 @@ export class CodexAppServerAgentSession implements AgentSession { if (!this.rememberCodexUserMessageTurn(timelineItem.messageId)) { return; } - const item = this.activeClientMessageId - ? { ...timelineItem, clientMessageId: this.activeClientMessageId } - : timelineItem; + const clientMessageId = timelineItem.clientMessageId ?? this.activeClientMessageId; + const item = clientMessageId ? { ...timelineItem, clientMessageId } : timelineItem; this.activeClientMessageId = null; this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item }); } diff --git a/packages/server/src/server/agent/providers/codex/app-server-transport.ts b/packages/server/src/server/agent/providers/codex/app-server-transport.ts index b2be8c8eb..ce7d4e64b 100644 --- a/packages/server/src/server/agent/providers/codex/app-server-transport.ts +++ b/packages/server/src/server/agent/providers/codex/app-server-transport.ts @@ -19,7 +19,7 @@ interface JsonRpcRequest { interface JsonRpcResponse { id: number; result?: unknown; - error?: { message?: string }; + error?: { message?: string; code?: string | number; data?: unknown }; } interface JsonRpcNotification { @@ -33,6 +33,17 @@ interface PendingRequest { timer: NodeJS.Timeout; } +export class CodexAppServerRpcError extends Error { + constructor( + message: string, + readonly code: string | number | undefined, + readonly data: unknown, + ) { + super(message); + this.name = "CodexAppServerRpcError"; + } +} + type RequestHandler = (params: unknown, requestId: number) => unknown; type NotificationHandler = (method: string, params: unknown) => void; type UnexpectedTerminationHandler = (error: Error) => void; @@ -329,7 +340,13 @@ export class CodexAppServerClient { clearTimeout(pending.timer); this.pending.delete(id); if (raw.error) { - pending.reject(new Error(raw.error.message ?? "Unknown error")); + pending.reject( + new CodexAppServerRpcError( + raw.error.message ?? "Unknown error", + raw.error.code, + raw.error.data, + ), + ); } else { pending.resolve(raw.result); } diff --git a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts index fc0031818..1ae3204fc 100644 --- a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts +++ b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts @@ -47,8 +47,11 @@ type CodexAppServerChildProcess = ChildProcessWithoutNullStreams & { export interface FakeCodexAppServer { readonly child: CodexAppServerChildProcess; readonly recordedRollbacks: JsonObject[]; + requests(): readonly JsonObject[]; assertNoErrors(): void; waitForTurnStart(): Promise; + waitForRequest(method: string): Promise; + disconnect(): void; nextResponse(): Promise; startsTurn(params: { threadId: string; turnId?: string }): void; startsCompaction(params: { threadId: string; itemId: string }): void; @@ -205,6 +208,11 @@ export function createFakeCodexAppServer( Promise.resolve(handler(message.params)) .then((result) => { + const rpcError = toJsonObject(result).__jsonRpcError; + if (rpcError) { + child.stdout.write(`${JSON.stringify({ id: message.id, error: rpcError })}\n`); + return undefined; + } child.stdout.write(`${JSON.stringify({ id: message.id, result })}\n`); return undefined; }) @@ -297,6 +305,9 @@ export function createFakeCodexAppServer( return { child, recordedRollbacks, + requests() { + return messages; + }, assertNoErrors() { if (errors.length > 0) { throw errors[0]; @@ -309,6 +320,16 @@ export function createFakeCodexAppServer( ); return toJsonObject(message.params); }, + async waitForRequest(method) { + const message = await waitForMessage( + (candidate) => candidate.method === method, + `${method} request`, + ); + return toJsonObject(message.params); + }, + disconnect() { + child.emit("exit", null, "SIGTERM"); + }, nextResponse() { return new Promise((resolve) => { child.stdin.once("data", (chunk) => resolve(chunk.toString())); diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index 78a13ea18..e40795aad 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -25,6 +25,8 @@ import type { ImportProviderSessionContext, ImportProviderSessionInput, ProviderCatalog, + SteerActiveTurnOptions, + SteerResult, ToolCallDetail, ToolCallTimelineItem, } from "../agent-sdk-types.js"; @@ -37,6 +39,10 @@ export const MOCK_LOAD_TEST_HANDLED_COMMAND = "/mock handled-command"; const MOCK_LOAD_TEST_MODE_ID = "load-test"; const MOCK_LOAD_TEST_DURATION_MS = 5 * 60 * 1000; const MOCK_LOAD_TEST_INTERVAL_MS = 40; + +function getPositiveFeatureInteger(value: unknown): number { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : 0; +} const ONE_PIXEL_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl4Kj8AAAAASUVORK5CYII="; @@ -160,6 +166,8 @@ interface AgentStreamStressRequest { coalesced: boolean; } +type SteeringReplayShape = "claude" | "codex"; + interface MockQuestionOption { label: string; description?: string; @@ -188,6 +196,13 @@ function shouldEmitTurnFailure(prompt: AgentPromptInput): boolean { return /emit\s+(?:a\s+)?synthetic\s+turn\s+failure/i.test(promptToText(prompt)); } +function parseSteeringReplayShape(prompt: AgentPromptInput): SteeringReplayShape | null { + const match = /replay a (claude|codex)-shaped foreground shell tool call/i.exec( + promptToText(prompt), + ); + return match?.[1] === "claude" || match?.[1] === "codex" ? match[1] : null; +} + function parseSettledAssistantImageMarkdown(prompt: AgentPromptInput): string | null { const match = /^emit settled assistant image markdown:\s*(!\[[^\]\r\n]*\]\(.+\))\s*$/i.exec( promptToText(prompt), @@ -648,6 +663,7 @@ export class MockLoadTestAgentSession implements AgentSession { private readonly streamingAssistantIntervalMs: number; private readonly rewindError: string | null; private remainingPromptRejections: number; + private remainingSteerFailures: number; constructor(options: { config: AgentSessionConfig; sessionId: string; logger?: Logger }) { this.id = options.sessionId; @@ -681,6 +697,9 @@ export class MockLoadTestAgentSession implements AgentSession { requestedPromptRejections > 0 ? requestedPromptRejections : 0; + this.remainingSteerFailures = getPositiveFeatureInteger( + options.config.featureValues?.mockSteerAmbiguousFailures, + ); } async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise { @@ -732,9 +751,12 @@ export class MockLoadTestAgentSession implements AgentSession { const questionPrompt = parseMockQuestionPrompt(prompt); const structuredBranchName = parseStructuredBranchNamePrompt(prompt); const settledAssistantImageMarkdown = parseSettledAssistantImageMarkdown(prompt); + const steeringReplayShape = parseSteeringReplayShape(prompt); const scheduleTurn = () => { if (shouldEmitTurnFailure(prompt)) { this.scheduleFailedTurn(turn); + } else if (steeringReplayShape) { + this.scheduleSteeringReplayTurn(turn, steeringReplayShape); } else if (this.streamingAssistantResponse !== null) { this.scheduleStreamingAssistantTurn(turn, this.streamingAssistantResponse); } else if (this.assistantResponse !== null) { @@ -928,6 +950,20 @@ export class MockLoadTestAgentSession implements AgentSession { }); } + async steerActiveTurn( + _prompt: AgentPromptInput, + options: SteerActiveTurnOptions, + ): Promise { + if (this.activeTurn?.turnId !== options.expectedTurnId) { + return { status: "unavailable" }; + } + if (this.remainingSteerFailures > 0) { + this.remainingSteerFailures -= 1; + throw new Error("Requested mock steer transport failure"); + } + return { status: "accepted" }; + } + async close(): Promise { await this.interrupt(); this.listeners.clear(); @@ -1025,6 +1061,52 @@ export class MockLoadTestAgentSession implements AgentSession { turn.timer.unref?.(); } + private scheduleSteeringReplayTurn(turn: ActiveTurn, shape: SteeringReplayShape): void { + turn.timer = setTimeout(() => { + if (this.activeTurn !== turn) return; + this.clearTurnTimer(turn); + this.emitTurnStarted(turn); + if (shape === "codex") { + this.emitTimeline(turn.turnId, { + type: "assistant_message", + text: "Running the foreground command.", + messageId: turn.assistantMessageId, + }); + } + const callId = `${turn.turnId}:steering-replay-shell`; + const detail: ToolCallDetail = { + type: "shell", + command: "sleep 5", + cwd: "/tmp/paseo-mock-load", + }; + this.emitTimeline( + turn.turnId, + createToolCall({ callId, name: "bash", status: "running", detail }), + ); + turn.timer = setTimeout(() => { + if (this.activeTurn !== turn) return; + this.clearTurnTimer(turn); + this.emitTimeline( + turn.turnId, + createToolCall({ + callId, + name: "bash", + status: "completed", + detail: { ...detail, output: "", exitCode: 0 }, + }), + ); + this.emitTimeline(turn.turnId, { + type: "assistant_message", + text: "Foreground command completed after steering.", + messageId: turn.assistantMessageId, + }); + this.finishTurnWithText(turn, "Foreground command completed after steering."); + }, 5_000); + turn.timer.unref?.(); + }, 0); + turn.timer.unref?.(); + } + private scheduleStressTurn(turn: ActiveTurn, stress: AgentStreamStressRequest): void { turn.timer = setTimeout(() => { this.emitStressTurn(turn, stress); diff --git a/packages/server/src/server/agent/timeline-projection.ts b/packages/server/src/server/agent/timeline-projection.ts index 73c77f80f..b152e44fd 100644 --- a/packages/server/src/server/agent/timeline-projection.ts +++ b/packages/server/src/server/agent/timeline-projection.ts @@ -13,6 +13,7 @@ export type TimelineLimitDirection = "tail" | "before" | "after"; export interface TimelineProjectionEntry { item: AgentTimelineItem; + turnId?: string; timestamp: string; seqStart: number; seqEnd: number; @@ -110,6 +111,7 @@ function mergeToolCallItems( function makeCanonicalEntries(rows: readonly AgentTimelineRow[]): WorkingEntry[] { return rows.map((row) => ({ item: row.item, + ...(row.turnId ? { turnId: row.turnId } : {}), timestamp: row.timestamp, seqStart: row.seq, seqEnd: row.seq, @@ -136,7 +138,7 @@ function collapseToolLifecycle(entries: readonly WorkingEntry[]): WorkingEntry[] } const existing = output[existingIndex]; - if (!existing || existing.item.type !== "tool_call") { + if (!existing || existing.item.type !== "tool_call" || existing.turnId !== entry.turnId) { output.push(entry); continue; } @@ -169,7 +171,8 @@ function mergeReasoningChunks(entries: readonly WorkingEntry[]): WorkingEntry[] previous && previous.item.type === "reasoning" && entry.item.type === "reasoning" && - previous.seqEnd + 1 === entry.seqStart; + previous.seqEnd + 1 === entry.seqStart && + previous.turnId === entry.turnId; if (!shouldMerge || !previous) { output.push(entry); @@ -209,7 +212,8 @@ function mergeAssistantChunks(entries: readonly WorkingEntry[]): WorkingEntry[] previous && previous.item.type === "assistant_message" && entry.item.type === "assistant_message" && - previous.seqEnd + 1 === entry.seqStart; + previous.seqEnd + 1 === entry.seqStart && + previous.turnId === entry.turnId; if (!shouldMerge || !previous) { output.push(entry); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 575245cf1..c1197754e 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -128,6 +128,7 @@ import type { LocalSpeechProviderConfig } from "./speech/providers/local/config. import type { RequestedSpeechProviders } from "./speech/speech-types.js"; import { createSpeechService } from "./speech/speech-runtime.js"; import { AgentManager } from "./agent/agent-manager.js"; +import { FileAgentTimelineStore } from "./agent/file-agent-timeline-store.js"; import { AgentStorage } from "./agent/agent-storage.js"; import { attachAgentStoragePersistence } from "./persistence-hooks.js"; import { createAgentMcpServer } from "./agent/mcp-server.js"; @@ -841,6 +842,7 @@ export async function createPaseoDaemon( } const agentStorage = new AgentStorage(config.agentStoragePath, logger); + const timelineStore = new FileAgentTimelineStore(path.join(config.paseoHome, "agent-timelines")); const projectRegistry = new FileBackedProjectRegistry( path.join(config.paseoHome, "projects", "projects.json"), logger, @@ -889,6 +891,7 @@ export async function createPaseoDaemon( }); const initialAgentManagerState = providerSnapshotManager.getAgentManagerProviderState(); const agentManager = new AgentManager({ + durableTimelineStore: timelineStore, clients: initialAgentManagerState.clients, providerDefinitions: initialAgentManagerState.providerDefinitions, registry: agentStorage, diff --git a/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts index 568bf454b..0756a802c 100644 --- a/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import pino from "pino"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { ClaudeAgentClient } from "../agent/providers/claude/agent.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { canRunRealProvider, @@ -23,6 +24,55 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +async function within(label: string, timeoutMs: number, operation: Promise): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + operation, + new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error(`Timed out after ${timeoutMs}ms: ${label}`)), + timeoutMs, + ); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +} + +function generateClientMessageId(): string { + return `msg_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; +} + +interface ObservedForegroundSleep { + callId: string; + turnId?: string; +} + +function getRunningClaudeSleep( + message: SessionOutboundMessage, + agentId: string, +): ObservedForegroundSleep | null { + if ( + message.type !== "agent_stream" || + message.payload.agentId !== agentId || + message.payload.event.type !== "timeline" || + message.payload.event.item.type !== "tool_call" + ) { + return null; + } + const tool = message.payload.event.item; + if ( + tool.status !== "running" || + tool.detail.type !== "shell" || + !/\bsleep 5\b/.test(tool.detail.command) + ) { + return null; + } + return { callId: tool.callId, turnId: message.payload.event.turnId }; +} + function hasRunningToolCall(messages: SessionOutboundMessage[], agentId: string): boolean { for (const m of messages) { if ( @@ -38,6 +88,37 @@ function hasRunningToolCall(messages: SessionOutboundMessage[], agentId: string) return false; } +function isCapturedSleepCompletion( + message: SessionOutboundMessage, + agentId: string, + callId: string, +): boolean { + return ( + message.type === "agent_stream" && + message.payload.agentId === agentId && + message.payload.event.type === "timeline" && + message.payload.event.item.type === "tool_call" && + message.payload.event.item.callId === callId && + message.payload.event.item.status === "completed" + ); +} + +function isCapturedSleepCancellation( + message: SessionOutboundMessage, + agentId: string, + callId: string, +): boolean { + return ( + message.type === "agent_stream" && + message.payload.agentId === agentId && + message.payload.event.type === "timeline" && + message.payload.event.item.type === "tool_call" && + message.payload.event.item.callId === callId && + (message.payload.event.item.status === "canceled" || + message.payload.event.item.status === "failed") + ); +} + function getAssistantTexts(messages: SessionOutboundMessage[], agentId: string): string[] { return messages .filter( @@ -165,13 +246,72 @@ async function waitForRunningToolCall( .filter((entry) => entry.item.type === "assistant_message") .slice(-5) .map((entry) => entry.item.text) ?? []; + const snapshot = await client.fetchAgent({ agentId }).catch(() => null); + const streamFailures = collector.messages + .filter( + (message) => + message.type === "agent_stream" && + message.payload.agentId === agentId && + (message.payload.event.type === "turn_failed" || + message.payload.event.type === "turn_canceled"), + ) + .map((message) => message.payload.event); + const permissionEvents = collector.messages + .filter( + (message) => + message.type === "agent_stream" && + message.payload.agentId === agentId && + message.payload.event.type === "permission_requested", + ) + .map((message) => message.payload.event); throw new Error( - `Timed out waiting for running tool call. Recent tool_calls=${JSON.stringify(recentToolCalls)} recent assistant text=${JSON.stringify(recentAssistantTexts)}`, + `Timed out waiting for running tool call. lifecycle=${snapshot?.agent.status ?? "missing"} activeTurn=${snapshot?.agent.activeForegroundTurnId ?? "none"} recent tool_calls=${JSON.stringify(recentToolCalls)} recent assistant text=${JSON.stringify(recentAssistantTexts)} stream failures=${JSON.stringify(streamFailures)} permissions=${JSON.stringify(permissionEvents)}`, + ); +} + +async function waitForRunningClaudeSleep( + client: DaemonClient, + collector: ReturnType, + agentId: string, + timeoutMs = 90_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const observed = collector.messages + .map((message) => getRunningClaudeSleep(message, agentId)) + .find((event): event is ObservedForegroundSleep => event !== null); + if (observed) { + return observed; + } + + // Projection is only diagnostic here; tool lifecycle rows are collapsed by fetch. + const timeline = await client.fetchAgentTimeline(agentId, { limit: 100 }).catch(() => null); + const assistantTexts = + timeline?.entries + .filter((entry) => entry.item.type === "assistant_message") + .slice(-5) + .map((entry) => entry.item.text) ?? []; + const limitText = assistantTexts.find((text) => hasProviderLimitText(text)); + if (limitText) { + throw new Error(`Claude provider rejected the run: ${limitText}`); + } + await sleep(50); + } + + const timeline = await client.fetchAgentTimeline(agentId, { limit: 100 }).catch(() => null); + throw new Error( + `Timed out waiting for Claude to report the live foreground sleep 5 call. Recent timeline=${JSON.stringify(timeline ? summarizeTimelineItems(timeline) : [])}`, ); } describe("daemon E2E (real claude) - send message during tool call", () => { let canRun = false; + interface SteeringResources { + cwd: string | null; + daemon: Awaited> | null; + client: DaemonClient | null; + collector: ReturnType | null; + } beforeAll(async () => { canRun = await canRunRealProvider("claude"); @@ -183,6 +323,187 @@ describe("daemon E2E (real claude) - send message during tool call", () => { } }); + test("steers one active Claude turn without starting another", async () => { + const logger = pino({ level: "silent" }); + const resources: SteeringResources = { + cwd: tmpCwd(), + daemon: null, + client: null, + collector: null, + }; + try { + resources.daemon = await createTestPaseoDaemon({ + // Use the installed SDK's configured authentication so this regression + // exercises the native streaming-input path. + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + resources.client = new DaemonClient({ url: `ws://127.0.0.1:${resources.daemon.port}/ws` }); + const { client, cwd } = resources; + if (!client) throw new Error("Claude steering test client was not created"); + await within("connect Claude steering test client", 15_000, client.connect()); + await within( + "subscribe Claude steering test client", + 15_000, + client.fetchAgents({ subscribe: { subscriptionId: "steer" } }), + ); + const agent = await within( + "create Claude steering test agent", + 30_000, + client.createAgent({ + cwd: cwd ?? process.cwd(), + title: "claude-exact-turn-steer", + ...getRealProviderConfig("claude"), + }), + ); + resources.collector = createMessageCollector(client); + await within( + "submit Claude foreground sleep turn", + 30_000, + client.sendAgentMessage( + agent.id, + [ + "Use the Bash tool.", + "Run exactly: sleep 5. Run it in the foreground. Do not finish until a later user message arrives.", + "After that message arrives, use the Bash tool again and run exactly: printf SECOND_BOUNDARY. Then reply exactly: STEERED_SAME_TURN.", + "Do not use a background task.", + ].join(" "), + { messageId: generateClientMessageId() }, + ), + ); + const foregroundSleep = await within( + "wait for Claude to begin the live foreground sleep tool", + 90_000, + waitForRunningClaudeSleep(client, resources.collector, agent.id, 80_000), + ); + await within( + "confirm Claude remains active at the first tool boundary", + 15_000, + client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running", 10_000), + ); + const initialTurnStarts = resources.collector.messages.filter( + (message) => + message.type === "agent_stream" && + message.payload.agentId === agent.id && + message.payload.event.type === "turn_started", + ); + expect(initialTurnStarts).toHaveLength(1); + const initialTurnId = initialTurnStarts[0]?.payload.event.turnId; + expect(initialTurnId).toEqual(expect.any(String)); + expect(foregroundSleep.turnId).toBe(initialTurnId); + const steeringMessageId = generateClientMessageId(); + const messagesBeforeSteer = resources.collector.messages.length; + await within( + "submit Claude active-turn steer", + 30_000, + client.sendAgentMessage(agent.id, "hello", { + messageId: steeringMessageId, + activeTurnBehavior: "steer", + }), + ); + const finish = await within( + "wait for steered Claude turn to finish", + 150_000, + client.waitForFinish(agent.id, 140_000), + ); + expect(finish.status).toBe("idle"); + const postSteerMessages = resources.collector.messages.slice(messagesBeforeSteer); + const turnStarts = postSteerMessages.filter( + (message) => + message.type === "agent_stream" && + message.payload.agentId === agent.id && + message.payload.event.type === "turn_started", + ); + expect(turnStarts).toHaveLength(0); + expect( + postSteerMessages.filter( + (message) => + message.type === "agent_stream" && + message.payload.agentId === agent.id && + message.payload.event.type === "turn_canceled", + ), + ).toHaveLength(0); + expect( + postSteerMessages.filter( + (message) => + message.type === "agent_stream" && + message.payload.agentId === agent.id && + message.payload.event.type === "turn_completed", + ), + ).toHaveLength(1); + expect( + postSteerMessages.some((message) => + isCapturedSleepCompletion(message, agent.id, foregroundSleep.callId), + ), + "the exact live sleep 5 call must complete after hello is submitted", + ).toBe(true); + expect( + postSteerMessages.some((message) => + isCapturedSleepCancellation(message, agent.id, foregroundSleep.callId), + ), + "the exact live sleep 5 call must not be canceled or fail after hello", + ).toBe(false); + const secondBoundaryEvents = postSteerMessages.filter( + (message) => + message.type === "agent_stream" && + message.payload.agentId === agent.id && + message.payload.event.type === "timeline" && + message.payload.event.item.type === "tool_call" && + message.payload.event.item.detail.type === "shell" && + /\bprintf\s+SECOND_BOUNDARY\b/.test(message.payload.event.item.detail.command), + ); + expect( + secondBoundaryEvents.some( + (message) => + message.payload.event.type === "timeline" && + message.payload.event.item.type === "tool_call" && + message.payload.event.item.status === "completed", + ), + "hello must drive a second completed tool call in the same active loop", + ).toBe(true); + expect( + secondBoundaryEvents.every( + (message) => + message.payload.event.type === "timeline" && + message.payload.event.turnId === initialTurnId, + ), + "both Claude tool boundaries must retain the original turn ID", + ).toBe(true); + const timeline = await within( + "fetch steered Claude timeline", + 15_000, + client.fetchAgentTimeline(agent.id, { limit: 100 }), + ); + const assistantText = timeline.entries + .filter((entry) => entry.item.type === "assistant_message") + .map( + (entry) => (entry.item as Extract).text, + ) + .join("\\n"); + const steeringRows = timeline.entries.filter( + (entry) => entry.item.type === "user_message" && entry.item.text === "hello", + ); + expect(steeringRows).toHaveLength(1); + expect(steeringRows[0]?.item).toMatchObject({ + messageId: steeringMessageId, + clientMessageId: steeringMessageId, + }); + expect(steeringRows[0]?.turnId).toBe(initialTurnId); + expect(assistantText).toContain("STEERED_SAME_TURN"); + } finally { + const cleanup = await Promise.allSettled([ + Promise.resolve(resources.collector?.unsubscribe()), + resources.client?.close() ?? Promise.resolve(), + resources.daemon?.close() ?? Promise.resolve(), + ]); + if (resources.cwd) rmSync(resources.cwd, { recursive: true, force: true }); + const failures = cleanup.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + expect(failures, "Claude steering E2E cleanup failures").toEqual([]); + } + }, 210_000); + test("sending a message while a tool call is running replaces the turn without error, idle flash, or autonomous fallback", async () => { const logger = pino({ level: "silent" }); const cwd = tmpCwd(); diff --git a/packages/server/src/server/daemon-e2e/send-during-tool-call-codex.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-during-tool-call-codex.real.e2e.test.ts index e122e846c..73ccc0198 100644 --- a/packages/server/src/server/daemon-e2e/send-during-tool-call-codex.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-during-tool-call-codex.real.e2e.test.ts @@ -5,15 +5,12 @@ import path from "node:path"; import pino from "pino"; import type { AgentTimelineItem } from "../agent/agent-sdk-types.js"; +import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createMessageCollector } from "../test-utils/message-collector.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import type { SessionOutboundMessage } from "../messages.js"; -import { - canRunRealProvider, - createRealProviderClients, - getRealProviderConfig, -} from "./real-provider-test-config.js"; +import { canRunRealProvider } from "./real-provider-test-config.js"; function tmpCwd(): string { return mkdtempSync(path.join(tmpdir(), "daemon-real-codex-tool-interrupt-")); @@ -23,6 +20,23 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +async function within(label: string, timeoutMs: number, operation: Promise): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + operation, + new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error(`Timed out after ${timeoutMs}ms: ${label}`)), + timeoutMs, + ); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +} + function generateClientMessageId(): string { return `msg_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; } @@ -102,24 +116,84 @@ function summarizeTimelineItems(timeline: Awaited, agentId: string, timeoutMs = 75_000, -): Promise { + command = /\bsleep 5\b/, +): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - const timeline = await client.fetchAgentTimeline(agentId, { limit: 100 }); - assertNoProviderLimit(timeline); - const assistantText = timeline.entries - .filter((entry) => entry.item.type === "assistant_message") - .map((entry) => entry.item.text) - .join("\n"); - if (/still running|waiting/i.test(assistantText) && !/`done`|\bdone\b/i.test(assistantText)) { - return; + const observed = collector.messages + .map((message) => getRunningCodexSleep(message, agentId, command)) + .find((event): event is ObservedForegroundSleep => event !== null); + if (observed) { + return observed; } - - await sleep(500); + // Timeline fetches are diagnostic only: projection collapses the lifecycle row. + const timeline = await client.fetchAgentTimeline(agentId, { limit: 100 }).catch(() => null); + if (timeline) assertNoProviderLimit(timeline); + await sleep(50); } const timeline = await client.fetchAgentTimeline(agentId, { limit: 100 }).catch(() => null); @@ -144,6 +218,12 @@ async function waitForAssistantWaitingOnSleep( describe("daemon E2E (real codex) - send message during tool call", () => { let canRun = false; + interface SteeringResources { + cwd: string | null; + daemon: Awaited> | null; + client: DaemonClient | null; + collector: ReturnType | null; + } beforeAll(async () => { canRun = await canRunRealProvider("codex"); @@ -155,11 +235,163 @@ describe("daemon E2E (real codex) - send message during tool call", () => { } }); + test("steers one active Codex turn without starting another", async () => { + const logger = pino({ level: "silent" }); + const resources: SteeringResources = { + cwd: tmpCwd(), + daemon: null, + client: null, + collector: null, + }; + try { + resources.daemon = await createTestPaseoDaemon({ + // Use the installed app-server so this regression exercises the native + // steering method rather than the broad provider smoke-test path. + agentClients: { codex: new CodexAppServerAgentClient(logger) }, + logger, + }); + resources.client = new DaemonClient({ url: `ws://127.0.0.1:${resources.daemon.port}/ws` }); + const { client, cwd } = resources; + if (!client) throw new Error("Codex steering test client was not created"); + await within("connect steering test client", 15_000, client.connect()); + await within( + "subscribe steering test client", + 15_000, + client.fetchAgents({ subscribe: { subscriptionId: "steer" } }), + ); + const agent = await within( + "create Codex steering test agent", + 30_000, + client.createAgent({ + cwd: cwd ?? process.cwd(), + title: "codex-exact-turn-steer", + provider: "codex", + model: "gpt-5.4", + thinkingOptionId: "low", + modeId: "full-access", + }), + ); + resources.collector = createMessageCollector(client); + await within( + "submit Codex foreground sleep turn", + 30_000, + client.sendAgentMessage( + agent.id, + "Use the shell tool to run exactly: sleep 5. Run it in the foreground. Do not finish until a later user message arrives; after it arrives, reply exactly: STEERED_SAME_TURN.", + { messageId: generateClientMessageId() }, + ), + ); + const foregroundSleep = await within( + "wait for live Codex foreground sleep tool", + 90_000, + waitForRunningCodexSleep(client, resources.collector, agent.id, 80_000), + ); + await within( + "confirm Codex remains active at the first tool boundary", + 15_000, + client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running", 10_000), + ); + const initialTurnStarts = resources.collector.messages.filter( + (message) => + message.type === "agent_stream" && + message.payload.agentId === agent.id && + message.payload.event.type === "turn_started", + ); + expect(initialTurnStarts).toHaveLength(1); + const initialTurnId = initialTurnStarts[0]?.payload.event.turnId; + expect(initialTurnId).toEqual(expect.any(String)); + const steeringMessageId = generateClientMessageId(); + const messagesBeforeSteer = resources.collector.messages.length; + await within( + "submit Codex active-turn steer", + 30_000, + client.sendAgentMessage(agent.id, "hello", { + messageId: steeringMessageId, + activeTurnBehavior: "steer", + }), + ); + const finish = await within( + "wait for steered Codex turn to finish", + 150_000, + client.waitForFinish(agent.id, 140_000), + ); + expect(finish.status).toBe("idle"); + const postSteerMessages = resources.collector.messages.slice(messagesBeforeSteer); + const turnStarts = postSteerMessages.filter( + (message) => + message.type === "agent_stream" && + message.payload.agentId === agent.id && + message.payload.event.type === "turn_started", + ); + expect(turnStarts).toHaveLength(0); + expect( + postSteerMessages.filter( + (message) => + message.type === "agent_stream" && + message.payload.agentId === agent.id && + message.payload.event.type === "turn_canceled", + ), + ).toHaveLength(0); + expect( + postSteerMessages.filter( + (message) => + message.type === "agent_stream" && + message.payload.agentId === agent.id && + message.payload.event.type === "turn_completed", + ), + ).toHaveLength(1); + expect( + postSteerMessages.some((message) => + isCapturedSleepCompletion(message, agent.id, foregroundSleep.callId), + ), + "the exact live sleep 5 call must complete after hello is submitted", + ).toBe(true); + expect( + postSteerMessages.some((message) => + isCapturedSleepCancellation(message, agent.id, foregroundSleep.callId), + ), + "the exact live sleep 5 call must not be canceled or fail after hello", + ).toBe(false); + const timeline = await within( + "fetch steered Codex timeline", + 15_000, + client.fetchAgentTimeline(agent.id, { limit: 100 }), + ); + const assistantText = timeline.entries + .filter((entry) => entry.item.type === "assistant_message") + .map( + (entry) => (entry.item as Extract).text, + ) + .join("\n"); + const steeringRows = timeline.entries.filter( + (entry) => entry.item.type === "user_message" && entry.item.text === "hello", + ); + expect(steeringRows).toHaveLength(1); + expect(steeringRows[0]?.item).toMatchObject({ + messageId: steeringMessageId, + clientMessageId: steeringMessageId, + }); + expect(steeringRows[0]?.turnId).toBe(initialTurnId); + expect(assistantText).toContain("STEERED_SAME_TURN"); + } finally { + const cleanup = await Promise.allSettled([ + Promise.resolve(resources.collector?.unsubscribe()), + resources.client?.close() ?? Promise.resolve(), + resources.daemon?.close() ?? Promise.resolve(), + ]); + if (resources.cwd) rmSync(resources.cwd, { recursive: true, force: true }); + const failures = cleanup.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + expect(failures, "Codex steering E2E cleanup failures").toEqual([]); + } + }, 210_000); + test("does not emit an idle agent_update between UI send and the replacement Codex turn", async () => { const logger = pino({ level: "silent" }); const cwd = tmpCwd(); const daemon = await createTestPaseoDaemon({ - agentClients: createRealProviderClients(["codex"], logger), + agentClients: { codex: new CodexAppServerAgentClient(logger) }, logger, }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); @@ -172,7 +404,10 @@ describe("daemon E2E (real codex) - send message during tool call", () => { const agent = await client.createAgent({ cwd, title: "codex-tool-interrupt-repro", - ...getRealProviderConfig("codex"), + provider: "codex", + model: "gpt-5.4", + thinkingOptionId: "low", + modeId: "full-access", }); collector = createMessageCollector(client); @@ -186,7 +421,7 @@ describe("daemon E2E (real codex) - send message during tool call", () => { (snapshot) => snapshot.status === "running", 60_000, ); - await waitForAssistantWaitingOnSleep(client, agent.id); + await waitForRunningCodexSleep(client, collector, agent.id, 75_000, /\bsleep 60\b/); collector.clear(); @@ -235,7 +470,7 @@ describe("daemon E2E (real codex) - send message during tool call", () => { const logger = pino({ level: "silent" }); const cwd = tmpCwd(); const daemon = await createTestPaseoDaemon({ - agentClients: createRealProviderClients(["codex"], logger), + agentClients: { codex: new CodexAppServerAgentClient(logger) }, logger, }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); @@ -248,7 +483,10 @@ describe("daemon E2E (real codex) - send message during tool call", () => { const agent = await client.createAgent({ cwd, title: "codex-quick-follow-up-repro", - ...getRealProviderConfig("codex"), + provider: "codex", + model: "gpt-5.4", + thinkingOptionId: "low", + modeId: "full-access", }); collector = createMessageCollector(client); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 8a250ef92..696ca138e 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -6621,21 +6621,26 @@ export class Session { hasOlder: selectedTimeline.hasOlder, hasNewer: selectedTimeline.hasNewer, ...(msg.mergeWindow === true ? { mergeWindow: true } : {}), - entries: selectedTimeline.entries.map((entry) => ({ - provider: snapshot.provider, - item: entry.item, - timestamp: entry.timestamp, - seqStart: entry.seqStart, - seqEnd: entry.seqEnd, - sourceSeqRanges: entry.sourceSeqRanges, - collapsed: ( - source - ? this.supportsForSource(CLIENT_CAPS.reasoningMergeEnum, source) - : this.supports(CLIENT_CAPS.reasoningMergeEnum) - ) - ? entry.collapsed - : entry.collapsed.filter((value) => value !== "reasoning_merge"), - })), + entries: selectedTimeline.entries.map((entry) => { + const payloadEntry = { + provider: snapshot.provider, + item: entry.item, + timestamp: entry.timestamp, + seqStart: entry.seqStart, + seqEnd: entry.seqEnd, + sourceSeqRanges: entry.sourceSeqRanges, + turnId: undefined as string | undefined, + collapsed: ( + source + ? this.supportsForSource(CLIENT_CAPS.reasoningMergeEnum, source) + : this.supports(CLIENT_CAPS.reasoningMergeEnum) + ) + ? entry.collapsed + : entry.collapsed.filter((value) => value !== "reasoning_merge"), + }; + payloadEntry.turnId = entry.turnId; + return payloadEntry; + }), error: null, }, }, @@ -6904,11 +6909,12 @@ export class Session { { agentId, messageId: msg.messageId, + activeTurnBehavior: msg.activeTurnBehavior, textPrefix: msg.text.slice(0, 80), }, "agent.session.send_agent_message", ); - let dispatchResult: { outOfBand: boolean }; + let dispatchResult: { disposition: "out_of_band" | "steered" | "turn_started" }; try { dispatchResult = await sendPromptToAgent({ agentManager: this.agentManager, @@ -6916,6 +6922,7 @@ export class Session { agentId, prompt, messageId: msg.messageId, + activeTurnBehavior: msg.activeTurnBehavior ?? "interrupt", logger: this.sessionLogger, }); } catch (error) { @@ -6933,7 +6940,7 @@ export class Session { return; } - if (dispatchResult.outOfBand) { + if (dispatchResult.disposition !== "turn_started") { this.emit({ type: "send_agent_message_response", payload: { diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index 89c441982..c15f40965 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -419,6 +419,32 @@ describe("wire compatibility", () => { expect(currentParsed.payload.entries[0]?.collapsed).toContain("reasoning_merge"); }); + test("carries canonical turn IDs to new clients while legacy schemas ignore them", async () => { + const response = await emitTimelineResponse({ + rows: [ + { + seq: 1, + timestamp: "2026-05-02T00:00:00.000Z", + turnId: "turn-1", + item: { type: "user_message", text: "prompt", clientMessageId: "message-1" }, + }, + { + seq: 2, + timestamp: "2026-05-02T00:00:01.000Z", + turnId: "turn-1", + item: { type: "assistant_message", text: "done" }, + }, + ], + }); + + expect(FetchAgentTimelineResponseMessageSchema.parse(response).payload.entries).toEqual( + expect.arrayContaining([expect.objectContaining({ turnId: "turn-1" })]), + ); + expect(LegacyFetchAgentTimelineResponseMessageSchema.parse(response).payload.entries).toEqual( + expect.arrayContaining([expect.not.objectContaining({ turnId: expect.anything() })]), + ); + }); + test("legacy worktree request shape normalizes to the same internal input as the new shape", async () => { const workflow = new InMemoryWorktreeWorkflow();