diff --git a/docs/architecture.md b/docs/architecture.md index 62f80b63b1..b28c3c9e4d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -228,6 +228,11 @@ New session RPCs use dotted names with `.request` and `.response` suffixes, such - `agent_permission_request` / `agent_permission_resolved` — Tool-call permission flow - `agent_deleted`, `agent_archived`, `agent_status`, `agent_list` - `checkout_status_update`, `checkout_diff_update`, and the full `checkout_*` request/response set for git operations + +Agent snapshots optionally carry the daemon-owned active turn identity, and turn lifecycle stream events +optionally carry the same `turnId`. New clients use these fields when present and normalize an old daemon's +status once at the directory boundary rather than maintaining a second activity model. + - Terminal subscribe/input/capture commands - Voice/dictation streaming events (`dictation_stream_*`, `assistant_chunk`, `audio_output`, `transcription_result`) - Request/response pairs for fetch, list, create, etc., correlated by `requestId`; failures use `rpc_error` @@ -293,7 +298,10 @@ initializing → idle ⇄ running `ManagedAgent` is a discriminated union over those lifecycle tags. Notes: - **AgentManager** is the source of truth for agent state and broadcasts updates to all subscribers -- Timeline is append-only with epochs (each run starts a new epoch). Storage uses sequence numbers for client-side dedup; the default fetch page is 200 items +- Timeline sequence allocation is append-only with epochs (each run starts a new epoch). The one + permitted in-place enrichment adds a provider message id to the manager-owned row for an accepted + prompt; it preserves the row's sequence, content, and timestamp. Storage uses sequence numbers for + client-side dedup; the default fetch page is 200 items. - Timeline row `timestamp` values are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics. - Events stream to connected clients in real time; correctness is backed by authoritative timeline fetches and paged-to-completion catch-up. - Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record). That storage path is derived from `cwd`, not from workspace id. diff --git a/docs/providers.md b/docs/providers.md index 5bee5b40b2..e6f97820dd 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -42,7 +42,9 @@ OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event. -Every provider adapter owns its canonical user-message timeline rows. When a foreground prompt is accepted, the adapter must emit exactly one `user_message` timeline item for that submitted prompt, using the same message ID it gives to or receives from the provider runtime. Optimistic client messages are UI-only and provider transcript echoes are optional; neither is allowed to be the only source of truth. If the provider later echoes the same submitted user message, dedupe it only within the active turn. Prefer provider-visible message IDs, but ACP runtimes may omit that ID or replace it with a provider-owned one; in that case suppress only echo chunks whose accumulated text is a prefix of the active submitted prompt. Do not perform global transcript text dedupe. +`AgentManager` owns the one canonical timeline row for a foreground prompt carrying a Paseo `clientMessageId`. It records that row when `startTurn` accepts. Provider adapters still emit their native user-message echo with the same `clientMessageId` when available; the manager uses it to enrich the existing row with the provider `messageId` without allocating another sequence. If an adapter emits the echo before `startTurn` resolves, the manager carries its provider identity into the row it records at acceptance. Provider adapters continue to own externally initiated user rows that have no Paseo client identity. Do not perform global transcript text dedupe. + +When the provider echo arrives after acceptance, the manager publishes the enriched canonical row again at its original sequence. This is an in-place row revision, not another timeline append; consumers may apply it only to the already-covered row with the matching `clientMessageId`. Submitted user-message rows preserve both identities: `messageId` is the provider-visible ID and the optional `clientMessageId` is the Paseo ID from `AgentRunOptions`. Attach `clientMessageId` only to the canonical row for that foreground submission; provider history and externally initiated user rows do not have a Paseo client ID. diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 74b19d18fc..6e9ac61e44 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -138,41 +138,46 @@ footer, and permanently drops attachments because the daemon does not echo them A submitted prompt is one `UserMessageItem` row. That row is the authoritative local presentation: its stable identity, text, timestamp, images, and attachments do not change when the provider acknowledges it. Submission lifecycle is a separate record keyed by agent, not another row shape. -The transaction registry records RPC acceptance and provider acknowledgement independently for -agent-scoped sends. Provider acknowledgement exists solely so a later transport error cannot roll -back a prompt already observed canonically; it does not own whether the local presentation remains -visible. +The transaction registry exists only until the daemon accepts or rejects the send. An accepted RPC +settles it because capable daemons guarantee the prompt is already durable. Canonical acknowledgement +can arrive first and prevents a later transport error from rolling back a prompt already observed. -The daemon's accepted response already waits for the correlated run start, but its response and the -canonical submitted row reach client state independently and may arrive in either order. The -transaction therefore records RPC acceptance and provider acknowledgement as separate phases. RPC -acceptance marks the record accepted unless provider acknowledgement already arrived; canonical -acknowledgement marks the record acknowledged unless RPC acceptance already arrived. Only the second -authority removes the transaction. Directory status never settles a submission. Overlapping sends -settle independently rather than collapsing to one newest pending message. +The daemon's accepted response waits for the correlated run start and guarantees that the canonical +submitted row has been recorded. It publishes the accepted turn's liveness before that row, so the +client applies authoritative activity before retiring optimistic activity on RPC acceptance. Timeline +render batching does not delay lifecycle application. Directory status never settles a submission. +Overlapping sends settle independently rather than collapsing to one newest pending message. Daemons advertising `server_info.features.canonicalSubmittedPrompts` guarantee that every accepted prompt carrying a client message id is recorded and streamed as a canonical `user_message` with that same id. This includes daemon-handled commands that do not allocate a foreground turn; their submitted -row is recorded before handler output. The app creates two-phase submission records only for hosts with -this capability. Older hosts keep the shipped untracked optimistic-row behavior and roll that row back -on RPC rejection. +row is recorded before handler output. The app tracks submission transactions only for hosts with this +capability. Older hosts keep the shipped untracked optimistic-row behavior and roll that row back on RPC +rejection. -Turn liveness has one client-side owner. `turn_started` opens the turn; ordered `turn_completed`, -`turn_failed`, and `turn_canceled` events close it. A running directory transition may seed an open -turn during hydration or after a selective stream subscription lapses, but an ordinary idle directory -update does not close one or settle a submission because it can arrive before the terminal stream -event. A completed timeline resume commits the response's coherent agent snapshot and closes any open -turn not backed by newer sequence evidence. An accepted running-to-idle directory transition requests -that resume for a viewed agent; it does not close liveness itself. This gives missed terminal events and -canonical prompt acknowledgements one convergence boundary without making directory delivery order an -authority for the footer. Disconnect and replica removal are the destructive close boundaries. Footer -activity is the owned turn state unioned with unsettled submissions, and its running start time comes -from lifecycle/submission evidence rather than whichever timeline rows happen to be mounted. +Turn activity has one client-side replica. Lifecycle events can attach `turnId`, and agent snapshots can +expose `activeTurn: { turnId, startedAt } | null`. An identified terminal cannot close a different +identified turn; an unnamed legacy terminal can close the current turn. Snapshots, stream events, +cancellation requests, and every visible surface update the same per-agent record. The snapshot covers +both user-started foreground turns and autonomous provider turns; +foreground control ownership remains a separate daemon concern. Cancellation request identity is stored +with that record rather than in a React component, so an old request cannot clear a newer one. Submissions +remain a separate pre-turn registry and retire on canonical acknowledgement. -Canonical submitted user rows carry the provider's `messageId` and Paseo's optional -`clientMessageId`. The user-message producer reconciles them by `clientMessageId`, adds provider -identity to the existing row, and keeps the local presentation in its original timeline slot. +The compatibility boundary for older daemons is snapshot normalization: running/idle status becomes an +anonymous active turn or idle state once, and downstream code consumes the same activity shape. The app +does not combine anonymous lifecycle events, timestamps, timeline rows, and resume coverage to infer a +second running state. Disconnect and replica removal remain destructive close boundaries. Running time +comes from the daemon snapshot/event or the pending submission, never from whichever timeline rows happen +to be mounted. + +The daemon records one canonical submitted user row with Paseo's `clientMessageId` as soon as the +provider accepts the turn. A correlated provider echo enriches that same sequence with its provider +`messageId`; it never appends a second source row. The row's identity and pagination position are +therefore independent of provider notification timing. A late echo is streamed as a revision at the +original sequence; clients apply it only to the already-covered row with the same client identity. +An immediately interrupted turn remains +canonical even when the provider never emits its echo. Content matching is limited to the dated compatibility path for daemon timelines created before that field existed. Canonical ingestion may match only an explicit unreconciled local candidate; the draft-create handoff is the one boundary that also permits the legacy canonical twin to have diff --git a/packages/app/e2e/agent-consecutive-turns.spec.ts b/packages/app/e2e/agent-consecutive-turns.spec.ts index d80968965b..7e291f06fd 100644 --- a/packages/app/e2e/agent-consecutive-turns.spec.ts +++ b/packages/app/e2e/agent-consecutive-turns.spec.ts @@ -48,6 +48,7 @@ interface TurnFrame { agentTab: ElementFrame; tabProgress: ElementFrame; interruptControl: ElementFrame; + primaryActionCount: number; composer: ElementFrame & { value: string | null }; contentChildren: ContentChildFrame[]; scroll: ElementFrame & { @@ -57,6 +58,22 @@ interface TurnFrame { }; } +interface ActivityCheckpoint { + row: boolean; + stop: boolean; + footer: boolean; + tabProgress: boolean; + elapsed: boolean; +} + +interface ActivityOracleState { + phase: "waiting" | "observing" | "complete"; + checkpoints: ActivityCheckpoint[]; + observer: MutationObserver; +} + +type RunningReleaseOrder = "turn-before-response" | "response-before-turn" | "snapshot-before-turn"; + const FIRST_PROMPT_IMAGE = { name: "first-prompt.png", mimeType: "image/png", @@ -69,6 +86,7 @@ const FIRST_PROMPT_IMAGE = { declare global { interface Window { __consecutiveTurnFrames?: { active: boolean; frames: TurnFrame[] }; + __activityContinuityOracle?: ActivityOracleState; } } @@ -210,6 +228,13 @@ async function recordTurnFrames(page: Page, prompt: string): Promise { tabProgress: agentTab?.querySelector('[role="progressbar"][aria-label="Agent running"]'), }; }; + const countPrimaryActions = (composerRoot: Element | null | undefined) => + Array.from(composerRoot?.querySelectorAll('[role="button"][aria-label]') ?? []).filter( + (candidate) => + /stop agent|canceling agent|interrupt agent|send message|send and interrupt|queue message/i.test( + candidate.getAttribute("aria-label") ?? "", + ) && isVisible(candidate), + ).length; const sample = () => { const viewport = Array.from( document.querySelectorAll('[data-testid="agent-chat-scroll"]'), @@ -232,6 +257,7 @@ async function recordTurnFrames(page: Page, prompt: string): Promise { ).find((candidate) => /stop agent|canceling agent/i.test(candidate.getAttribute("aria-label") ?? ""), ); + const primaryActionCount = countPrimaryActions(composerRoot); const { agentTab, tabProgress } = findAgentTabState(); const scrollFrame = snapshot(viewport); const contentChildren = Array.from(viewport?.firstElementChild?.children ?? []).map( @@ -254,6 +280,7 @@ async function recordTurnFrames(page: Page, prompt: string): Promise { agentTab: snapshot(agentTab), tabProgress: snapshot(tabProgress), interruptControl: snapshot(interrupt), + primaryActionCount, composer: { ...snapshot(composerRoot ?? composer), value: @@ -312,6 +339,87 @@ async function recordPaintsFor(page: Page, durationMs: number): Promise { .toBeGreaterThanOrEqual(until); } +async function installActivityContinuityOracle(page: Page, prompt: string): Promise { + await page.evaluate((promptText) => { + const isVisible = (element: Element | null) => Boolean(element?.checkVisibility()); + const snapshot = (): ActivityCheckpoint => { + const visibleAgentTab = Array.from( + document.querySelectorAll('[data-testid^="workspace-tab-agent_"]'), + ).find((candidate) => isVisible(candidate)); + return { + row: Array.from(document.querySelectorAll('[data-testid="user-message"]')).some( + (candidate) => candidate.textContent?.includes(promptText), + ), + stop: isVisible(document.querySelector('[role="button"][aria-label="Stop agent"]')), + footer: isVisible(document.querySelector('[data-testid="turn-working-indicator"]')), + tabProgress: isVisible( + visibleAgentTab?.querySelector('[role="progressbar"][aria-label="Agent running"]') ?? + null, + ), + elapsed: isVisible(document.querySelector('[data-testid="turn-working-elapsed"]')), + }; + }; + const state = { + phase: "waiting" as ActivityOracleState["phase"], + checkpoints: [] as ActivityCheckpoint[], + observer: null as unknown as MutationObserver, + }; + const observeCommit = () => { + const checkpoint = snapshot(); + if (state.phase === "waiting" && checkpoint.row) { + state.phase = "observing"; + } + if (state.phase !== "observing") return; + state.checkpoints.push(checkpoint); + if (checkpoint.elapsed) { + state.phase = "complete"; + state.observer.disconnect(); + } + }; + state.observer = new MutationObserver(observeCommit); + window.__activityContinuityOracle = state; + state.observer.observe(document.body, { + subtree: true, + childList: true, + attributes: true, + characterData: true, + }); + }, prompt); +} + +async function waitForActivityOracleArmed(page: Page): Promise { + await expect + .poll(() => page.evaluate(() => window.__activityContinuityOracle?.phase ?? "missing")) + .not.toBe("waiting"); +} + +async function readActivityContinuityOracle(page: Page): Promise { + return page.evaluate(() => { + const state = window.__activityContinuityOracle; + if (!state) throw new Error("Activity continuity oracle was never installed"); + state.observer.disconnect(); + return state.checkpoints; + }); +} + +function expectActivityContinuity(checkpoints: ActivityCheckpoint[]): void { + const incompleteActivityCheckpoints = checkpoints.filter( + (checkpoint) => !checkpoint.stop || !checkpoint.footer || !checkpoint.tabProgress, + ); + expect(checkpoints[0]?.row, "activity oracle never armed on the optimistic row").toBe(true); + expect( + incompleteActivityCheckpoints, + `activity oracle observed row without full activity cluster:\n${JSON.stringify(incompleteActivityCheckpoints, null, 2)}\n\nall checkpoints:\n${JSON.stringify(checkpoints, null, 2)}`, + ).toEqual([]); + expect( + checkpoints.slice(0, -1).filter((checkpoint) => checkpoint.elapsed), + `activity oracle observed elapsed before its authoritative disarm checkpoint:\n${JSON.stringify(checkpoints, null, 2)}`, + ).toEqual([]); + expect(checkpoints.at(-1)?.elapsed, "activity oracle never observed authoritative elapsed").toBe( + true, + ); +} + async function stopTurnFrameRecording(page: Page): Promise { return page.evaluate(() => { const state = window.__consecutiveTurnFrames; @@ -440,6 +548,10 @@ function collectElementViolations( passes: hasPaintedLayout(frame.interruptControl), reason: "interrupt control was not painted", }, + { + passes: frame.primaryActionCount === 1, + reason: `expected one primary action, found ${frame.primaryActionCount}`, + }, { passes: [hasPaintedLayout(frame.composer), frame.composer.value === ""].every(Boolean), reason: "composer was not painted and empty", @@ -590,36 +702,76 @@ function expectAtomicFirstPromptTransition(frames: TurnFrame[]): void { async function recordDelayedRunningTransition( page: Page, agent: MockAgentWorkspace, -): Promise { + releaseOrder: RunningReleaseOrder, +): Promise<{ frames: TurnFrame[]; checkpoints: ActivityCheckpoint[] }> { const gate = await installDaemonWebSocketGate(page); await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); await expectAgentIdle(page); await expect(page.getByRole("button", { name: "Copy turn" })).toHaveCount(1); const prompt = "Second prompt keeps streaming."; + gate.holdNextAgentStreamEvent("turn_started"); gate.holdNextAgentUpdate(agent.agentId, "running"); + gate.holdNextServerMessage("send_agent_message_response"); gate.setAgentStreamItemSuppressed("assistant_message", true); await recordTurnFrames(page, prompt); + await installActivityContinuityOracle(page, prompt); await submitMessage(page, prompt); - await gate.waitForHeldServerMessage(); + await Promise.all([ + gate.waitForHeldAgentStreamEvent("turn_started"), + gate.waitForHeldAgentUpdate(agent.agentId, "running"), + gate.waitForHeldServerMessage("send_agent_message_response"), + ]); - let released = false; + let turnReleased = false; + let snapshotReleased = false; + let responseReleased = false; try { await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toBeVisible(); await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toHaveValue(""); - await Promise.all([ - gate.waitForAgentStreamItem("user_message"), - gate.waitForServerMessage("send_agent_message_response"), - ]); - await recordPaintsFor(page, 80); - gate.releaseHeldServerMessage(); - released = true; + await gate.waitForAgentStreamItem("user_message"); + await waitForActivityOracleArmed(page); + const elapsedBeforeAuthoritative = await page.getByTestId("turn-working-elapsed").count(); + + if (releaseOrder === "turn-before-response") { + gate.releaseHeldAgentStreamEvent("turn_started"); + turnReleased = true; + await expect(page.getByTestId("turn-working-elapsed")).toBeVisible(); + gate.releaseHeldServerMessage("send_agent_message_response"); + responseReleased = true; + gate.releaseHeldAgentUpdate(agent.agentId, "running"); + snapshotReleased = true; + } else if (releaseOrder === "response-before-turn") { + gate.releaseHeldServerMessage("send_agent_message_response"); + responseReleased = true; + await recordPaintsFor(page, 80); + gate.releaseHeldAgentStreamEvent("turn_started"); + turnReleased = true; + await expect(page.getByTestId("turn-working-elapsed")).toBeVisible(); + gate.releaseHeldAgentUpdate(agent.agentId, "running"); + snapshotReleased = true; + } else { + gate.releaseHeldServerMessage("send_agent_message_response"); + responseReleased = true; + await recordPaintsFor(page, 80); + gate.releaseHeldAgentUpdate(agent.agentId, "running"); + snapshotReleased = true; + await expect(page.getByTestId("turn-working-elapsed")).toBeVisible(); + gate.releaseHeldAgentStreamEvent("turn_started"); + turnReleased = true; + } + await waitForRecordedFrames(page, "turn-running", 3); const frames = await stopTurnFrameRecording(page); + const checkpoints = await readActivityContinuityOracle(page); + expectActivityContinuity(checkpoints); + expect(elapsedBeforeAuthoritative).toBe(0); gate.setAgentStreamItemSuppressed("assistant_message", false); - return frames; + return { frames, checkpoints }; } finally { - if (!released) gate.releaseHeldServerMessage(); + if (!turnReleased) gate.releaseHeldAgentStreamEvent("turn_started"); + if (!snapshotReleased) gate.releaseHeldAgentUpdate(agent.agentId, "running"); + if (!responseReleased) gate.releaseHeldServerMessage("send_agent_message_response"); gate.setAgentStreamItemSuppressed("assistant_message", false); } } @@ -664,10 +816,38 @@ test("keeps the first prompt of a new agent in place through authoritative hydra } }); -test("commits a follow-up prompt and running footer in one painted frame", async ({ +test("keeps follow-up activity continuous when turn starts before response", async ({ page, streamingAgent, }) => { - const frames = await recordDelayedRunningTransition(page, streamingAgent); + const { frames } = await recordDelayedRunningTransition( + page, + streamingAgent, + "turn-before-response", + ); + expectAtomicIdleToRunningTransition(frames); +}); + +test("keeps follow-up activity continuous when response arrives before running state", async ({ + page, + streamingAgent, +}) => { + const { frames } = await recordDelayedRunningTransition( + page, + streamingAgent, + "response-before-turn", + ); + expectAtomicIdleToRunningTransition(frames); +}); + +test("keeps follow-up activity continuous when snapshot opens before turn event", async ({ + page, + streamingAgent, +}) => { + const { frames } = await recordDelayedRunningTransition( + page, + streamingAgent, + "snapshot-before-turn", + ); expectAtomicIdleToRunningTransition(frames); }); diff --git a/packages/app/e2e/agent-message-submission.spec.ts b/packages/app/e2e/agent-message-submission.spec.ts index 53758e40c3..fccf211fbd 100644 --- a/packages/app/e2e/agent-message-submission.spec.ts +++ b/packages/app/e2e/agent-message-submission.spec.ts @@ -3,8 +3,10 @@ import { expect, test as baseTest } from "./fixtures"; import { awaitToolCall, expectAgentIdle, + expectAgentReadyToInterrupt, expectAgentSurfacesIdle, expectRunningAgentChrome, + expectVisibleAgentSurfacesIdle, } from "./helpers/agent-stream"; import { gateNextAgentMessage } from "./helpers/agent-message-gate"; import { @@ -14,6 +16,7 @@ import { expectAttachmentPill, expectComposerVisible, cancelAgent, + composerLocator, fillComposerDraft, sendDraftToQueue, startRunningMockAgent, @@ -670,6 +673,201 @@ async function completeDraftCreateSubmission( } test.describe("Agent message submission", () => { + test("settles an immediately interrupted first prompt", async ({ page }, testInfo) => { + const workspace = await seedWorkspace({ + repoPrefix: `submission-immediate-interrupt-${testInfo.workerIndex}-`, + }); + const prompt = "Withhold synthetic user message until interrupted."; + try { + await openWorkspaceDraft(page, workspace.workspaceId); + await selectModel(page, "one-minute-stream"); + + await submitMessage(page, prompt); + await expectAgentReadyToInterrupt(page); + await page.getByRole("button", { name: "Stop agent", exact: true }).click(); + + const submittedPrompt = page.getByTestId("user-message").filter({ hasText: prompt }); + await expect(submittedPrompt).toHaveCount(1); + await expect(submittedPrompt).toHaveAttribute("aria-busy", "false", { timeout: 30_000 }); + await expect(page.getByText("(end of synthetic stream)", { exact: true })).toHaveCount(0); + await expectVisibleAgentSurfacesIdle(page); + await expectComposerEditable(page); + + await page.reload(); + await expect(submittedPrompt).toHaveCount(1); + await expect(submittedPrompt).toHaveAttribute("aria-busy", "false"); + await expectVisibleAgentSurfacesIdle(page); + await expectComposerEditable(page); + } finally { + await workspace.cleanup(); + } + }); + + test("keeps one canonical prompt when the provider echoes before accepting", async ({ + page, + }, testInfo) => { + const workspace = await seedWorkspace({ + repoPrefix: `submission-echo-before-accept-${testInfo.workerIndex}-`, + }); + const prompt = "Emit synthetic user message before accepting turn."; + try { + await openWorkspaceDraft(page, workspace.workspaceId); + await selectModel(page, "one-minute-stream"); + + await submitMessage(page, prompt); + await expectAgentReadyToInterrupt(page); + await page.getByRole("button", { name: "Stop agent", exact: true }).click(); + await expectVisibleAgentSurfacesIdle(page); + + await page.reload(); + const submittedPrompt = page.getByTestId("user-message").filter({ hasText: prompt }); + await expect(submittedPrompt).toHaveCount(1); + await expect(submittedPrompt).toHaveAttribute("aria-busy", "false"); + await expectVisibleAgentSurfacesIdle(page); + } finally { + await workspace.cleanup(); + } + }); + + test("enriches a visible prompt when the provider identity arrives later", async ({ + page, + }, testInfo) => { + const workspace = await seedWorkspace({ + repoPrefix: `submission-late-provider-identity-${testInfo.workerIndex}-`, + }); + const prompt = "Delay synthetic user message by 2000ms."; + try { + await openWorkspaceDraft(page, workspace.workspaceId); + await selectModel(page, "one-minute-stream"); + + await submitMessage(page, prompt); + const submittedPrompt = page.getByTestId("user-message").filter({ hasText: prompt }); + await expect(submittedPrompt).toHaveAttribute("aria-busy", "false", { timeout: 30_000 }); + const marker = `late-provider-identity-${Date.now()}`; + await submittedPrompt.evaluate((element, value) => { + element.dataset.lateProviderIdentity = value; + }, marker); + + await submittedPrompt.hover(); + await expect(submittedPrompt.getByTestId("rewind-menu-trigger")).toBeVisible({ + timeout: 5_000, + }); + await expect(page.locator(`[data-late-provider-identity="${marker}"]`)).toBeVisible(); + await page.getByRole("button", { name: "Stop agent", exact: true }).click(); + await expectVisibleAgentSurfacesIdle(page); + } finally { + await workspace.cleanup(); + } + }); + + test("keeps one prompt when its provider echo crosses the bounded tail", async ({ + page, + }, testInfo) => { + const workspace = await seedWorkspace({ + repoPrefix: `submission-echo-past-tail-${testInfo.workerIndex}-`, + }); + const prompt = "Emit 205 assistant messages before synthetic user message."; + try { + await openWorkspaceDraft(page, workspace.workspaceId); + await selectModel(page, "one-minute-stream"); + + await submitMessage(page, prompt); + const submittedPrompt = page.getByTestId("user-message").filter({ hasText: prompt }); + await expect(submittedPrompt).toHaveAttribute("aria-busy", "false", { timeout: 30_000 }); + await expectVisibleAgentSurfacesIdle(page); + await page.reload(); + + await expect(submittedPrompt).toHaveCount(0); + await scrollTimelineUntilOlderHistoryIsReachable(page, prompt); + await expect(submittedPrompt).toHaveCount(1); + await expect(submittedPrompt).toHaveAttribute("aria-busy", "false"); + } finally { + await workspace.cleanup(); + } + }); + + test("keeps one Stop action while submitting into a running agent", async ({ page }) => { + const gate = await installDaemonWebSocketGate(page); + const agent = await seedMockAgentWorkspace({ + repoPrefix: "submission-running-action-", + title: "Running submission action", + model: "one-minute-stream", + }); + try { + await openAgentRoute(page, agent); + await expectComposerVisible(page); + await submitMessage(page, "Keep running while the next prompt is submitted."); + await expectAgentReadyToInterrupt(page); + + gate.holdNextClientRequest("send_agent_message_request"); + await fillComposerDraft(page, "Replace the running turn without duplicating its action."); + await expect( + page.getByRole("button", { name: "Send and interrupt", exact: true }), + ).toHaveCount(1); + await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Interrupt agent", exact: true })).toHaveCount( + 0, + ); + await composerLocator(page).press("Enter"); + await gate.waitForHeldClientRequest(); + + await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toHaveCount(1); + await expect(page.getByRole("button", { name: "Interrupt agent", exact: true })).toHaveCount( + 0, + ); + + gate.releaseHeldClientRequest(); + } finally { + gate.restore(); + await agent.cleanup(); + } + }); + + test("makes the next queued turn interruptible after cancellation settles", async ({ page }) => { + const title = "Queued turn after interrupt"; + const secondPrompt = "Run the queued turn after interruption."; + const agent = await seedMockAgentWorkspace({ + repoPrefix: "submission-queued-after-interrupt-", + title, + model: "one-minute-stream", + }); + try { + const gate = await installDaemonWebSocketGate(page); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + await submitMessage(page, "Keep running until the queued turn is ready."); + await expectAgentReadyToInterrupt(page); + await queueMessage(page, secondPrompt); + await expect(page.getByRole("button", { name: "Send queued message now" })).toBeVisible(); + + gate.holdNextServerMessage("cancel_agent_response"); + await page.getByRole("button", { name: "Stop agent", exact: true }).click(); + await gate.waitForHeldServerMessage(); + + await expect(page.getByTestId("user-message").filter({ hasText: secondPrompt })).toHaveCount( + 1, + ); + await expectRunningAgentChrome(page, title); + await expectAgentReadyToInterrupt(page); + + gate.holdNextClientRequest("cancel_agent_request"); + await page.getByRole("button", { name: "Stop agent", exact: true }).click(); + await gate.waitForHeldClientRequest(); + await expect( + page.getByRole("button", { name: "Canceling agent", exact: true }), + ).toBeVisible(); + + gate.releaseHeldServerMessage(); + await expect( + page.getByRole("button", { name: "Canceling agent", exact: true }), + ).toBeVisible(); + gate.releaseHeldClientRequest(); + await expectVisibleAgentSurfacesIdle(page); + } finally { + await agent.cleanup(); + } + }); + test("shows every agent surface idle after interrupting a submitted turn", async ({ page }) => { const title = "Interrupted submission"; const agent = await seedMockAgentWorkspace({ diff --git a/packages/app/e2e/helpers/agent-stream.ts b/packages/app/e2e/helpers/agent-stream.ts index 0654ff5a40..313028ab77 100644 --- a/packages/app/e2e/helpers/agent-stream.ts +++ b/packages/app/e2e/helpers/agent-stream.ts @@ -35,6 +35,26 @@ export async function expectRunningAgentChrome(page: Page, title: string): Promi await expectInlineWorkingIndicator(page); } +export async function expectAgentReadyToInterrupt(page: Page): Promise { + await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByRole("button", { name: "Canceling agent", exact: true })).toHaveCount(0); +} + +export async function expectVisibleAgentSurfacesIdle(page: Page): Promise { + const visibleAgentTab = page + .getByTestId(/^workspace-tab-agent_/) + .filter({ visible: true }) + .first(); + + await expect(visibleAgentTab).toBeVisible({ timeout: 30_000 }); + await expect(visibleAgentTab.getByRole("progressbar", { name: "Agent running" })).toHaveCount(0); + await expect(page.getByRole("button", { name: /stop agent|canceling agent/i })).toHaveCount(0); + await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0); + await expect(page.getByTestId("turn-working-elapsed")).toHaveCount(0); +} + export async function expectAgentSurfacesIdle(page: Page, title: string): Promise { const tab = page.getByRole("button", { name: title, exact: true }); diff --git a/packages/app/e2e/helpers/daemon-websocket-gate.ts b/packages/app/e2e/helpers/daemon-websocket-gate.ts index 44d5a14ac1..2ed48f70ee 100644 --- a/packages/app/e2e/helpers/daemon-websocket-gate.ts +++ b/packages/app/e2e/helpers/daemon-websocket-gate.ts @@ -25,6 +25,14 @@ interface HeldAgentUpdate { status: string; } +interface HeldServerMessage { + browser: WebSocketRoute; + message: string | Buffer; + key: string; + agentStreamFollowers: Array; + blockedAgentId?: string; +} + function readSessionMessage(message: string | Buffer): ClientRequest | null { if (typeof message !== "string") return null; try { @@ -111,6 +119,14 @@ function readAgentStreamEventType(message: ClientRequest | null): string | null return typeof event?.type === "string" ? event.type : null; } +function readAgentStreamAgentId(message: ClientRequest | null): string | null { + if (message?.type !== "agent_stream" || !message.payload || typeof message.payload !== "object") { + return null; + } + const agentId = (message.payload as { agentId?: unknown }).agentId; + return typeof agentId === "string" ? agentId : null; +} + function readAgentStreamItemType(message: ClientRequest | null): string | null { if (message?.type !== "agent_stream" || !message.payload || typeof message.payload !== "object") { return null; @@ -138,13 +154,20 @@ function shouldSuppressServerMessage(input: { return Boolean(eventType && input.agentStreamEventTypes.has(eventType)); } -function shouldHoldServerMessage( - message: ClientRequest | null, - messageType: string | null, - agentUpdate: HeldAgentUpdate | null, -): boolean { - if (message?.type === messageType) return true; - if (message?.type !== "agent_update" || !agentUpdate) return false; +function serverMessageKey(type: string): string { + return `message:${type}`; +} + +function agentStreamEventKey(type: string): string { + return `agent-stream-event:${type}`; +} + +function agentUpdateKey(agentId: string, status: string): string { + return `agent-update:${agentId}:${status}`; +} + +function matchesAgentUpdate(message: ClientRequest | null, agentUpdate: HeldAgentUpdate): boolean { + if (message?.type !== "agent_update") return false; const payload = message.payload; if ( !payload || @@ -167,10 +190,9 @@ export async function installDaemonWebSocketGate(page: Page) { let heldClientRequestType: string | null = null; let heldClientRequest: { server: WebSocketRoute; message: string | Buffer } | null = null; let resolveHeldClientRequest: (() => void) | null = null; - let heldServerMessageType: string | null = null; - let heldAgentUpdate: HeldAgentUpdate | null = null; - let heldServerMessage: { browser: WebSocketRoute; message: string | Buffer } | null = null; - let resolveHeldServerMessage: (() => void) | null = null; + const pendingServerMessageHolds = new Map boolean>(); + const heldServerMessages: HeldServerMessage[] = []; + const heldServerMessageWaiters = new Set<() => void>(); const suppressedServerMessageTypes = new Set(); const suppressedAgentStreamEventTypes = new Set(); const suppressedAgentStreamItemTypes = new Set(); @@ -275,22 +297,42 @@ export async function installDaemonWebSocketGate(page: Page) { outboundMessage = forceTimelineReset(outboundMessage, shouldForceTimelineReset); if (shouldForceTimelineReset) forceTimelineEpochReset = false; recordServerMessage(serverMessage); - if (shouldHoldServerMessage(serverMessage, heldServerMessageType, heldAgentUpdate)) { - heldServerMessage = { browser: ws, message: outboundMessage }; - resolveHeldServerMessage?.(); - resolveHeldServerMessage = null; + const suppressed = shouldSuppressServerMessage({ + message: serverMessage, + messageTypes: suppressedServerMessageTypes, + agentStreamEventTypes: suppressedAgentStreamEventTypes, + agentStreamItemTypes: suppressedAgentStreamItemTypes, + suppressAgentStream, + }); + const agentStreamAgentId = readAgentStreamAgentId(serverMessage); + const blockedAgentStream = agentStreamAgentId + ? heldServerMessages.find((held) => held.blockedAgentId === agentStreamAgentId) + : undefined; + if (blockedAgentStream) { + if (!suppressed) blockedAgentStream.agentStreamFollowers.push(outboundMessage); return; } - if ( - shouldSuppressServerMessage({ - message: serverMessage, - messageTypes: suppressedServerMessageTypes, - agentStreamEventTypes: suppressedAgentStreamEventTypes, - agentStreamItemTypes: suppressedAgentStreamItemTypes, - suppressAgentStream, - }) - ) + const matchedHold = Array.from(pendingServerMessageHolds).find(([, matches]) => + matches(serverMessage), + ); + if (matchedHold) { + const [key] = matchedHold; + pendingServerMessageHolds.delete(key); + heldServerMessages.push({ + browser: ws, + message: outboundMessage, + key, + agentStreamFollowers: [], + blockedAgentId: + readAgentStreamEventType(serverMessage) === "turn_started" + ? (agentStreamAgentId ?? undefined) + : undefined, + }); + for (const resolve of heldServerMessageWaiters) resolve(); + heldServerMessageWaiters.clear(); return; + } + if (suppressed) return; try { ws.send(outboundMessage); } catch { @@ -334,27 +376,67 @@ export async function installDaemonWebSocketGate(page: Page) { heldClientRequestType = null; }, holdNextServerMessage(type: string): void { - heldServerMessageType = type; - heldAgentUpdate = null; - heldServerMessage = null; + pendingServerMessageHolds.set(serverMessageKey(type), (message) => message?.type === type); }, holdNextAgentUpdate(agentId: string, status: string): void { - heldServerMessageType = null; - heldAgentUpdate = { agentId, status }; - heldServerMessage = null; + const heldAgentUpdate = { agentId, status }; + pendingServerMessageHolds.set(agentUpdateKey(agentId, status), (message) => + matchesAgentUpdate(message, heldAgentUpdate), + ); }, - waitForHeldServerMessage(): Promise { - if (heldServerMessage) return Promise.resolve(); - return new Promise((resolve) => { - resolveHeldServerMessage = resolve; - }); + holdNextAgentStreamEvent(type: string): void { + pendingServerMessageHolds.set( + agentStreamEventKey(type), + (message) => readAgentStreamEventType(message) === type, + ); }, - releaseHeldServerMessage(): void { + async waitForHeldServerMessage(type?: string): Promise { + const key = type ? serverMessageKey(type) : null; + while (!heldServerMessages.some((message) => key === null || message.key === key)) { + await new Promise((resolve) => heldServerMessageWaiters.add(resolve)); + } + }, + releaseHeldServerMessage(type?: string): void { + const key = type ? serverMessageKey(type) : null; + const index = heldServerMessages.findIndex((message) => key === null || message.key === key); + const [heldServerMessage] = index >= 0 ? heldServerMessages.splice(index, 1) : []; if (!heldServerMessage) throw new Error("No held server message to release"); heldServerMessage.browser.send(heldServerMessage.message); - heldServerMessage = null; - heldServerMessageType = null; - heldAgentUpdate = null; + for (const follower of heldServerMessage.agentStreamFollowers) { + heldServerMessage.browser.send(follower); + } + }, + async waitForHeldAgentUpdate(agentId: string, status: string): Promise { + const key = agentUpdateKey(agentId, status); + while (!heldServerMessages.some((message) => message.key === key)) { + await new Promise((resolve) => heldServerMessageWaiters.add(resolve)); + } + }, + releaseHeldAgentUpdate(agentId: string, status: string): void { + const key = agentUpdateKey(agentId, status); + const index = heldServerMessages.findIndex((message) => message.key === key); + const [heldServerMessage] = index >= 0 ? heldServerMessages.splice(index, 1) : []; + if (!heldServerMessage) throw new Error("No held agent update to release"); + heldServerMessage.browser.send(heldServerMessage.message); + for (const follower of heldServerMessage.agentStreamFollowers) { + heldServerMessage.browser.send(follower); + } + }, + async waitForHeldAgentStreamEvent(type: string): Promise { + const key = agentStreamEventKey(type); + while (!heldServerMessages.some((message) => message.key === key)) { + await new Promise((resolve) => heldServerMessageWaiters.add(resolve)); + } + }, + releaseHeldAgentStreamEvent(type: string): void { + const key = agentStreamEventKey(type); + const index = heldServerMessages.findIndex((message) => message.key === key); + const [heldServerMessage] = index >= 0 ? heldServerMessages.splice(index, 1) : []; + if (!heldServerMessage) throw new Error("No held agent stream event to release"); + heldServerMessage.browser.send(heldServerMessage.message); + for (const follower of heldServerMessage.agentStreamFollowers) { + heldServerMessage.browser.send(follower); + } }, requestTimelineTail(agentId: string): void { if (!latestServer) throw new Error("No daemon WebSocket is connected"); @@ -373,6 +455,7 @@ export async function installDaemonWebSocketGate(page: Page) { ); }, getHeldTimelineLastItemType(): string | null { + const heldServerMessage = heldServerMessages[0]; if (!heldServerMessage) throw new Error("No held server message to inspect"); const response = readSessionMessage(heldServerMessage.message); const payload = response?.payload; @@ -383,6 +466,7 @@ export async function installDaemonWebSocketGate(page: Page) { return typeof last?.item?.type === "string" ? last.item.type : null; }, truncateHeldTimelineAfterLast(itemType: string): void { + const heldServerMessage = heldServerMessages[0]; if (!heldServerMessage || typeof heldServerMessage.message !== "string") { throw new Error("No held text server message to truncate"); } diff --git a/packages/app/src/client-slash-commands/index.test.ts b/packages/app/src/client-slash-commands/index.test.ts index 3fd4e4b7c6..768602a3d8 100644 --- a/packages/app/src/client-slash-commands/index.test.ts +++ b/packages/app/src/client-slash-commands/index.test.ts @@ -53,6 +53,7 @@ function createAgent(overrides: Partial = {}): Agent { parentAgentId: null, labels: {}, ...overrides, + activeTurn: overrides.activeTurn ?? null, }; } diff --git a/packages/app/src/composer/actions.test.ts b/packages/app/src/composer/actions.test.ts index 1597b82025..3923ef9eb3 100644 --- a/packages/app/src/composer/actions.test.ts +++ b/packages/app/src/composer/actions.test.ts @@ -291,7 +291,6 @@ describe("cancelComposerAgent", () => { isAgentRunning: boolean; isCancellingAgent: boolean; isConnected: boolean; - onCancelFailed: (error: unknown) => void; } { const canceledIds: string[] = []; return { @@ -305,53 +304,45 @@ describe("cancelComposerAgent", () => { isAgentRunning: true, isCancellingAgent: false, isConnected: true, - onCancelFailed: () => undefined, }; } - it("issues a cancel and reports true when the agent is running, connected, and not already canceling", () => { + it("returns the cancel request when the agent is running, connected, and not already canceling", async () => { const input = baseInput(); const result = cancelComposerAgent(input); - expect(result).toBe(true); + expect(result).not.toBeNull(); + await result; expect(input.client.canceledIds).toEqual(["agent"]); }); - it("reports a rejected cancel so the composer can leave its canceling state", async () => { + it("returns a rejected cancel request to the composer", async () => { const cancellationError = new Error("Provider rejected the interrupt"); - const failures: unknown[] = []; const input = baseInput(); input.client.cancelAgent = async () => { throw cancellationError; }; - const result = cancelComposerAgent({ - ...input, - onCancelFailed: (error: unknown) => failures.push(error), - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(result).toBe(true); - expect(failures).toEqual([cancellationError]); + await expect(cancelComposerAgent(input)).rejects.toBe(cancellationError); }); it("does nothing when the agent is not running", () => { const input = baseInput(); const result = cancelComposerAgent({ ...input, isAgentRunning: false }); - expect(result).toBe(false); + expect(result).toBeNull(); expect(input.client.canceledIds).toEqual([]); }); it("does nothing when the agent is already being canceled", () => { const input = baseInput(); const result = cancelComposerAgent({ ...input, isCancellingAgent: true }); - expect(result).toBe(false); + expect(result).toBeNull(); expect(input.client.canceledIds).toEqual([]); }); it("does nothing when disconnected or the client is null", () => { const input = baseInput(); - expect(cancelComposerAgent({ ...input, isConnected: false })).toBe(false); - expect(cancelComposerAgent({ ...input, client: null })).toBe(false); + expect(cancelComposerAgent({ ...input, isConnected: false })).toBeNull(); + expect(cancelComposerAgent({ ...input, client: null })).toBeNull(); expect(input.client.canceledIds).toEqual([]); }); }); diff --git a/packages/app/src/composer/actions.ts b/packages/app/src/composer/actions.ts index d622269aa2..7b454f1309 100644 --- a/packages/app/src/composer/actions.ts +++ b/packages/app/src/composer/actions.ts @@ -139,19 +139,16 @@ export interface CancelComposerAgentInput { isAgentRunning: boolean; isCancellingAgent: boolean; isConnected: boolean; - onCancelFailed: (error: unknown) => void; } -export function cancelComposerAgent(input: CancelComposerAgentInput): boolean { - if (!input.isAgentRunning || input.isCancellingAgent) return false; - if (!input.isConnected || !input.client) return false; +export function cancelComposerAgent(input: CancelComposerAgentInput): Promise | null { + if (!input.isAgentRunning || input.isCancellingAgent) return null; + if (!input.isConnected || !input.client) return null; try { - void Promise.resolve(input.client.cancelAgent(input.agentId)).catch(input.onCancelFailed); + return Promise.resolve(input.client.cancelAgent(input.agentId)); } catch (error) { - input.onCancelFailed(error); - return false; + return Promise.reject(error); } - return true; } export interface DispatchComposerAgentMessageInput { diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 4b2de9e560..97f19a7741 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -246,6 +246,7 @@ function buildDraftAgentSnapshot(input: { id: tabId, provider, status: "running", + activeTurn: null, createdAt: now, updatedAt: now, lastUserMessageAt: now, diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 3333f740b2..0b9a94b75d 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -910,20 +910,6 @@ function ComposerCancelButton({ ); } -interface ComposerCancelButtonSlotProps extends ComposerCancelButtonProps { - isAgentRunning: boolean; - hasSendableContent: boolean; -} - -function ComposerCancelButtonSlot({ - isAgentRunning, - hasSendableContent, - ...rest -}: ComposerCancelButtonSlotProps) { - if (!isAgentRunning || hasSendableContent) return null; - return ; -} - interface ComposerVoiceModeButtonProps { buttonIconSize: number; handleToggleRealtimeVoice: () => void; @@ -942,7 +928,6 @@ interface ComposerRightControlsSlotProps extends ComposerVoiceModeButtonProps { isAgentRunning: boolean; hasSendableContent: boolean; isCompact: boolean; - cancelButton: ReactElement; } function ComposerRightControlsSlot({ @@ -951,18 +936,15 @@ function ComposerRightControlsSlot({ isAgentRunning, hasSendableContent, isCompact, - cancelButton, ...voiceProps }: ComposerRightControlsSlotProps) { const hideVoiceForCompactInput = isCompact && hasSendableContent; const showVoiceModeButton = !isVoiceModeForAgent && hasAgent && !isAgentRunning && !hideVoiceForCompactInput; - const shouldShowCancelButton = isAgentRunning && !hasSendableContent; - if (!showVoiceModeButton && !shouldShowCancelButton) return null; + if (!showVoiceModeButton) return null; return ( - {showVoiceModeButton ? : null} - {cancelButton} + ); } @@ -1119,7 +1101,6 @@ export function Composer({ const [cursorIndex, setCursorIndex] = useState(0); const [isProcessing, setIsProcessing] = useState(false); const [isUploadingFile, setIsUploadingFile] = useState(false); - const [isCancellingAgent, setIsCancellingAgent] = useState(false); const [sendError, setSendError] = useState(null); const [isMessageInputFocused, setIsMessageInputFocused] = useState(false); const [isGithubPickerOpen, setIsGithubPickerOpen] = useState(false); @@ -1299,7 +1280,12 @@ export function Composer({ const hasActiveTurn = useSessionStore( (state) => selectAgentTurnPresentation(state.sessions[serverId], agentId).isActive, ); - const isAgentRunning = agentState.status === "running" || hasActiveTurn; + const isCancellingAgent = useSessionStore( + (state) => selectAgentTurnPresentation(state.sessions[serverId], agentId).isCancelling, + ); + const beginAgentCancellation = useSessionStore((state) => state.beginAgentCancellation); + const settleAgentCancellation = useSessionStore((state) => state.settleAgentCancellation); + const isAgentRunning = hasActiveTurn; const hasAgent = agentState.status !== null; const queueWriter = useMemo( @@ -1533,31 +1519,37 @@ export function Composer({ [openAttachment], ); - useEffect(() => { - if (!isAgentRunning || !isConnected) { - setIsCancellingAgent(false); - } - }, [isAgentRunning, isConnected]); - const handleCancelAgent = useCallback(() => { - const didCancel = cancelComposerAgent({ + const targetAgentId = agentIdRef.current; + const cancellation = cancelComposerAgent({ client, - agentId: agentIdRef.current, + agentId: targetAgentId, isAgentRunning, isCancellingAgent, isConnected, - onCancelFailed: (error) => { - setIsCancellingAgent(false); + }); + if (!cancellation) return; + const requestId = beginAgentCancellation(serverId, targetAgentId); + void cancellation + .catch((error) => { const message = resolveErrorMessage(error); if (message && message.trim().length > 0) { toastErrorRef.current(message); } - }, - }); - if (!didCancel) return; - setIsCancellingAgent(true); + }) + .finally(() => { + settleAgentCancellation(serverId, targetAgentId, requestId); + }); messageInputRef.current?.focus(); - }, [client, isAgentRunning, isCancellingAgent, isConnected]); + }, [ + beginAgentCancellation, + client, + isAgentRunning, + isCancellingAgent, + isConnected, + serverId, + settleAgentCancellation, + ]); const focusMessageInputForKeyboardAction = useCallback(() => { focusMessageInputWithPlatformStrategy(messageInputRef); @@ -1691,11 +1683,9 @@ export function Composer({ [isCompactLayout, voiceButtonDisabled], ); - const cancelButton = useMemo( + const activeActionContent = useMemo( () => ( - ), [ buttonIconSize, - cancelButton, handleToggleRealtimeVoice, hasAgent, hasSendableContent, @@ -1758,8 +1744,7 @@ export function Composer({ agentState.contextWindowUsedTokens, ); - const contextWindowPending = - agentState.status === "initializing" || agentState.status === "running"; + const contextWindowPending = agentState.status === "initializing" || isAgentRunning; const contextWindowMeterGlyphSize = isCompactLayout ? ICON_SIZE.md : buttonIconSize; const contextWindowMeter = useMemo( @@ -2081,6 +2066,7 @@ export function Composer({ leftContent={leftContent} beforeVoiceContent={beforeVoiceContent} rightContent={rightContent} + activeActionContent={activeActionContent} voiceServerId={serverId} voiceAgentId={agentId} isAgentRunning={isAgentRunning} diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index cbe92e3304..902ca80025 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -120,8 +120,10 @@ export interface MessageInputProps { leftContent?: React.ReactNode; /** Content to render on the right side before the voice button (e.g., context window meter) */ beforeVoiceContent?: React.ReactNode; - /** Content to render on the right side after voice button (e.g., realtime button, cancel button) */ + /** Auxiliary content to render on the right side after the voice button. */ rightContent?: React.ReactNode; + /** Primary action to render when the agent is active and the composer has no sendable content. */ + activeActionContent?: React.ReactNode; voiceServerId?: string; voiceAgentId?: string; /** When true and there's sendable content, calls onQueue instead of onSubmit */ @@ -778,6 +780,41 @@ function SendButtonTooltip({ ); } +type PrimaryActionKind = "send" | "active" | "none"; + +function hasSendableComposerContent(input: { + value: string; + attachments: readonly ComposerAttachment[]; + hasExternalContent: boolean; +}): boolean { + return input.value.trim().length > 0 || input.attachments.length > 0 || input.hasExternalContent; +} + +function resolvePrimaryActionKind(input: { + hasSendableContent: boolean; + allowEmptySubmit: boolean; + isAgentRunning: boolean; + isSubmitLoading: boolean; +}): PrimaryActionKind { + if (input.hasSendableContent || input.allowEmptySubmit) return "send"; + if (input.isAgentRunning) return "active"; + if (input.isSubmitLoading) return "send"; + return "none"; +} + +function PrimaryAction({ + kind, + activeActionContent, + ...sendButtonProps +}: { + kind: PrimaryActionKind; + activeActionContent: React.ReactNode; +} & React.ComponentProps) { + if (kind === "active") return activeActionContent; + if (kind === "send") return ; + return null; +} + interface DictationTranscriptContext { value: string; defaultSendBehavior: "interrupt" | "queue"; @@ -963,30 +1000,6 @@ function computeShouldShowDictationOverlay( return isDictating || isDictationProcessing || dictationStatus === "failed"; } -interface SendableContentInput { - value: string; - attachments: ComposerAttachment[]; - hasExternalContent: boolean; - allowEmptySubmit: boolean; - isSubmitLoading: boolean; -} - -interface SendableContentOutput { - hasAttachments: boolean; - hasRealContent: boolean; - hasSendableContent: boolean; - shouldShowSendButton: boolean; -} - -function computeSendableContent(input: SendableContentInput): SendableContentOutput { - const hasAttachments = input.attachments.length > 0; - const hasRealContent = input.value.trim().length > 0 || hasAttachments; - const hasSendableContent = hasRealContent || input.hasExternalContent; - const shouldShowSendButton = - hasSendableContent || input.allowEmptySubmit || input.isSubmitLoading; - return { hasAttachments, hasRealContent, hasSendableContent, shouldShowSendButton }; -} - function computeIsDictationStartEnabled( isReadyForDictation: boolean | undefined, isConnected: boolean, @@ -1082,6 +1095,7 @@ interface ResolvedMessageInputProps { leftContent: React.ReactNode; beforeVoiceContent: React.ReactNode; rightContent: React.ReactNode; + activeActionContent: React.ReactNode; voiceServerId: string | undefined; voiceAgentId: string | undefined; isAgentRunning: boolean; @@ -1124,6 +1138,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu leftContent: props.leftContent, beforeVoiceContent: props.beforeVoiceContent, rightContent: props.rightContent, + activeActionContent: props.activeActionContent, voiceServerId: props.voiceServerId, voiceAgentId: props.voiceAgentId, isAgentRunning: props.isAgentRunning ?? false, @@ -1174,6 +1189,7 @@ export const MessageInput = forwardRef( leftContent, beforeVoiceContent, rightContent, + activeActionContent, voiceServerId, voiceAgentId, isAgentRunning, @@ -1588,11 +1604,14 @@ export const MessageInput = forwardRef( }); } - const { shouldShowSendButton } = computeSendableContent({ - value, - attachments, - hasExternalContent, + const primaryActionKind = resolvePrimaryActionKind({ + hasSendableContent: hasSendableComposerContent({ + value, + attachments, + hasExternalContent, + }), allowEmptySubmit, + isAgentRunning, isSubmitLoading, }); const { canPressLoadingButton, isSendButtonDisabled, defaultActionQueues } = @@ -1791,8 +1810,10 @@ export const MessageInput = forwardRef( dictationToggleKeys={dictationToggleKeys} /> {rightContent} - { expect(getSendingClientMessageIds(both)).toEqual(["client-1", "client-2"]); }); - it("removes only the provider-acknowledged transaction on RPC acceptance", () => { + it("settles only the accepted transaction on RPC acceptance", () => { const both = beginMessageSubmission( beginMessageSubmission([], { clientMessageId: "client-1", submittedAt }), { clientMessageId: "client-2", submittedAt }, @@ -36,37 +36,27 @@ describe("message submission transactions", () => { { clientMessageId: "client-2", submittedAt, - rpcAccepted: false, providerAcknowledged: false, + rpcSettled: false, }, ]); }); - it("keeps an accepted RPC active until canonical acknowledgement", () => { + it("keeps activity until an accepted RPC's canonical row is observed", () => { const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt }); const accepted = acceptMessageSubmission(sending, "client-1"); - expect(getActiveMessageSubmissions(accepted)).toHaveLength(1); - expect(accepted[0].rpcAccepted).toBe(true); - }); - - it("does not settle RPC acceptance from directory running status", () => { - const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt }); - - expect(acceptMessageSubmission(sending, "client-1")).toEqual([ + expect(accepted).toEqual([ { clientMessageId: "client-1", submittedAt, - rpcAccepted: true, providerAcknowledged: false, + rpcSettled: true, }, ]); - }); - - it("settles an accepted RPC when provider acknowledgement arrives after running was missed", () => { - const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt }); - const accepted = acceptMessageSubmission(sending, "client-1"); - + expect(getActiveMessageSubmissions(accepted).map((item) => item.clientMessageId)).toEqual([ + "client-1", + ]); expect(observeMessageSubmissionCanonical(accepted, ["client-1"])).toEqual([]); }); @@ -81,14 +71,14 @@ describe("message submission transactions", () => { { clientMessageId: "client-1", submittedAt, - rpcAccepted: false, providerAcknowledged: true, + rpcSettled: false, }, { clientMessageId: "client-2", submittedAt, - rpcAccepted: false, providerAcknowledged: false, + rpcSettled: false, }, ]); expect(getSendingClientMessageIds(observed)).toEqual(["client-2"]); diff --git a/packages/app/src/composer/submission/model.ts b/packages/app/src/composer/submission/model.ts index db8bafc1ea..4246f6a938 100644 --- a/packages/app/src/composer/submission/model.ts +++ b/packages/app/src/composer/submission/model.ts @@ -4,8 +4,8 @@ export interface PendingMessageSubmission { } export type MessageSubmissionRecord = PendingMessageSubmission & { - rpcAccepted: boolean; providerAcknowledged: boolean; + rpcSettled: boolean; }; const EMPTY_MESSAGE_SUBMISSIONS: readonly MessageSubmissionRecord[] = []; @@ -13,7 +13,9 @@ const EMPTY_MESSAGE_SUBMISSIONS: readonly MessageSubmissionRecord[] = []; export function getActiveMessageSubmissions( submissions: readonly MessageSubmissionRecord[] | null | undefined, ): readonly PendingMessageSubmission[] { - return submissions ?? EMPTY_MESSAGE_SUBMISSIONS; + return (submissions ?? EMPTY_MESSAGE_SUBMISSIONS).filter( + (submission) => !submission.providerAcknowledged, + ); } export function getSendingClientMessageIds( @@ -38,7 +40,7 @@ export function beginMessageSubmission( if (submissions.some((submission) => submission.clientMessageId === input.clientMessageId)) { throw new Error(`Message submission already exists: ${input.clientMessageId}`); } - return [...submissions, { ...input, rpcAccepted: false, providerAcknowledged: false }]; + return [...submissions, { ...input, providerAcknowledged: false, rpcSettled: false }]; } export function acceptMessageSubmission( @@ -49,13 +51,14 @@ export function acceptMessageSubmission( (submission) => submission.clientMessageId === clientMessageId, ); if (index < 0) return submissions as MessageSubmissionRecord[]; - if (submissions[index].providerAcknowledged) { + const submission = submissions[index]; + if (submission.providerAcknowledged) { return submissions.filter((_, submissionIndex) => submissionIndex !== index); } - if (submissions[index].rpcAccepted) return submissions as MessageSubmissionRecord[]; - const next = submissions.slice(); - next[index] = { ...next[index], rpcAccepted: true }; - return next; + if (submission.rpcSettled) return submissions as MessageSubmissionRecord[]; + return submissions.map((item, submissionIndex) => + submissionIndex === index ? { ...item, rpcSettled: true } : item, + ); } export function observeMessageSubmissionCanonical( @@ -70,7 +73,7 @@ export function observeMessageSubmissionCanonical( return [submission]; } changed = true; - return submission.rpcAccepted ? [] : [{ ...submission, providerAcknowledged: true }]; + return submission.rpcSettled ? [] : [{ ...submission, providerAcknowledged: true }]; }); return changed ? next : (submissions as MessageSubmissionRecord[]); } @@ -84,7 +87,7 @@ export function rejectMessageSubmission( return { outcome: "unknown", submissions: submissions as MessageSubmissionRecord[] }; } return { - outcome: submission.providerAcknowledged || submission.rpcAccepted ? "accepted" : "rejected", + outcome: submission.providerAcknowledged ? "accepted" : "rejected", submissions: submissions.filter((item) => item.clientMessageId !== clientMessageId), }; } diff --git a/packages/app/src/composer/submission/writer.test.ts b/packages/app/src/composer/submission/writer.test.ts new file mode 100644 index 0000000000..3ee174d554 --- /dev/null +++ b/packages/app/src/composer/submission/writer.test.ts @@ -0,0 +1,49 @@ +import { afterEach, expect, test } from "vitest"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { useSessionStore } from "@/stores/session-store"; +import type { UserMessageItem } from "@/types/stream"; +import { createMessageSubmissionWriter } from "./writer"; + +const CANONICAL_ONLY_SERVER_ID = "canonical-only"; + +function submittedMessage(clientMessageId: string): UserMessageItem { + return { + kind: "user_message", + id: clientMessageId, + clientMessageId, + text: "hello", + timestamp: new Date("2026-07-31T10:00:00.000Z"), + }; +} + +function initializeSession(serverId: string, features: Record): void { + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "0.2.6", + features, + }); +} + +function submissionIds(serverId: string): string[] { + return ( + useSessionStore.getState().sessions[serverId]?.messageSubmissions.get("agent-1") ?? [] + ).map((submission) => submission.clientMessageId); +} + +afterEach(() => { + useSessionStore.getState().clearSession(CANONICAL_ONLY_SERVER_ID); +}); + +test("tracks submissions when canonical prompts are supported", () => { + initializeSession(CANONICAL_ONLY_SERVER_ID, { canonicalSubmittedPrompts: true }); + + createMessageSubmissionWriter(CANONICAL_ONLY_SERVER_ID).begin( + "agent-1", + submittedMessage("canonical-only-message"), + ); + + expect(submissionIds(CANONICAL_ONLY_SERVER_ID)).toEqual(["canonical-only-message"]); +}); diff --git a/packages/app/src/composer/submission/writer.ts b/packages/app/src/composer/submission/writer.ts index b9211cedef..f8b40034b6 100644 --- a/packages/app/src/composer/submission/writer.ts +++ b/packages/app/src/composer/submission/writer.ts @@ -45,15 +45,12 @@ function createUntrackedMessageSubmissionWriter(serverId: string): MessageSubmis }; } -/** - * Binds message submission presentation to a host session. Capable hosts use the tracked - * two-phase lifecycle; older hosts preserve the untracked optimistic-row behavior. - */ export function createMessageSubmissionWriter(serverId: string): MessageSubmissionWriter { - const supportsCanonicalSubmittedPrompts = + const supportsTrackedMessageSubmissions = useSessionStore.getState().sessions[serverId]?.serverInfo?.features ?.canonicalSubmittedPrompts === true; - if (!supportsCanonicalSubmittedPrompts) { + if (!supportsTrackedMessageSubmissions) { + // COMPAT(canonicalSubmittedPrompts): added in v0.2.6; remove the gate after 2027-01-31 once daemon floor >= v0.2.6. return createUntrackedMessageSubmissionWriter(serverId); } return { @@ -71,10 +68,5 @@ export function handoffCreatedAgentMessageSubmission( agentId: string, message: UserMessageItem, ): boolean { - const supportsCanonicalSubmittedPrompts = - useSessionStore.getState().sessions[serverId]?.serverInfo?.features - ?.canonicalSubmittedPrompts === true; - return useSessionStore - .getState() - .handoffCreatedAgentUserMessage(serverId, agentId, message, supportsCanonicalSubmittedPrompts); + return useSessionStore.getState().handoffCreatedAgentUserMessage(serverId, agentId, message); } diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 3073ac6f29..22e90575a6 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -14,6 +14,7 @@ import { prefetchProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { generateMessageId, type StreamItem } from "@/types/stream"; import { createSessionAgentStreamReducerQueue, + deriveAgentStreamTurnLiveness, processTimelineResponse, type ProcessTimelineResponseOutput, type TimelineReducerSideEffect, @@ -619,17 +620,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider return; } - if (shouldMarkAuthoritativeHistoryApplied && payload.agent) { - applyAgentTurnLiveness(serverId, agentId, { - type: "resume_snapshot", - status: payload.agent.status, - startedAt: payload.agent.lastUserMessageAt - ? new Date(payload.agent.lastUserMessageAt) - : null, - coverage: { epoch: payload.epoch, seq: payload.window.maxSeq }, - }); - } - if (result.commit === "discard") { if (result.acknowledgedClientMessageIds.length > 0) { setAgentStreamState(serverId, agentId, { @@ -665,7 +655,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider }, [ applyAgentTimelineResponseState, - applyAgentTurnLiveness, markAgentHistorySynchronized, recoverTimelineGap, serverId, @@ -750,7 +739,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const agentStreamReducerQueue = createSessionAgentStreamReducerQueue({ serverId, setAgentStreamState, - applyAgentTurnLiveness, setAgentTimelineCursor, recoverTimelineGap, }); @@ -768,6 +756,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider ) { voiceRuntime?.onTurnEvent(serverId, agentId, event.type); } + const turnLiveness = deriveAgentStreamTurnLiveness([ + { event: streamEvent, seq, epoch, timestamp: parsedTimestamp }, + ]); + if (turnLiveness.length > 0) { + applyAgentTurnLiveness(serverId, agentId, turnLiveness); + } agentStreamReducerQueue.enqueue(agentId, { event: streamEvent, seq, diff --git a/packages/app/src/contexts/session-status-tracking.test.ts b/packages/app/src/contexts/session-status-tracking.test.ts index c5235df867..346127b1ff 100644 --- a/packages/app/src/contexts/session-status-tracking.test.ts +++ b/packages/app/src/contexts/session-status-tracking.test.ts @@ -8,6 +8,7 @@ function createAgent(status: Agent["status"]): Agent { id: "agent-1", provider: "codex", status, + activeTurn: null, createdAt: new Date(0), updatedAt: new Date(0), lastUserMessageAt: null, diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts index 141132942f..88cce1e35b 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts @@ -17,6 +17,7 @@ function createAgent(id: string): Agent { id, provider: "claude", status: "running", + activeTurn: { turnId: "turn-1", startedAt: now }, createdAt: now, updatedAt: now, lastUserMessageAt: now, diff --git a/packages/app/src/hooks/use-archive-agent.test.ts b/packages/app/src/hooks/use-archive-agent.test.ts index aad9fc4e28..cd4d433f74 100644 --- a/packages/app/src/hooks/use-archive-agent.test.ts +++ b/packages/app/src/hooks/use-archive-agent.test.ts @@ -41,6 +41,7 @@ function makeAgent(overrides: Partial = {}): Agent { labels: {}, archivedAt: null, ...overrides, + activeTurn: overrides.activeTurn ?? null, }; } diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 5e49bc32aa..62373fb243 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1298,16 +1298,18 @@ const AgentStreamSection = memo(function AgentStreamSection({ const streamItemsRaw = useSessionStore((state) => agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined, ); - const pendingMessageSubmissions = useSessionStore((state) => - agentId - ? getActiveMessageSubmissions(state.sessions[serverId]?.messageSubmissions.get(agentId)) - : EMPTY_MESSAGE_SUBMISSIONS, + const pendingMessageSubmissions = useSessionStore( + useShallow((state) => + agentId + ? getActiveMessageSubmissions(state.sessions[serverId]?.messageSubmissions.get(agentId)) + : EMPTY_MESSAGE_SUBMISSIONS, + ), ); const turnPresentation = useSessionStore( useShallow((state) => agentId ? selectAgentTurnPresentation(state.sessions[serverId], agentId) - : { isActive: false, startedAt: null }, + : { isActive: false, isCancelling: false, startedAt: null, turnId: null }, ), ); const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS; diff --git a/packages/app/src/panels/provider-subagent-panel.tsx b/packages/app/src/panels/provider-subagent-panel.tsx index aae1e44907..e744b1b394 100644 --- a/packages/app/src/panels/provider-subagent-panel.tsx +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -160,7 +160,12 @@ function ProviderSubagentPanel() { [isLoadingOlder, loadOlder, progressKey, timeline?.hasOlder], ); const turnPresentation = useMemo( - () => ({ isActive: descriptor?.status === "running", startedAt: null }), + () => ({ + isActive: descriptor?.status === "running", + isCancelling: false, + startedAt: null, + turnId: null, + }), [descriptor?.status], ); diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 7507dd17d2..74cdf85316 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -2697,6 +2697,12 @@ describe("HostRuntimeStore", () => { }).agent; const staleAgent: Agent = { ...stale, + activeTurn: stale.activeTurn + ? { + turnId: stale.activeTurn.turnId, + startedAt: stale.activeTurn.startedAt ? new Date(stale.activeTurn.startedAt) : null, + } + : null, serverId: host.serverId, createdAt: new Date(stale.createdAt), updatedAt: new Date(stale.updatedAt), diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index 6816ba41af..591a0ac985 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -5,6 +5,7 @@ import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages"; import { normalizeWorkspaceDescriptor, + selectAgentTurnPresentation, selectAgentTimelineState, useSessionStore, type WorkspaceDescriptor, @@ -55,6 +56,31 @@ function getTestSessionReferences() { }; } +function submittedMessage(clientMessageId: string): Extract { + return { + kind: "user_message", + id: clientMessageId, + clientMessageId, + text: clientMessageId, + timestamp: new Date("2026-07-31T10:00:00.000Z"), + }; +} + +function hasSubmittedMessage(items: readonly StreamItem[] | undefined, clientMessageId: string) { + return items?.some( + (item) => item.kind === "user_message" && item.clientMessageId === clientMessageId, + ); +} + +function permutations(values: readonly T[]): T[][] { + if (values.length === 0) return [[]]; + return values.flatMap((value, index) => + permutations([...values.slice(0, index), ...values.slice(index + 1)]).map((suffix) => + [value].concat(suffix), + ), + ); +} + describe("agent timeline state", () => { it("commits canonical items, range, and older availability as one synced state", () => { initializeTestSession(); @@ -137,25 +163,181 @@ describe("agent timeline state", () => { initializeTestSession(); const store = useSessionStore.getState(); store.applyAgentTurnLiveness("test-server", "agent-1", { - type: "directory_running", - startedAt: null, + type: "snapshot", + activeTurn: { turnId: "turn-1", startedAt: null }, }); expect(store.getSession("test-server")?.agentTurnLiveness.get("agent-1")).toEqual({ phase: "open", + turnId: "turn-1", startedAt: null, - evidence: null, + cancellationRequestId: null, }); store.applyAgentTurnLiveness("test-server", "agent-1", { - type: "resume_snapshot", - status: "idle", - startedAt: null, - coverage: { epoch: "epoch-1", seq: 40 }, + type: "snapshot", + activeTurn: null, }); expect(store.getSession("test-server")?.agentTurnLiveness.has("agent-1")).toBe(false); }); }); +describe("message submission ordering", () => { + const agentId = "agent-1"; + const clientMessageId = "client-1"; + const startedAt = new Date("2026-07-31T10:00:01.000Z"); + const steps = [ + "accept", + "canonical-row", + "stream-open", + "snapshot-idle", + "snapshot-open", + "stream-close", + ] as const; + + function applyStep(step: (typeof steps)[number]): void { + const store = useSessionStore.getState(); + if (step === "accept") { + store.acceptAgentMessageSubmission("test-server", agentId, clientMessageId); + return; + } + if (step === "canonical-row") { + store.setAgentStreamState("test-server", agentId, { + acknowledgedClientMessageIds: [clientMessageId], + }); + return; + } + if (step === "stream-open") { + store.applyAgentTurnLiveness("test-server", agentId, { + type: "stream_open", + turn: { turnId: "turn-1", startedAt }, + }); + return; + } + if (step === "snapshot-open") { + store.applyAgentTurnLiveness("test-server", agentId, { + type: "snapshot", + activeTurn: { turnId: "turn-1", startedAt }, + }); + return; + } + if (step === "snapshot-idle") { + store.applyAgentTurnLiveness("test-server", agentId, { + type: "snapshot", + activeTurn: null, + }); + return; + } + store.applyAgentTurnLiveness("test-server", agentId, { + type: "stream_close", + turnId: "turn-1", + }); + } + + it("keeps every ordering active until its canonical row and settles at quiescence", () => { + const continuityViolations: string[] = []; + const settlementViolations: string[] = []; + + for (const ordering of permutations(steps)) { + useSessionStore.getState().clearSession("test-server"); + initializeTestSession(); + useSessionStore + .getState() + .beginAgentMessageSubmission("test-server", agentId, submittedMessage(clientMessageId)); + let canonicalObserved = false; + + for (const step of ordering) { + applyStep(step); + canonicalObserved ||= step === "canonical-row"; + const presentation = selectAgentTurnPresentation( + useSessionStore.getState().sessions["test-server"], + agentId, + ); + if (!canonicalObserved && !presentation.isActive) { + continuityViolations.push(`${ordering.join(" → ")}: inactive after ${step}`); + } + } + + const store = useSessionStore.getState(); + store.acceptAgentMessageSubmission("test-server", agentId, clientMessageId); + store.setAgentStreamState("test-server", agentId, { + acknowledgedClientMessageIds: [clientMessageId], + }); + store.applyAgentTurnLiveness("test-server", agentId, [ + { type: "stream_close", turnId: "turn-1" }, + { type: "snapshot", activeTurn: null }, + ]); + const settled = selectAgentTurnPresentation( + useSessionStore.getState().sessions["test-server"], + agentId, + ); + if (settled.isActive) settlementViolations.push(ordering.join(" → ")); + } + + expect({ continuityViolations, settlementViolations }).toEqual({ + continuityViolations: [], + settlementViolations: [], + }); + }); + + it("publishes the optimistic row and active presentation in one store notification", () => { + initializeTestSession(); + const notifications: Array<{ hasOptimisticRow: boolean; isActive: boolean }> = []; + const unsubscribe = useSessionStore.subscribe((state) => { + const session = state.sessions["test-server"]; + notifications.push({ + hasOptimisticRow: + hasSubmittedMessage(session?.agentStreamTail.get(agentId), clientMessageId) === true, + isActive: selectAgentTurnPresentation(session, agentId).isActive, + }); + }); + + useSessionStore + .getState() + .beginAgentMessageSubmission("test-server", agentId, submittedMessage(clientMessageId)); + unsubscribe(); + + expect(notifications).toEqual([{ hasOptimisticRow: true, isActive: true }]); + }); + + it("keeps another unacknowledged submission active after one row is acknowledged", () => { + initializeTestSession(); + const store = useSessionStore.getState(); + store.beginAgentMessageSubmission("test-server", agentId, submittedMessage(clientMessageId)); + store.beginAgentMessageSubmission("test-server", agentId, submittedMessage("client-2")); + store.acceptAgentMessageSubmission("test-server", agentId, clientMessageId); + store.setAgentStreamState("test-server", agentId, { + acknowledgedClientMessageIds: [clientMessageId], + }); + + expect( + selectAgentTurnPresentation(useSessionStore.getState().sessions["test-server"], agentId) + .isActive, + ).toBe(true); + }); + + it("reconciles a created-agent row without creating a submission transaction", () => { + initializeTestSession(); + const message = submittedMessage(clientMessageId); + + const handedOff = useSessionStore + .getState() + .handoffCreatedAgentUserMessage("test-server", agentId, message); + + const session = useSessionStore.getState().sessions["test-server"]; + expect({ + handedOff, + tail: session?.agentStreamTail.get(agentId), + head: session?.agentStreamHead.get(agentId) ?? [], + submissions: session?.messageSubmissions.get(agentId) ?? [], + }).toEqual({ + handedOff: true, + tail: [message], + head: [], + submissions: [], + }); + }); +}); + describe("normalizeWorkspaceDescriptor", () => { it("normalizes workspace scripts and invalid activity timestamps", () => { const scripts = [ diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index dc057d1b42..b7102c9d68 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -7,7 +7,6 @@ import type { AgentDirectoryEntry } from "@/types/agent-directory"; import { appendSubmittedUserMessage, handoffCreatedAgentUserMessageToStream, - isUnreconciledLocalUserMessage, removeSubmittedUserMessage, type StreamItem, type UserMessageItem, @@ -117,6 +116,7 @@ export interface Agent { id: string; provider: AgentProvider; status: AgentLifecycleStatus; + activeTurn: { turnId: string | null; startedAt: Date | null } | null; createdAt: Date; updatedAt: Date; lastUserMessageAt: Date | null; @@ -525,8 +525,10 @@ interface SessionStoreActions { applyAgentTurnLiveness: ( serverId: string, agentId: string, - transition: TurnLivenessTransition, + transition: TurnLivenessTransition | readonly TurnLivenessTransition[], ) => void; + beginAgentCancellation: (serverId: string, agentId: string) => number; + settleAgentCancellation: (serverId: string, agentId: string, requestId: number) => void; clearAgentTurnLiveness: (serverId: string) => void; beginAgentMessageSubmission: ( serverId: string, @@ -547,7 +549,6 @@ interface SessionStoreActions { serverId: string, agentId: string, message: UserMessageItem, - trackSubmission: boolean, ) => boolean; clearAgentStreamHead: (serverId: string, agentId: string) => void; setAgentTimelineCursor: ( @@ -749,6 +750,7 @@ function isSessionServerInfoUnchanged(input: { export const useSessionStore = create()( subscribeWithSelector((set, get) => { + let nextCancellationRequestId = 0; const commitActivityUpdates: AgentLastActivityCommitter = (updates) => { set((prev) => { let nextActivity: Map | null = null; @@ -1188,6 +1190,23 @@ export const useSessionStore = create()( }); }, + beginAgentCancellation: (serverId, agentId) => { + nextCancellationRequestId += 1; + const requestId = nextCancellationRequestId; + get().applyAgentTurnLiveness(serverId, agentId, { + type: "cancellation_started", + requestId, + }); + return requestId; + }, + + settleAgentCancellation: (serverId, agentId, requestId) => { + get().applyAgentTurnLiveness(serverId, agentId, { + type: "cancellation_settled", + requestId, + }); + }, + clearAgentTurnLiveness: (serverId) => { set((prev) => { const session = prev.sessions[serverId]; @@ -1313,7 +1332,7 @@ export const useSessionStore = create()( return outcome; }, - handoffCreatedAgentUserMessage: (serverId, agentId, message, trackSubmission) => { + handoffCreatedAgentUserMessage: (serverId, agentId, message) => { let didHandoff = false; set((prev) => { const session = prev.sessions[serverId]; @@ -1338,28 +1357,6 @@ export const useSessionStore = create()( const nextHead = result.changedHead ? new Map(session.agentStreamHead).set(agentId, result.head) : session.agentStreamHead; - const clientMessageId = message.clientMessageId; - let messageSubmissions = session.messageSubmissions; - if ( - trackSubmission && - clientMessageId && - [...result.tail, ...result.head].some( - (item) => - item.kind === "user_message" && - item.clientMessageId === clientMessageId && - isUnreconciledLocalUserMessage(item), - ) - ) { - const currentSubmissions = session.messageSubmissions.get(agentId) ?? []; - const accepted = acceptMessageSubmission( - beginMessageSubmission(currentSubmissions, { - clientMessageId, - submittedAt: message.timestamp, - }), - clientMessageId, - ); - messageSubmissions = new Map(session.messageSubmissions).set(agentId, accepted); - } didHandoff = true; return { @@ -1370,7 +1367,6 @@ export const useSessionStore = create()( ...session, agentStreamTail: nextTail, agentStreamHead: nextHead, - messageSubmissions, }, }, }; diff --git a/packages/app/src/stores/workspace-subagents-integration.test.ts b/packages/app/src/stores/workspace-subagents-integration.test.ts index 1559359f0f..4dbaac7127 100644 --- a/packages/app/src/stores/workspace-subagents-integration.test.ts +++ b/packages/app/src/stores/workspace-subagents-integration.test.ts @@ -36,6 +36,7 @@ const AGENT_DEFAULTS: Agent = { id: "agent", provider: "codex", status: "idle", + activeTurn: null, createdAt: AGENT_TIMESTAMP, updatedAt: AGENT_TIMESTAMP, lastUserMessageAt: null, diff --git a/packages/app/src/subagents/select.test.ts b/packages/app/src/subagents/select.test.ts index 7545ea27c3..57bd5dfbb7 100644 --- a/packages/app/src/subagents/select.test.ts +++ b/packages/app/src/subagents/select.test.ts @@ -13,6 +13,7 @@ const AGENT_DEFAULTS: Agent = { id: "agent", provider: "codex", status: "idle", + activeTurn: null, createdAt: AGENT_TIMESTAMP, updatedAt: AGENT_TIMESTAMP, lastUserMessageAt: null, diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index 6e96514a69..3fe8742e79 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -134,22 +134,29 @@ function makeSubmittedUserMessage( describe("deriveAgentStreamTurnLiveness", () => { it("tracks start through every terminal turn event", () => { const started = makeStreamReducerEvent( - { type: "turn_started", provider: "claude" } as AgentStreamEventPayload, + { type: "turn_started", provider: "claude", turnId: "turn-1" }, 1, ); - expect(deriveAgentStreamTurnLiveness([started])).toEqual({ - type: "stream_open", - startedAt: started.timestamp, - evidence: { epoch: "epoch-1", seq: 1 }, - }); + expect(deriveAgentStreamTurnLiveness([started])).toEqual([ + { + type: "stream_open", + turn: { turnId: "turn-1", startedAt: started.timestamp }, + }, + ]); for (const event of [ { type: "turn_completed", provider: "claude" }, { type: "turn_failed", provider: "claude", error: "failed" }, { type: "turn_canceled", provider: "claude", reason: "canceled" }, ] as AgentStreamEventPayload[]) { - expect(deriveAgentStreamTurnLiveness([started, makeStreamReducerEvent(event, 2)])).toEqual({ - type: "stream_close", - }); + expect( + deriveAgentStreamTurnLiveness([ + started, + makeStreamReducerEvent({ ...event, turnId: "turn-1" } as AgentStreamEventPayload, 2), + ]), + ).toEqual([ + { type: "stream_open", turn: { turnId: "turn-1", startedAt: started.timestamp } }, + { type: "stream_close", turnId: "turn-1" }, + ]); } }); @@ -158,7 +165,7 @@ describe("deriveAgentStreamTurnLiveness", () => { deriveAgentStreamTurnLiveness([ makeStreamReducerEvent(makeAssistantTimelineEvent("still working"), 1), ]), - ).toBeUndefined(); + ).toEqual([]); expect( deriveAgentStreamTurnLiveness([ @@ -167,24 +174,26 @@ describe("deriveAgentStreamTurnLiveness", () => { 2, ), ]), - ).toEqual({ type: "stream_close" }); + ).toEqual([{ type: "stream_close", turnId: null }]); }); it("restarts timing when a terminal event and the next start share one reducer batch", () => { const completed = makeStreamReducerEvent( - { type: "turn_completed", provider: "claude" } as AgentStreamEventPayload, + { type: "turn_completed", provider: "claude", turnId: "turn-1" }, 2, ); const restarted = makeStreamReducerEvent( - { type: "turn_started", provider: "claude" } as AgentStreamEventPayload, + { type: "turn_started", provider: "claude", turnId: "turn-2" }, 3, ); - expect(deriveAgentStreamTurnLiveness([completed, restarted])).toEqual({ - type: "stream_restart", - startedAt: restarted.timestamp, - evidence: { epoch: "epoch-1", seq: 3 }, - }); + expect(deriveAgentStreamTurnLiveness([completed, restarted])).toEqual([ + { type: "stream_close", turnId: "turn-1" }, + { + type: "stream_open", + turn: { turnId: "turn-2", startedAt: restarted.timestamp }, + }, + ]); }); }); @@ -3141,6 +3150,48 @@ describe("processAgentStreamEvent", () => { expect(result.sideEffects).toEqual([]); }); + it("applies a same-sequence provider identity revision to its canonical prompt", () => { + const canonical = createUserMessage({ + id: "canonical-prompt", + clientMessageId: "client-message-1", + text: "Revise this prompt", + timestamp: new Date(1000), + timelineCursor: { epoch: "epoch-1", seq: 5 }, + }); + const currentHead = [makeAssistantItem("Still streaming")]; + + const result = processAgentStreamEvent({ + ...baseStreamInput, + currentTail: [canonical], + currentHead, + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 8 }, + event: { + type: "timeline", + provider: "claude", + item: { + type: "user_message", + text: "Revise this prompt", + clientMessageId: "client-message-1", + messageId: "provider-message-1", + }, + }, + seq: 5, + epoch: "epoch-1", + }); + + expect(result.tail).toEqual([ + expect.objectContaining({ + id: "canonical-prompt", + clientMessageId: "client-message-1", + messageId: "provider-message-1", + timelineCursor: { epoch: "epoch-1", seq: 5 }, + }), + ]); + expect(result.head).toBe(currentHead); + expect(result.cursorChanged).toBe(false); + expect(result.sideEffects).toEqual([]); + }); + it("drops timeline event with epoch mismatch", () => { const existingCursor: TimelineCursor = { epoch: "epoch-1", diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index daa1871968..87d43ea325 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -4,6 +4,7 @@ import type { AssistantMessageItem, StreamItem } from "@/types/stream"; import type { TurnLivenessTransition } from "@/timeline/turn-liveness"; import { applyStreamEvent, + createUserMessage, flushHeadToTail, hydrateStreamState, isAgentToolCallItem, @@ -1092,6 +1093,55 @@ interface TimelineSequencingGateResult { sideEffects: AgentStreamReducerSideEffect[]; } +function applyCanonicalUserMessageRevision(input: { + event: AgentStreamEventPayload; + seq: number | undefined; + epoch: string | undefined; + timestamp: Date; + currentTail: StreamItem[]; + currentHead: StreamItem[]; + currentCursor: TimelineCursor | undefined; +}): ReturnType | null { + const { event, seq, epoch, currentCursor } = input; + if ( + event.type !== "timeline" || + event.item.type !== "user_message" || + !event.item.clientMessageId || + !event.item.messageId || + typeof seq !== "number" || + typeof epoch !== "string" || + currentCursor?.epoch !== epoch || + seq > currentCursor.endSeq + ) { + return null; + } + const clientMessageId = event.item.clientMessageId; + const existing = [...input.currentTail, ...input.currentHead].find( + (item) => + item.kind === "user_message" && + item.clientMessageId === clientMessageId && + item.timelineCursor?.epoch === epoch && + item.timelineCursor.seq === seq, + ); + if (!existing || existing.kind !== "user_message") { + return null; + } + return upsertUserMessageAcrossStream({ + tail: input.currentTail, + head: input.currentHead, + message: createUserMessage({ + id: existing.id, + clientMessageId, + messageId: event.item.messageId, + text: event.item.text, + timestamp: input.timestamp, + timelineCursor: { epoch, seq }, + }), + insert: "none", + presentation: "existing", + }); +} + export interface ProcessAgentStreamEventsInput { events: AgentStreamReducerEvent[]; currentTail: StreamItem[]; @@ -1209,6 +1259,30 @@ export function processAgentStreamEvent( hasAuthoritativeBaseline = true, } = input; + const revision = applyCanonicalUserMessageRevision({ + event, + seq, + epoch, + timestamp, + currentTail, + currentHead, + currentCursor, + }); + if (revision) { + return { + tail: revision.tail, + head: revision.head, + changedTail: revision.changedTail, + changedHead: revision.changedHead, + cursor: null, + cursorChanged: false, + acknowledgedClientMessageIds: revision.location?.message.clientMessageId + ? [revision.location.message.clientMessageId] + : [], + sideEffects: [], + }; + } + const sequencing = processTimelineSequencingGate({ event, seq, @@ -1417,42 +1491,28 @@ interface StreamStatePatch { export function deriveAgentStreamTurnLiveness( events: readonly AgentStreamReducerEvent[], -): TurnLivenessTransition | undefined { - let transition: TurnLivenessTransition | undefined; - let shouldRestart = false; - let restartStartedAt: Date | null = null; - for (const { event, timestamp, seq, epoch } of events) { +): TurnLivenessTransition[] { + const transitions: TurnLivenessTransition[] = []; + for (const { event, timestamp } of events) { if (event.type === "turn_started") { - if (shouldRestart) restartStartedAt ??= timestamp; - transition = - typeof seq === "number" && typeof epoch === "string" - ? { - type: restartStartedAt ? "stream_restart" : "stream_open", - startedAt: restartStartedAt ?? timestamp, - evidence: { epoch, seq }, - } - : { type: "directory_running", startedAt: timestamp }; + transitions.push({ + type: "stream_open", + turn: { turnId: event.turnId ?? null, startedAt: timestamp }, + }); } else if ( event.type === "turn_completed" || event.type === "turn_failed" || event.type === "turn_canceled" ) { - transition = { type: "stream_close" }; - shouldRestart = true; - restartStartedAt = null; + transitions.push({ type: "stream_close", turnId: event.turnId ?? null }); } } - return transition; + return transitions; } export interface CreateSessionAgentStreamReducerQueueInput { serverId: string; setAgentStreamState: (serverId: string, agentId: string, state: StreamStatePatch) => void; - applyAgentTurnLiveness: ( - serverId: string, - agentId: string, - transition: TurnLivenessTransition, - ) => void; setAgentTimelineCursor: ( serverId: string, state: (prev: Map) => Map, @@ -1471,13 +1531,7 @@ function cancelAgentStreamReducerFlush(id: number) { export function createSessionAgentStreamReducerQueue( input: CreateSessionAgentStreamReducerQueueInput, ): AgentStreamReducerQueue { - const { - serverId, - setAgentStreamState, - applyAgentTurnLiveness, - setAgentTimelineCursor, - recoverTimelineGap, - } = input; + const { serverId, setAgentStreamState, setAgentTimelineCursor, recoverTimelineGap } = input; return createAgentStreamReducerQueue({ getSnapshot: (agentId) => { @@ -1491,7 +1545,6 @@ export function createSessionAgentStreamReducerQueue( }; }, commit: (agentId, result, events) => { - const turnLiveness = deriveAgentStreamTurnLiveness(events); if ( result.changedTail || result.changedHead || @@ -1505,10 +1558,6 @@ export function createSessionAgentStreamReducerQueue( : {}), }); } - if (turnLiveness) { - applyAgentTurnLiveness(serverId, agentId, turnLiveness); - } - if (result.cursorChanged && result.cursor) { const nextCursor = result.cursor; const lastEvent = events.at(-1); diff --git a/packages/app/src/timeline/turn-liveness.test.ts b/packages/app/src/timeline/turn-liveness.test.ts index 2bb4aee47c..2146b50615 100644 --- a/packages/app/src/timeline/turn-liveness.test.ts +++ b/packages/app/src/timeline/turn-liveness.test.ts @@ -1,113 +1,82 @@ import { describe, expect, it } from "vitest"; import { reduceTurnLiveness, resolveTurnPresentation, TURN_LIVENESS_IDLE } from "./turn-liveness"; -describe("reduceTurnLiveness", () => { - it("closes a directory-seeded turn when a completed resume snapshot is idle", () => { - const startedAt = new Date("2026-07-31T10:00:00.000Z"); - const opened = reduceTurnLiveness(TURN_LIVENESS_IDLE, { - type: "directory_running", - startedAt, - }); +describe("turn activity", () => { + const startedAt = new Date("2026-07-31T10:00:00.000Z"); - const resumed = reduceTurnLiveness(opened, { - type: "resume_snapshot", - status: "idle", - startedAt, - coverage: { epoch: "epoch-1", seq: 40 }, - }); - - expect(resumed).toEqual(TURN_LIVENESS_IDLE); - }); - - it("does not let an older resume snapshot close a newer live turn", () => { - const startedAt = new Date("2026-07-31T10:01:00.000Z"); - const liveTurn = reduceTurnLiveness(TURN_LIVENESS_IDLE, { + it("ignores a terminal event for another turn", () => { + const open = reduceTurnLiveness(TURN_LIVENESS_IDLE, { type: "stream_open", - startedAt, - evidence: { epoch: "epoch-1", seq: 41 }, + turn: { turnId: "turn-2", startedAt }, }); - const resumed = reduceTurnLiveness(liveTurn, { - type: "resume_snapshot", - status: "idle", - startedAt: null, - coverage: { epoch: "epoch-1", seq: 40 }, + expect(reduceTurnLiveness(open, { type: "stream_close", turnId: "turn-1" })).toBe(open); + }); + + it("replaces activity from an authoritative snapshot", () => { + const open = reduceTurnLiveness(TURN_LIVENESS_IDLE, { + type: "stream_open", + turn: { turnId: "turn-1", startedAt }, }); - expect(resumed).toEqual({ + expect(reduceTurnLiveness(open, { type: "snapshot", activeTurn: null })).toEqual( + TURN_LIVENESS_IDLE, + ); + }); + + it("opens an autonomous turn from an authoritative snapshot", () => { + expect( + reduceTurnLiveness(TURN_LIVENESS_IDLE, { + type: "snapshot", + activeTurn: { turnId: "autonomous-turn-1", startedAt }, + }), + ).toEqual({ phase: "open", + turnId: "autonomous-turn-1", startedAt, - evidence: { epoch: "epoch-1", seq: 41 }, + cancellationRequestId: null, }); }); - it("uses submission evidence when the rendered tail does not contain the prompt", () => { - const submittedAt = new Date("2026-07-31T10:02:00.000Z"); + it("does not let an old cancel completion clear the current request", () => { + const first = reduceTurnLiveness(TURN_LIVENESS_IDLE, { + type: "cancellation_started", + requestId: 1, + }); + const second = reduceTurnLiveness(first, { type: "cancellation_started", requestId: 2 }); - expect(resolveTurnPresentation(TURN_LIVENESS_IDLE, [{ submittedAt }])).toEqual({ + expect(reduceTurnLiveness(second, { type: "cancellation_settled", requestId: 1 })).toBe(second); + }); + + it("keeps cancellation pending across submission-to-turn handoff", () => { + const canceling = reduceTurnLiveness(TURN_LIVENESS_IDLE, { + type: "cancellation_started", + requestId: 1, + }); + + expect( + reduceTurnLiveness(canceling, { + type: "stream_open", + turn: { turnId: "turn-1", startedAt }, + }), + ).toEqual({ + phase: "open", + turnId: "turn-1", + startedAt, + cancellationRequestId: 1, + }); + }); + + it("keeps submission-only activity untimed", () => { + expect( + resolveTurnPresentation(TURN_LIVENESS_IDLE, [ + { clientMessageId: "message-1", submittedAt: startedAt }, + ]), + ).toEqual({ isActive: true, - startedAt: submittedAt, - }); - }); - - it("fills a missing directory start time from a running resume snapshot", () => { - const snapshotStartedAt = new Date("2026-07-31T10:03:00.000Z"); - const opened = reduceTurnLiveness(TURN_LIVENESS_IDLE, { - type: "directory_running", + isCancelling: false, startedAt: null, - }); - - expect( - reduceTurnLiveness(opened, { - type: "resume_snapshot", - status: "running", - startedAt: snapshotStartedAt, - coverage: { epoch: "epoch-1", seq: 40 }, - }), - ).toEqual({ - phase: "open", - startedAt: snapshotStartedAt, - evidence: { epoch: "epoch-1", seq: 40 }, - }); - }); - - it("does not downgrade newer live evidence from an older running snapshot", () => { - const startedAt = new Date("2026-07-31T10:04:00.000Z"); - const liveTurn = reduceTurnLiveness(TURN_LIVENESS_IDLE, { - type: "stream_open", - startedAt, - evidence: { epoch: "epoch-1", seq: 41 }, - }); - - expect( - reduceTurnLiveness(liveTurn, { - type: "resume_snapshot", - status: "running", - startedAt, - coverage: { epoch: "epoch-1", seq: 40 }, - }), - ).toBe(liveTurn); - }); - - it("starts a new clock when consecutive turns settle in one stream batch", () => { - const firstStartedAt = new Date("2026-07-31T10:05:00.000Z"); - const secondStartedAt = new Date("2026-07-31T10:06:00.000Z"); - const firstTurn = reduceTurnLiveness(TURN_LIVENESS_IDLE, { - type: "stream_open", - startedAt: firstStartedAt, - evidence: { epoch: "epoch-1", seq: 41 }, - }); - - expect( - reduceTurnLiveness(firstTurn, { - type: "stream_restart", - startedAt: secondStartedAt, - evidence: { epoch: "epoch-1", seq: 43 }, - }), - ).toEqual({ - phase: "open", - startedAt: secondStartedAt, - evidence: { epoch: "epoch-1", seq: 43 }, + turnId: null, }); }); }); diff --git a/packages/app/src/timeline/turn-liveness.ts b/packages/app/src/timeline/turn-liveness.ts index 8fb1835af2..a817af38c3 100644 --- a/packages/app/src/timeline/turn-liveness.ts +++ b/packages/app/src/timeline/turn-liveness.ts @@ -1,40 +1,29 @@ -import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle"; - -export interface TurnLivenessEvidence { - epoch: string; - seq: number; +export interface ActiveTurnIdentity { + turnId: string | null; + startedAt: Date | null; } export type TurnLiveness = - | { phase: "idle" } + | { phase: "idle"; cancellationRequestId: number | null } | { phase: "open"; + turnId: string | null; startedAt: Date | null; - evidence: TurnLivenessEvidence | null; + cancellationRequestId: number | null; }; export type TurnLivenessTransition = - | { type: "directory_running"; startedAt: Date | null } - | { - type: "stream_open"; - startedAt: Date; - evidence: TurnLivenessEvidence; - } - | { - type: "stream_restart"; - startedAt: Date; - evidence: TurnLivenessEvidence; - } - | { type: "stream_close" } - | { type: "destructive_close" } - | { - type: "resume_snapshot"; - status: AgentLifecycleStatus; - startedAt: Date | null; - coverage: TurnLivenessEvidence; - }; + | { type: "snapshot"; activeTurn: ActiveTurnIdentity | null } + | { type: "stream_open"; turn: ActiveTurnIdentity } + | { type: "stream_close"; turnId: string | null } + | { type: "cancellation_started"; requestId: number } + | { type: "cancellation_settled"; requestId: number } + | { type: "destructive_close" }; -export const TURN_LIVENESS_IDLE: TurnLiveness = { phase: "idle" }; +export const TURN_LIVENESS_IDLE: TurnLiveness = { + phase: "idle", + cancellationRequestId: null, +}; export function readTurnLiveness( turns: ReadonlyMap, @@ -46,93 +35,92 @@ export function readTurnLiveness( export function applyTurnLivenessTransition( turns: ReadonlyMap, agentId: string, - transition: TurnLivenessTransition, + transition: TurnLivenessTransition | readonly TurnLivenessTransition[], ): Map { const current = readTurnLiveness(turns, agentId); - const next = reduceTurnLiveness(current, transition); + const transitions = Array.isArray(transition) ? transition : [transition]; + const next = transitions.reduce(reduceTurnLiveness, current); if (next === current) return turns as Map; const updated = new Map(turns); - if (next.phase === "idle") updated.delete(agentId); + if (next.phase === "idle" && next.cancellationRequestId === null) updated.delete(agentId); else updated.set(agentId, next); return updated; } export interface TurnPresentation { isActive: boolean; + isCancelling: boolean; startedAt: Date | null; + turnId: string | null; } export function resolveTurnPresentation( liveness: TurnLiveness, - submissions: readonly { submittedAt: Date }[], + submissions: readonly { clientMessageId: string; submittedAt: Date }[], ): TurnPresentation { const latestSubmission = submissions.at(-1); if (liveness.phase === "open") { return { isActive: true, - startedAt: liveness.startedAt ?? latestSubmission?.submittedAt ?? null, + isCancelling: liveness.cancellationRequestId !== null, + startedAt: liveness.startedAt, + turnId: liveness.turnId, }; } - return latestSubmission - ? { isActive: true, startedAt: latestSubmission.submittedAt } - : { isActive: false, startedAt: null }; + return { + isActive: latestSubmission !== undefined, + isCancelling: liveness.cancellationRequestId !== null, + startedAt: null, + turnId: null, + }; +} + +function closeTurn(current: TurnLiveness, turnId: string | null): TurnLiveness { + // COMPAT(agentTurnIdentity): added in v0.2.6, remove after 2027-01-31 once daemon floor >= v0.2.6. + const targetsDifferentIdentifiedTurn = + current.phase === "open" && + turnId !== null && + current.turnId !== null && + turnId !== current.turnId; + return targetsDifferentIdentifiedTurn ? current : TURN_LIVENESS_IDLE; +} + +function openTurn(current: TurnLiveness, activeTurn: ActiveTurnIdentity): TurnLiveness { + // COMPAT(agentTurnIdentity): added in v0.2.6, remove after 2027-01-31 once daemon floor >= v0.2.6. + const sameTurn = + current.phase === "open" && + ((activeTurn.turnId !== null && current.turnId === activeTurn.turnId) || + (activeTurn.turnId === null && current.turnId === null)); + return { + phase: "open", + turnId: activeTurn.turnId, + startedAt: sameTurn ? (current.startedAt ?? activeTurn.startedAt) : activeTurn.startedAt, + cancellationRequestId: + sameTurn || current.phase === "idle" ? current.cancellationRequestId : null, + }; } export function reduceTurnLiveness( current: TurnLiveness, transition: TurnLivenessTransition, ): TurnLiveness { - if (transition.type === "directory_running") { - return current.phase === "open" - ? current - : { - phase: "open", - startedAt: transition.startedAt, - evidence: null, - }; + if (transition.type === "destructive_close") return TURN_LIVENESS_IDLE; + + if (transition.type === "cancellation_started") { + if (current.cancellationRequestId === transition.requestId) return current; + return { ...current, cancellationRequestId: transition.requestId }; } - if (transition.type === "stream_open") { - return { - phase: "open", - startedAt: - current.phase === "open" - ? (current.startedAt ?? transition.startedAt) - : transition.startedAt, - evidence: transition.evidence, - }; + if (transition.type === "cancellation_settled") { + if (current.cancellationRequestId !== transition.requestId) return current; + return { ...current, cancellationRequestId: null }; } - if (transition.type === "stream_restart") { - return { - phase: "open", - startedAt: transition.startedAt, - evidence: transition.evidence, - }; + if (transition.type === "stream_close") { + return closeTurn(current, transition.turnId); } - if (transition.type === "stream_close" || transition.type === "destructive_close") { - return TURN_LIVENESS_IDLE; - } - - if ( - current.phase === "open" && - current.evidence?.epoch === transition.coverage.epoch && - current.evidence.seq > transition.coverage.seq - ) { - return current; - } - - if (transition.status === "running") { - return { - phase: "open", - startedAt: - current.phase === "open" - ? (current.startedAt ?? transition.startedAt) - : transition.startedAt, - evidence: transition.coverage, - }; - } - - return TURN_LIVENESS_IDLE; + const activeTurn = transition.type === "snapshot" ? transition.activeTurn : transition.turn; + if (!activeTurn) return TURN_LIVENESS_IDLE; + return openTurn(current, activeTurn); } diff --git a/packages/app/src/utils/agent-attention.test.ts b/packages/app/src/utils/agent-attention.test.ts index 2fc70a20b8..b9a0d8e6be 100644 --- a/packages/app/src/utils/agent-attention.test.ts +++ b/packages/app/src/utils/agent-attention.test.ts @@ -34,6 +34,7 @@ function createAgent(input: Partial & Pick): Agent { attentionReason: null, attentionTimestamp: null, ...rest, + activeTurn: rest.activeTurn ?? null, }; } diff --git a/packages/app/src/utils/agent-directory-reconciliation.test.ts b/packages/app/src/utils/agent-directory-reconciliation.test.ts index 03387d010e..bf1e0a53ef 100644 --- a/packages/app/src/utils/agent-directory-reconciliation.test.ts +++ b/packages/app/src/utils/agent-directory-reconciliation.test.ts @@ -53,6 +53,7 @@ function entry(id: string, status: AgentSnapshotPayload["status"]): FetchAgentsE function replica(id: string, status: Agent["status"]): Agent { return { ...snapshot(id, status), + activeTurn: null, serverId: "server", createdAt: new Date("2026-07-12T10:00:00.000Z"), updatedAt: new Date("2026-07-12T10:00:00.000Z"), diff --git a/packages/app/src/utils/agent-directory-sync.test.ts b/packages/app/src/utils/agent-directory-sync.test.ts index 243b88a2d9..94bda7d622 100644 --- a/packages/app/src/utils/agent-directory-sync.test.ts +++ b/packages/app/src/utils/agent-directory-sync.test.ts @@ -99,7 +99,7 @@ function applyAgentStatus(input: { } describe("turn liveness authority", () => { - it("seeds from a running directory transition without closing from directory idle", () => { + it("normalizes old-daemon status into the shared activity replica", () => { const serverId = "server-turn-liveness"; const agentId = "agent-1"; const startedAt = "2026-07-27T10:00:01.000Z"; @@ -114,6 +114,13 @@ describe("turn liveness authority", () => { updatedAt: "2026-07-27T10:00:02.000Z", lastUserMessageAt: startedAt, }); + expect(store.getSession(serverId)?.agentTurnLiveness.get(agentId)).toEqual({ + phase: "open", + turnId: null, + startedAt: new Date(startedAt), + cancellationRequestId: null, + }); + applyAgentStatus({ serverId, agentId, @@ -121,11 +128,7 @@ describe("turn liveness authority", () => { updatedAt: "2026-07-27T10:00:03.000Z", }); - expect(store.getSession(serverId)?.agentTurnLiveness.get(agentId)).toEqual({ - phase: "open", - startedAt: new Date(startedAt), - evidence: null, - }); + expect(store.getSession(serverId)?.agentTurnLiveness.has(agentId)).toBe(false); store.clearSession(serverId); }); }); @@ -155,8 +158,8 @@ describe("message submission authority", () => { { clientMessageId, submittedAt: new Date("2026-07-27T10:00:00.000Z"), - rpcAccepted: false, providerAcknowledged: false, + rpcSettled: false, }, ]); store.clearSession(serverId); diff --git a/packages/app/src/utils/agent-directory-sync.ts b/packages/app/src/utils/agent-directory-sync.ts index 1ee4e4cff5..1e0f0f87c3 100644 --- a/packages/app/src/utils/agent-directory-sync.ts +++ b/packages/app/src/utils/agent-directory-sync.ts @@ -65,29 +65,22 @@ function upsertAgentDirectoryReplica( export function upsertAgentReplica(serverId: string, agent: Agent): Agent { let acceptedAgent = agent; - let previousAgent: Agent | undefined; useSessionStore.getState().setAgents(serverId, (current) => { const currentAgent = current.get(agent.id); - previousAgent = currentAgent; acceptedAgent = acceptAgentDirectoryUpdate(currentAgent, agent); if (acceptedAgent === currentAgent) return current; const next = new Map(current); next.set(agent.id, acceptedAgent); return next; }); - seedAgentTurnLivenessFromDirectory(serverId, previousAgent, acceptedAgent); + applyAgentTurnSnapshot(serverId, acceptedAgent); return acceptedAgent; } -function seedAgentTurnLivenessFromDirectory( - serverId: string, - previousAgent: Agent | undefined, - agent: Agent, -): void { - if (previousAgent?.status === "running" || agent.status !== "running") return; +function applyAgentTurnSnapshot(serverId: string, agent: Agent): void { useSessionStore.getState().applyAgentTurnLiveness(serverId, agent.id, { - type: "directory_running", - startedAt: agent.lastUserMessageAt, + type: "snapshot", + activeTurn: agent.activeTurn, }); } @@ -188,8 +181,6 @@ export function replaceFetchedAgentDirectory(input: { }): { agents: Map } { const { agents: fetchedAgents, pendingPermissions } = buildAgentDirectoryState(input); const store = useSessionStore.getState(); - const previousAgents = store.sessions[input.serverId]?.agents ?? new Map(); - for (const agent of fetchedAgents.values()) { if (agent.archivedAt) { clearArchiveAgentPending({ queryClient, serverId: input.serverId, agentId: agent.id }); @@ -198,7 +189,7 @@ export function replaceFetchedAgentDirectory(input: { store.setAgents(input.serverId, fetchedAgents); for (const agent of fetchedAgents.values()) { - seedAgentTurnLivenessFromDirectory(input.serverId, previousAgents.get(agent.id), agent); + applyAgentTurnSnapshot(input.serverId, agent); } store.setAgentDetails(input.serverId, (prev) => { let next: Map | null = null; diff --git a/packages/app/src/utils/agent-snapshots.test.ts b/packages/app/src/utils/agent-snapshots.test.ts index b514db1a0b..966b17eb34 100644 --- a/packages/app/src/utils/agent-snapshots.test.ts +++ b/packages/app/src/utils/agent-snapshots.test.ts @@ -17,6 +17,7 @@ function createSnapshot( updatedAt: input.updatedAt ?? "2026-04-20T00:01:00.000Z", lastUserMessageAt: input.lastUserMessageAt ?? null, status: input.status ?? "idle", + activeTurn: input.activeTurn, capabilities: input.capabilities ?? { supportsStreaming: true, supportsSessionPersistence: true, @@ -35,6 +36,26 @@ function createSnapshot( } describe("normalizeAgentSnapshot", () => { + it("normalizes identified and legacy active turns at the snapshot boundary", () => { + const startedAt = "2026-07-31T12:00:00.000Z"; + expect( + normalizeAgentSnapshot( + createSnapshot({ + status: "running", + activeTurn: { turnId: "turn-1", startedAt }, + }), + "server-1", + ).activeTurn, + ).toEqual({ turnId: "turn-1", startedAt: new Date(startedAt) }); + + expect( + normalizeAgentSnapshot( + createSnapshot({ status: "running", lastUserMessageAt: startedAt }), + "server-1", + ).activeTurn, + ).toEqual({ turnId: null, startedAt: new Date(startedAt) }); + }); + it("derives parentAgentId from the parent label while preserving labels", () => { const labels = { [PARENT_AGENT_ID_LABEL]: "parent-1", diff --git a/packages/app/src/utils/agent-snapshots.ts b/packages/app/src/utils/agent-snapshots.ts index 08de1633e7..97fd3e11c1 100644 --- a/packages/app/src/utils/agent-snapshots.ts +++ b/packages/app/src/utils/agent-snapshots.ts @@ -1,6 +1,21 @@ import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels"; +import type { ActiveTurnIdentity } from "@/timeline/turn-liveness"; + +function normalizeActiveTurn( + snapshot: AgentSnapshotPayload, + lastUserMessageAt: Date | null, +): ActiveTurnIdentity | null { + if (snapshot.activeTurn === null) return null; + if (snapshot.activeTurn) { + return { + turnId: snapshot.activeTurn.turnId, + startedAt: snapshot.activeTurn.startedAt ? new Date(snapshot.activeTurn.startedAt) : null, + }; + } + return snapshot.status === "running" ? { turnId: null, startedAt: lastUserMessageAt } : null; +} export function derivePendingPermissionKey( agentId: string, @@ -27,12 +42,16 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: : null; const archivedAt = snapshot.archivedAt ? new Date(snapshot.archivedAt) : null; const parentAgentId = getParentAgentIdFromLabels(snapshot.labels); + // COMPAT(agentTurnIdentity): old daemons expose only status. Normalize that legacy + // signal once so the rest of the app consumes one activity shape. + const activeTurn = normalizeActiveTurn(snapshot, lastUserMessageAt); return { serverId, id: snapshot.id, provider: snapshot.provider, status: snapshot.status, + activeTurn, createdAt, updatedAt, lastUserMessageAt, diff --git a/packages/app/src/utils/workspace-agent-activity.test.ts b/packages/app/src/utils/workspace-agent-activity.test.ts index 7cea821919..09ada95695 100644 --- a/packages/app/src/utils/workspace-agent-activity.test.ts +++ b/packages/app/src/utils/workspace-agent-activity.test.ts @@ -19,6 +19,7 @@ function agent(input: { id: input.id, provider: "codex", status: input.status ?? "idle", + activeTurn: input.status === "running" ? { turnId: "turn-1", startedAt: null } : null, createdAt: new Date("2026-01-01T00:00:00.000Z"), updatedAt: new Date(input.updatedAt), lastUserMessageAt: null, diff --git a/packages/app/src/workspace-tabs/agent-visibility.test.ts b/packages/app/src/workspace-tabs/agent-visibility.test.ts index 33b30f80df..b0fd6a8613 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.test.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.test.ts @@ -23,6 +23,7 @@ function makeAgent(input: { id: input.id, provider: "codex", status: "idle", + activeTurn: null, createdAt, updatedAt: createdAt, lastUserMessageAt: null, diff --git a/packages/protocol/src/agent-feature-schemas.test.ts b/packages/protocol/src/agent-feature-schemas.test.ts index 1eb5d6e501..a14ef5f746 100644 --- a/packages/protocol/src/agent-feature-schemas.test.ts +++ b/packages/protocol/src/agent-feature-schemas.test.ts @@ -144,6 +144,47 @@ describe("agent feature schemas", () => { expect(parsed.features?.[0]?.id).toBe("fast_mode"); }); + it("accepts optional active turn identity without requiring it from old daemons", () => { + const base = { + id: "agent-turn-identity", + provider: "codex", + cwd: "/tmp/project", + model: "gpt-5", + createdAt: "2026-07-31T12:00:00.000Z", + updatedAt: "2026-07-31T12:00:01.000Z", + lastUserMessageAt: "2026-07-31T12:00:00.000Z", + status: "running", + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: null, + labels: {}, + }; + + expect(AgentSnapshotPayloadSchema.parse(base).activeTurn).toBeUndefined(); + expect( + AgentSnapshotPayloadSchema.parse({ + ...base, + activeTurn: { + turnId: "turn-1", + startedAt: "2026-07-31T12:00:00.000Z", + }, + }).activeTurn, + ).toEqual({ + turnId: "turn-1", + startedAt: "2026-07-31T12:00:00.000Z", + }); + }); + it("defaults missing rewind capabilities to false", () => { const parsed = AgentSnapshotPayloadSchema.parse({ id: "agent-123", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 6624d58262..8f98efc1bd 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -612,15 +612,18 @@ export const AgentStreamEventPayloadSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("turn_started"), provider: AgentProviderSchema, + turnId: z.string().optional(), }), z.object({ type: z.literal("turn_completed"), provider: AgentProviderSchema, + turnId: z.string().optional(), usage: AgentUsageSchema.optional(), }), z.object({ type: z.literal("turn_failed"), provider: AgentProviderSchema, + turnId: z.string().optional(), error: z.string(), code: z.string().optional(), diagnostic: z.string().optional(), @@ -628,6 +631,7 @@ export const AgentStreamEventPayloadSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("turn_canceled"), provider: AgentProviderSchema, + turnId: z.string().optional(), reason: z.string(), }), z.object({ @@ -685,6 +689,11 @@ const AgentRuntimeInfoSchema: z.ZodType = z.object({ extra: z.record(z.string(), z.unknown()).optional(), }); +const AgentActiveTurnPayloadSchema = z.object({ + turnId: z.string(), + startedAt: z.string().nullable(), +}); + export const AgentSnapshotPayloadSchema = z.object({ id: z.string(), provider: AgentProviderSchema, @@ -698,6 +707,7 @@ export const AgentSnapshotPayloadSchema = z.object({ updatedAt: z.string(), lastUserMessageAt: z.string().nullable(), status: AgentStatusSchema, + activeTurn: AgentActiveTurnPayloadSchema.nullable().optional(), capabilities: AgentCapabilityFlagsSchema, currentModeId: z.string().nullable(), availableModes: z.array(AgentModeSchema), @@ -2830,6 +2840,8 @@ export const ServerInfoStatusPayloadSchema = z selectiveAgentTimeline: z.boolean().optional(), // COMPAT(canonicalSubmittedPrompts): added in v0.2.6, remove gate after 2027-01-30. canonicalSubmittedPrompts: z.boolean().optional(), + // COMPAT(agentTurnIdentity): accept peers that observed pre-release v0.2.6 through 2027-01-31. + agentTurnIdentity: z.boolean().optional(), // COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15. stableProjectIdentity: z.boolean().optional(), // COMPAT(workspaceScriptManagement): added in v0.1.105, remove gate after 2027-01-10. diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index d4cc21ea01..72200903dd 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -4816,6 +4816,14 @@ test("applies live autonomous events while no foreground run is active", async ( provider: "codex", turnId: autonomousTurnId, }); + await vi.waitFor(() => { + const running = manager.getAgent(snapshot.id); + expect(running?.lifecycle).toBe("running"); + expect(running ? toAgentPayload(running).activeTurn : null).toEqual({ + turnId: autonomousTurnId, + startedAt: expect.any(String), + }); + }); capturedSession!.pushEvent({ type: "timeline", provider: "codex", @@ -4831,6 +4839,7 @@ test("applies live autonomous events while no foreground run is active", async ( const updated = manager.getAgent(snapshot.id); expect(updated?.lifecycle).toBe("idle"); + expect(updated ? toAgentPayload(updated).activeTurn : null).toBeNull(); expect(manager.getTimeline(snapshot.id)).toContainEqual({ type: "assistant_message", text: "AUTONOMOUS_PUMP_MESSAGE", @@ -4839,6 +4848,149 @@ test("applies live autonomous events while no foreground run is active", async ( expect(lifecycleUpdates).toContain("idle"); }); +test("ignores stale autonomous terminals without lowering the active turn lifecycle", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stale-autonomous-terminal-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + let capturedSession: TestAgentSession | null = null; + + class LiveEventClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + capturedSession = new TestAgentSession(config); + return capturedSession; + } + } + + const manager = new AgentManager({ + clients: { codex: new LiveEventClient() }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000135", + }); + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + + capturedSession!.pushEvent({ + type: "turn_completed", + provider: "codex", + turnId: "prior-untracked-turn", + usage: { inputTokens: 7 }, + }); + capturedSession!.pushEvent({ + type: "turn_failed", + provider: "codex", + turnId: "prior-untracked-turn", + error: "turn-b marker", + }); + await vi.waitFor(() => { + const priorFailure = manager.getAgent(snapshot.id); + expect(priorFailure?.lastError).toBe("turn-b marker"); + expect(priorFailure?.lastUsage).toEqual({ inputTokens: 7 }); + }); + + capturedSession!.pushEvent({ type: "turn_started", provider: "codex", turnId: "turn-b" }); + await vi.waitFor(() => { + const running = manager.getAgent(snapshot.id); + expect(running?.lifecycle).toBe("running"); + expect(running ? toAgentPayload(running).activeTurn?.turnId : null).toBe("turn-b"); + }); + + const active = manager.getAgent(snapshot.id)!; + active.pendingPermissions.set("turn-b-permission", { + id: "turn-b-permission", + provider: "codex", + name: "Current turn permission", + kind: "tool", + }); + const timelineBeforeStaleTerminals = manager.getTimeline(snapshot.id); + + const staleTerminals: AgentStreamEvent[] = [ + { + type: "turn_completed", + provider: "codex", + turnId: "turn-a", + usage: { inputTokens: 99 }, + }, + { type: "turn_failed", provider: "codex", turnId: "turn-a", error: "late failure" }, + { type: "turn_canceled", provider: "codex", turnId: "turn-a", reason: "late cancel" }, + ]; + for (const terminal of staleTerminals) { + const processed = new Promise((resolve) => { + const unsubscribe = manager.subscribe( + (event) => { + if ( + event.type === "agent_stream" && + event.agentId === snapshot.id && + event.event.type === terminal.type + ) { + unsubscribe(); + resolve(); + } + }, + { agentId: snapshot.id, replayState: false }, + ); + }); + capturedSession!.pushEvent(terminal); + await processed; + const stillRunning = manager.getAgent(snapshot.id); + expect(stillRunning?.lifecycle).toBe("running"); + expect(stillRunning ? toAgentPayload(stillRunning).activeTurn?.turnId : null).toBe("turn-b"); + expect(stillRunning?.lastError).toBe("turn-b marker"); + expect(stillRunning?.lastUsage).toEqual({ inputTokens: 7 }); + expect(stillRunning?.pendingPermissions.has("turn-b-permission")).toBe(true); + expect(manager.getTimeline(snapshot.id)).toEqual(timelineBeforeStaleTerminals); + } + + capturedSession!.pushEvent({ type: "turn_completed", provider: "codex", turnId: "turn-b" }); + await vi.waitFor(() => { + const settled = manager.getAgent(snapshot.id); + expect(settled?.lifecycle).toBe("idle"); + expect(settled ? toAgentPayload(settled).activeTurn : null).toBeNull(); + }); +}); + +test("preserves terminal fallback when no active turn identity was observed", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-untracked-terminal-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + let capturedSession: TestAgentSession | null = null; + + class LiveEventClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + capturedSession = new TestAgentSession(config); + return capturedSession; + } + } + + const manager = new AgentManager({ + clients: { codex: new LiveEventClient() }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000136", + }); + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + + capturedSession!.pushEvent({ + type: "turn_failed", + provider: "codex", + turnId: "untracked-turn", + error: "untracked failure", + }); + + await vi.waitFor(() => { + const failed = manager.getAgent(snapshot.id); + expect(failed?.lifecycle).toBe("error"); + expect(failed?.lastError).toBe("untracked failure"); + }); + expect(manager.getTimeline(snapshot.id)).toContainEqual( + expect.objectContaining({ + type: "assistant_message", + text: expect.stringContaining("untracked failure"), + }), + ); +}); + test("cancelAgentRun waits for an acknowledged autonomous interrupt to settle", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-cancel-")); const storagePath = join(workdir, "agents"); @@ -5118,6 +5270,11 @@ test("autonomous events arriving during foreground run are processed via subscri provider: "codex", turnId: autonomousTurnId, }); + await vi.waitFor(() => { + const running = manager.getAgent(snapshot.id); + expect(running?.lifecycle).toBe("running"); + expect(running ? toAgentPayload(running).activeTurn?.turnId : null).toBe("fg-turn-1"); + }); capturedSession!.pushEvent({ type: "timeline", provider: "codex", @@ -5130,6 +5287,18 @@ test("autonomous events arriving during foreground run are processed via subscri turnId: autonomousTurnId, }); + await vi.waitFor(() => + expect(manager.getTimeline(snapshot.id)).toContainEqual({ + type: "assistant_message", + text: "AUTONOMOUS_DURING_FOREGROUND", + }), + ); + const stillForeground = manager.getAgent(snapshot.id); + expect(stillForeground?.lifecycle).toBe("running"); + expect(stillForeground ? toAgentPayload(stillForeground).activeTurn?.turnId : null).toBe( + "fg-turn-1", + ); + releaseForeground.resolve(); const foregroundEvents = await foregroundResults; @@ -7977,6 +8146,8 @@ test("authoritative timeline includes provider-emitted submitted user prompt", a logger, idFactory: () => "00000000-0000-4000-8000-000000000402", }); + const events: AgentManagerEvent[] = []; + manager.subscribe((event) => events.push(event), { replayState: false }); try { const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { @@ -7987,13 +8158,53 @@ test("authoritative timeline includes provider-emitted submitted user prompt", a clientMessageId: "msg-client-1", }); + const liveEvents = events.filter( + (event): event is Extract => + event.type === "agent_stream" && event.agentId === snapshot.id, + ); + expect( + liveEvents.map((event) => + event.event.type === "timeline" + ? { + type: event.event.type, + item: event.event.item, + seq: event.seq, + } + : { type: event.event.type }, + ), + ).toEqual([ + { type: "turn_started" }, + { + type: "timeline", + item: { + type: "user_message", + text: "hello from composer", + clientMessageId: "msg-client-1", + }, + seq: 1, + }, + { + type: "timeline", + item: { + type: "user_message", + text: "hello from composer", + messageId: "provider-message-1", + clientMessageId: "msg-client-1", + }, + seq: 1, + }, + { type: "turn_completed" }, + ]); + const timeline = manager.fetchTimeline(snapshot.id, { direction: "tail", limit: 20 }).rows; - expect(timeline.map((row) => row.item)).toContainEqual({ - type: "user_message", - text: "hello from composer", - messageId: "provider-message-1", - clientMessageId: "msg-client-1", - }); + expect(timeline.map((row) => row.item)).toEqual([ + { + type: "user_message", + text: "hello from composer", + messageId: "provider-message-1", + clientMessageId: "msg-client-1", + }, + ]); } finally { await manager.flush().catch(() => undefined); await storage.flush().catch(() => undefined); @@ -8040,12 +8251,30 @@ test("authoritative timeline records a daemon-handled submitted prompt before it const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { workspaceId: undefined, }); + const events: AgentManagerEvent[] = []; + manager.subscribe((event) => events.push(event), { + agentId: snapshot.id, + replayState: false, + }); await startAgentRun(manager, snapshot.id, "/handled", logger, { runOptions: { clientMessageId: "msg-client-daemon-handled" }, }); await commandCompleted.promise; + expect( + events.flatMap((event) => + event.type === "agent_stream" && event.event.type === "timeline" ? [event.event.item] : [], + ), + ).toEqual([ + { + type: "user_message", + text: "/handled", + clientMessageId: "msg-client-daemon-handled", + }, + { type: "assistant_message", text: "Handled by the daemon" }, + ]); + const timeline = manager.fetchTimeline(snapshot.id, { direction: "tail", limit: 20 }).rows; expect(timeline.map((row) => row.item)).toEqual([ { diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index b734624470..d1fd273469 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -90,6 +90,16 @@ const STORED_AGENT_CAPABILITIES: AgentCapabilityFlags = { type TimeoutResult = "completed" | "timed_out"; +function submittedPromptText(prompt: AgentPromptInput): string { + if (typeof prompt === "string") { + return prompt; + } + return prompt + .flatMap((block) => (block.type === "text" ? [block.text] : [])) + .join("\n") + .trim(); +} + export class AgentManagerShuttingDownError extends Error { constructor() { super("Agent manager is shutting down"); @@ -296,6 +306,8 @@ interface StreamEventFlags { shouldNotifyWaiters: boolean; } +type ActiveTurnTerminalDisposition = "closed_current" | "stale" | "untracked"; + interface HandleStreamEventOptions { fromHistory?: boolean; } @@ -329,6 +341,8 @@ interface ManagedAgentBase { persistence: AgentPersistenceHandle | null; historyPrimed: boolean; lastUserMessageAt: Date | null; + activeTurnId: string | null; + activeTurnStartedAt: Date | null; lastUsage?: AgentUsage; lastError?: string; attention: AttentionState; @@ -403,6 +417,38 @@ type ActiveManagedAgent = type LiveManagedAgent = ActiveManagedAgent; type AgentLabelPatch = Record; +function attachManagedTurnIdentity( + agent: ActiveManagedAgent, + event: AgentStreamEvent, + fromHistory: boolean, +): { event: AgentStreamEvent; turnId: string | undefined } { + const existingTurnId = getAgentStreamEventTurnId(event); + if (fromHistory || existingTurnId !== undefined) { + return { event, turnId: existingTurnId }; + } + switch (event.type) { + case "turn_started": { + const turnId = + agent.activeForegroundTurnId ?? agent.activeTurnId ?? `autonomous-${randomUUID()}`; + return { event: { ...event, turnId }, turnId }; + } + case "turn_completed": + case "turn_failed": + case "turn_canceled": { + const turnId = agent.activeForegroundTurnId ?? agent.activeTurnId ?? undefined; + return turnId ? { event: { ...event, turnId }, turnId } : { event, turnId }; + } + default: + return { event, turnId: undefined }; + } +} + +function limitAgentStreamEventContent(event: AgentStreamEvent): AgentStreamEvent { + return event.type === "timeline" + ? { ...event, item: limitAgentTimelineItemContent(event.item) } + : event; +} + interface WriteLabelsResult { record: StoredAgentRecord | null; live: boolean; @@ -1518,6 +1564,8 @@ export class AgentManager { inFlightPermissionResponses: new Set(), pendingReplacement: false, activeForegroundTurnId: null, + activeTurnId: null, + activeTurnStartedAt: null, foregroundTurnWaiters: new Set(), finalizedForegroundTurnIds: new Set(), unsubscribeSession: null, @@ -1875,19 +1923,8 @@ export class AgentManager { return false; } if (options?.clientMessageId) { - if (typeof prompt !== "string") { - throw new Error("Daemon-handled submitted prompts must be text"); - } - this.touchUpdatedAt(agent); - this.recordAndDispatchTimelineItem( - agent.id, - { - type: "user_message", - text: prompt, - clientMessageId: options.clientMessageId, - }, - agent.provider, - ); + this.recordSubmittedPrompt(agent, prompt, options.clientMessageId); + this.emitState(agent); } const dispatch = (event: AgentStreamEvent): void => { // Persist timeline items so they show up in fetchAgentTimeline; broadcast @@ -1989,7 +2026,7 @@ export class AgentManager { const isReplacement = agent.pendingReplacement; agent.lastError = undefined; - const pendingRun = this.runs.createPendingRun(agentId); + const pendingRun = this.runs.createPendingRun(agentId, options?.clientMessageId); const streamForwarder = async function* streamForwarder(this: AgentManager) { let turnId: string; @@ -2010,14 +2047,48 @@ export class AgentManager { throw error; } - pendingRun.started = true; - pendingRun.turnId = turnId; if (isReplacement) { agent.pendingReplacement = false; } + const turnStartedAt = new Date(); + pendingRun.started = true; + pendingRun.turnId = turnId; agent.activeForegroundTurnId = turnId; + this.openActiveTurn(agent, turnId, turnStartedAt); agent.lifecycle = "running"; this.touchUpdatedAt(agent); + // AgentManager owns the accepted-turn boundary. Publish liveness before the canonical + // prompt so clients can retire optimistic activity without painting an idle frame. + // The provider's duplicate start for this turn is suppressed at the ingestion boundary. + this.dispatchStream( + agent.id, + { type: "turn_started", provider: agent.provider, turnId }, + { timestamp: turnStartedAt.toISOString() }, + ); + const stagedSubmittedPromptEcho = pendingRun.stagedEvents.find( + (event): event is Extract => + event.type === "timeline" && + event.item.type === "user_message" && + event.item.clientMessageId === options?.clientMessageId, + ); + if (options?.clientMessageId) { + this.recordSubmittedPrompt( + agent, + prompt, + options.clientMessageId, + stagedSubmittedPromptEcho?.item.type === "user_message" + ? stagedSubmittedPromptEcho.item.messageId + : undefined, + ); + } + for (const stagedEvent of pendingRun.stagedEvents.splice(0)) { + const isAcceptedTurnStart = + stagedEvent.type === "turn_started" && getAgentStreamEventTurnId(stagedEvent) === turnId; + if (isAcceptedTurnStart || stagedEvent === stagedSubmittedPromptEcho) { + continue; + } + this.enqueueSessionEvent(agent.id, stagedEvent); + } this.emitState(agent); this.logger.trace( { @@ -2035,6 +2106,12 @@ export class AgentManager { this.runs.addWaiter(agent, turnStream.waiter); try { + const acceptedTurnStartedEvent: AgentStreamEvent = { + type: "turn_started", + provider: agent.provider, + turnId, + }; + yield acceptedTurnStartedEvent; for await (const event of turnStream.events(isTurnTerminalEvent)) { yield event; } @@ -2058,6 +2135,7 @@ export class AgentManager { this.runs.rememberFinalizedTurn(mutableAgent, turnId); } mutableAgent.activeForegroundTurnId = null; + this.applyActiveTurnTerminal(mutableAgent, turnId); const terminalError = mutableAgent.lastError; const shouldHoldBusyForReplacement = mutableAgent.pendingReplacement && !terminalError; let nextLifecycle: "running" | "error" | "idle"; @@ -2095,6 +2173,22 @@ export class AgentManager { } } + private openActiveTurn(agent: ActiveManagedAgent, turnId: string, startedAt: Date): void { + agent.activeTurnId = turnId; + agent.activeTurnStartedAt = startedAt; + } + + private applyActiveTurnTerminal( + agent: ActiveManagedAgent, + turnId?: string, + ): ActiveTurnTerminalDisposition { + if (!agent.activeTurnId) return "untracked"; + if (turnId && agent.activeTurnId !== turnId) return "stale"; + agent.activeTurnId = null; + agent.activeTurnStartedAt = null; + return "closed_current"; + } + async replaceAgentRun( agentId: string, prompt: AgentPromptInput, @@ -2849,6 +2943,8 @@ export class AgentManager { inFlightPermissionResponses: new Set(), pendingReplacement: false, activeForegroundTurnId: null, + activeTurnId: null, + activeTurnStartedAt: null, foregroundTurnWaiters: new Set(), finalizedForegroundTurnIds: new Set(), unsubscribeSession: null, @@ -2903,6 +2999,8 @@ export class AgentManager { lifecycle: "closed", session: null, activeForegroundTurnId: null, + activeTurnId: null, + activeTurnStartedAt: null, pendingPermissions: new Map(), bufferedPermissionResolutions: new Map(), inFlightPermissionResponses: new Set(), @@ -2945,6 +3043,11 @@ export class AgentManager { }, "agent.manager.enqueue", ); + const pendingRun = this.runs.getPendingRun(agentId); + if (pendingRun && !pendingRun.started) { + pendingRun.stagedEvents.push(event); + return; + } const previous = this.sessionEventTails.get(agentId) ?? Promise.resolve(); const next = previous .catch(() => undefined) @@ -3306,13 +3409,10 @@ export class AgentManager { event: AgentStreamEvent, options?: HandleStreamEventOptions, ): Promise { - if (event.type === "timeline") { - event = { - ...event, - item: limitAgentTimelineItemContent(event.item), - }; - } - const eventTurnId = getAgentStreamEventTurnId(event); + event = limitAgentStreamEventContent(event); + const identified = attachManagedTurnIdentity(agent, event, options?.fromHistory === true); + event = identified.event; + const eventTurnId = identified.turnId; const isForegroundEvent = Boolean(eventTurnId && agent.activeForegroundTurnId === eventTurnId); this.traceHandleStreamEventStart(agent, event, eventTurnId, isForegroundEvent); if ( @@ -3333,6 +3433,13 @@ export class AgentManager { this.agentStreamCoalescer.flushFor(agent.id); } + let terminalDisposition: ActiveTurnTerminalDisposition = "untracked"; + if (isTurnTerminalEvent(event)) { + terminalDisposition = options?.fromHistory + ? "stale" + : this.applyActiveTurnTerminal(agent, eventTurnId); + } + const flags: StreamEventFlags = { shouldDispatchEvent: true, shouldNotifyWaiters: true }; const dispatchPromise = this.dispatchStreamEventByType({ @@ -3341,6 +3448,7 @@ export class AgentManager { options, isForegroundEvent, eventTurnId, + terminalDisposition, flags, }); if (dispatchPromise) { @@ -3429,9 +3537,11 @@ export class AgentManager { options: HandleStreamEventOptions | undefined; isForegroundEvent: boolean; eventTurnId: string | undefined; + terminalDisposition: ActiveTurnTerminalDisposition; flags: StreamEventFlags; }): Promise | undefined { - const { agent, event, options, isForegroundEvent, eventTurnId, flags } = params; + const { agent, event, options, isForegroundEvent, eventTurnId, terminalDisposition, flags } = + params; switch (event.type) { case "thread_started": this.onStreamThreadStarted(agent); @@ -3475,7 +3585,13 @@ export class AgentManager { case "timeline": return this.onStreamTimelineEvent({ agent, event, options, isForegroundEvent, flags }); case "turn_completed": - this.onStreamTurnCompleted({ agent, event, eventTurnId, isForegroundEvent }); + this.onStreamTurnCompleted({ + agent, + event, + eventTurnId, + isForegroundEvent, + terminalDisposition, + }); return undefined; case "turn_failed": return this.onStreamTurnFailed({ @@ -3483,13 +3599,21 @@ export class AgentManager { event, eventTurnId, isForegroundEvent, + terminalDisposition, options, }); case "turn_canceled": - this.onStreamTurnCanceled({ agent, event, eventTurnId, isForegroundEvent, options }); + this.onStreamTurnCanceled({ + agent, + event, + eventTurnId, + isForegroundEvent, + terminalDisposition, + options, + }); return undefined; case "turn_started": - this.onStreamTurnStarted({ agent, eventTurnId, isForegroundEvent }); + this.onStreamTurnStarted({ agent, eventTurnId, isForegroundEvent, flags }); return undefined; case "permission_requested": this.onStreamPermissionRequested(agent, event); @@ -3529,6 +3653,16 @@ export class AgentManager { return; } + if ( + event.item.type === "user_message" && + event.item.clientMessageId && + this.reconcileSubmittedPromptEcho(agent, event, event.item) + ) { + flags.shouldDispatchEvent = false; + flags.shouldNotifyWaiters = false; + return; + } + if (options?.fromHistory) { this.recordTimeline( agent.id, @@ -3554,8 +3688,9 @@ export class AgentManager { event: Extract; eventTurnId: string | undefined; isForegroundEvent: boolean; + terminalDisposition: ActiveTurnTerminalDisposition; }): void { - const { agent, event, eventTurnId, isForegroundEvent } = params; + const { agent, event, eventTurnId, isForegroundEvent, terminalDisposition } = params; this.logger.trace( { agentId: agent.id, @@ -3567,9 +3702,15 @@ export class AgentManager { }, "agent.manager.turn.completed", ); + if (terminalDisposition === "stale") return; agent.lastUsage = event.usage; agent.lastError = undefined; - if (!isForegroundEvent && agent.lifecycle !== "idle" && !agent.pendingReplacement) { + if ( + !isForegroundEvent && + !agent.activeForegroundTurnId && + agent.lifecycle !== "idle" && + !agent.pendingReplacement + ) { (agent as ActiveManagedAgent).lifecycle = "idle"; this.emitState(agent); } @@ -3581,9 +3722,10 @@ export class AgentManager { event: Extract; eventTurnId: string | undefined; isForegroundEvent: boolean; + terminalDisposition: ActiveTurnTerminalDisposition; options: { fromHistory?: boolean } | undefined; }): Promise { - const { agent, event, eventTurnId, isForegroundEvent, options } = params; + const { agent, event, eventTurnId, isForegroundEvent, terminalDisposition, options } = params; this.logger.warn( { agentId: agent.id, @@ -3599,7 +3741,8 @@ export class AgentManager { }, "handleStreamEvent: turn_failed", ); - if (!isForegroundEvent) { + if (terminalDisposition === "stale") return; + if (!isForegroundEvent && !agent.activeForegroundTurnId) { agent.lifecycle = "error"; } agent.lastError = event.error; @@ -3610,7 +3753,7 @@ export class AgentManager { options, ); this.resolvePendingPermissionsForAgent(agent, event.provider, options, "Turn failed"); - if (!isForegroundEvent) { + if (!isForegroundEvent && !agent.activeForegroundTurnId) { this.emitState(agent); } } @@ -3620,13 +3763,14 @@ export class AgentManager { event: Extract; eventTurnId: string | undefined; isForegroundEvent: boolean; + terminalDisposition: ActiveTurnTerminalDisposition; options: | { fromHistory?: boolean; } | undefined; }): void { - const { agent, event, eventTurnId, isForegroundEvent, options } = params; + const { agent, event, eventTurnId, isForegroundEvent, terminalDisposition, options } = params; this.logger.trace( { agentId: agent.id, @@ -3639,12 +3783,13 @@ export class AgentManager { }, "agent.manager.turn.canceled", ); - if (!isForegroundEvent && !agent.pendingReplacement) { + if (terminalDisposition === "stale") return; + if (!isForegroundEvent && !agent.activeForegroundTurnId && !agent.pendingReplacement) { agent.lifecycle = "idle"; } agent.lastError = undefined; this.resolvePendingPermissionsForAgent(agent, event.provider, options, "Interrupted"); - if (!isForegroundEvent) { + if (!isForegroundEvent && !agent.activeForegroundTurnId) { this.emitState(agent); } } @@ -3653,8 +3798,9 @@ export class AgentManager { agent: ActiveManagedAgent; eventTurnId: string | undefined; isForegroundEvent: boolean; + flags: StreamEventFlags; }): void { - const { agent, eventTurnId, isForegroundEvent } = params; + const { agent, eventTurnId, isForegroundEvent, flags } = params; this.logger.trace( { agentId: agent.id, @@ -3666,11 +3812,22 @@ export class AgentManager { }, "agent.manager.turn.started", ); - if (!isForegroundEvent) { - this.runs.trackAutonomousRun(agent.id, eventTurnId ?? null); - agent.lifecycle = "running"; - this.emitState(agent); + if (isForegroundEvent) { + flags.shouldDispatchEvent = false; + flags.shouldNotifyWaiters = false; + return; } + if (agent.activeForegroundTurnId) { + flags.shouldDispatchEvent = false; + flags.shouldNotifyWaiters = false; + return; + } + this.runs.trackAutonomousRun(agent.id, eventTurnId ?? null); + if (eventTurnId) { + this.openActiveTurn(agent, eventTurnId, new Date()); + } + agent.lifecycle = "running"; + this.emitState(agent); } private onStreamPermissionRequested( @@ -3754,6 +3911,63 @@ export class AgentManager { return event; } + private recordSubmittedPrompt( + agent: ActiveManagedAgent, + prompt: AgentPromptInput, + clientMessageId: string, + messageId?: string, + ): void { + if (this.timelineStore.getSubmittedUserMessage(agent.id, clientMessageId)) { + return; + } + this.touchUpdatedAt(agent); + agent.lastUserMessageAt = new Date(); + this.recordAndDispatchTimelineItem( + agent.id, + { + type: "user_message", + text: submittedPromptText(prompt), + clientMessageId, + ...(messageId ? { messageId } : {}), + }, + agent.provider, + ); + } + + private reconcileSubmittedPromptEcho( + agent: ActiveManagedAgent, + event: Extract, + item: Extract, + ): AgentTimelineRow | null { + const { clientMessageId, messageId } = item; + if (!clientMessageId) return null; + const existing = this.timelineStore.getSubmittedUserMessage(agent.id, clientMessageId); + if ( + !existing || + existing.item.type !== "user_message" || + !messageId || + existing.item.messageId === messageId + ) { + return existing; + } + const row = this.timelineStore.enrichSubmittedUserMessage(agent.id, clientMessageId, { + messageId, + }); + if (row) { + this.enqueueDurableTimelineUpdate(agent.id, row); + this.dispatchStream( + agent.id, + { ...event, item: row.item }, + { + seq: row.seq, + epoch: this.timelineStore.getEpoch(agent.id), + timestamp: row.timestamp, + }, + ); + } + return row; + } + private async appendSystemErrorTimelineMessage( agent: ActiveManagedAgent, provider: AgentProvider, @@ -3933,6 +4147,17 @@ export class AgentManager { this.trackBackgroundTask(task); } + private enqueueDurableTimelineUpdate(agentId: string, row: AgentTimelineRow): void { + if (!this.durableTimelineStore) return; + const task = 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", + ); + }); + this.trackBackgroundTask(task); + } + private trackBackgroundTask(task: Promise): void { this.backgroundTasks.add(task); void task.finally(() => { diff --git a/packages/server/src/server/agent/agent-projections.test.ts b/packages/server/src/server/agent/agent-projections.test.ts index c516b84f46..ffa1b5026d 100644 --- a/packages/server/src/server/agent/agent-projections.test.ts +++ b/packages/server/src/server/agent/agent-projections.test.ts @@ -79,6 +79,8 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent currentModeId: "plan", pendingPermissions: pendingPermissionsOverride ?? new Map(), activeForegroundTurnId: activeForegroundTurnIdValue, + activeTurnId: activeForegroundTurnIdValue, + activeTurnStartedAt: lifecycle === "running" ? new Date("2025-01-01T00:00:01.000Z") : null, foregroundTurnWaiters: new Set(), unsubscribeSession: null, timeline: [], @@ -105,6 +107,13 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent }; } +it("projects the daemon-owned active turn identity", () => { + expect(toAgentPayload(createManagedAgent({ lifecycle: "running" })).activeTurn).toEqual({ + turnId: "test-turn-id", + startedAt: "2025-01-01T00:00:01.000Z", + }); +}); + function createPermission(overrides: Partial = {}): AgentPermissionRequest { const base: AgentPermissionRequest = { id: "perm-1", diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index a01c67b29d..d0175b7f7b 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -121,6 +121,12 @@ export function toAgentPayload( updatedAt: agent.updatedAt.toISOString(), lastUserMessageAt: agent.lastUserMessageAt ? agent.lastUserMessageAt.toISOString() : null, status: agent.lifecycle, + activeTurn: agent.activeTurnId + ? { + turnId: agent.activeTurnId, + startedAt: agent.activeTurnStartedAt?.toISOString() ?? null, + } + : null, capabilities: cloneCapabilities(agent.capabilities), currentModeId: agent.currentModeId, availableModes: cloneAvailableModes(agent.availableModes), diff --git a/packages/server/src/server/agent/agent-run-state.ts b/packages/server/src/server/agent/agent-run-state.ts index e747317181..601a5d1f5b 100644 --- a/packages/server/src/server/agent/agent-run-state.ts +++ b/packages/server/src/server/agent/agent-run-state.ts @@ -13,6 +13,8 @@ export interface ForegroundTurnWaiter { export interface PendingForegroundRun { token: string; kind: "foreground"; + clientMessageId: string | null; + stagedEvents: AgentStreamEvent[]; turnId: string | null; started: boolean; settled: boolean; @@ -40,8 +42,8 @@ export interface ForegroundRunAgentState { export class AgentRunState { private readonly runs = new Map(); - createPendingRun(agentId: string): PendingForegroundRun { - const pendingRun = createPendingForegroundRun(); + createPendingRun(agentId: string, clientMessageId?: string): PendingForegroundRun { + const pendingRun = createPendingForegroundRun(clientMessageId ?? null); this.runs.set(agentId, pendingRun); return pendingRun; } @@ -69,7 +71,12 @@ export class AgentRunState { return current; } - const run = createTrackedRun({ kind: "autonomous", turnId, started: true }); + const run: AutonomousAgentRun = { + ...createTrackedRunState(), + kind: "autonomous", + turnId, + started: true, + }; this.runs.set(agentId, run); return run; } @@ -258,32 +265,29 @@ export class ForegroundTurnStream { } } -function createPendingForegroundRun(): PendingForegroundRun { - return createTrackedRun({ kind: "foreground", turnId: null, started: false }); +function createPendingForegroundRun(clientMessageId: string | null): PendingForegroundRun { + return { + ...createTrackedRunState(), + kind: "foreground", + turnId: null, + started: false, + clientMessageId, + stagedEvents: [], + }; } -function createTrackedRun(input: { - kind: "foreground"; - turnId: null; - started: false; -}): PendingForegroundRun; -function createTrackedRun(input: { - kind: "autonomous"; - turnId: string | null; - started: true; -}): AutonomousAgentRun; -function createTrackedRun( - input: - | { kind: "foreground"; turnId: null; started: false } - | { kind: "autonomous"; turnId: string | null; started: true }, -): TrackedAgentRun { +function createTrackedRunState(): { + token: string; + settled: boolean; + settledPromise: Promise; + resolveSettled: () => void; +} { let resolveSettled!: () => void; const settledPromise = new Promise((resolvePromise) => { resolveSettled = resolvePromise; }); return { token: randomUUID(), - ...input, settled: false, settledPromise, resolveSettled, 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 779a55691c..20c3d4d01e 100644 --- a/packages/server/src/server/agent/agent-timeline-store-types.ts +++ b/packages/server/src/server/agent/agent-timeline-store-types.ts @@ -58,4 +58,5 @@ export interface AgentTimelineStore { getLastAssistantMessage(agentId: string): Promise; deleteAgent(agentId: string): Promise; bulkInsert(agentId: string, rows: readonly AgentTimelineRow[]): Promise; + updateCommittedRow(agentId: string, row: AgentTimelineRow): Promise; } diff --git a/packages/server/src/server/agent/agent-timeline-store.test.ts b/packages/server/src/server/agent/agent-timeline-store.test.ts index b033a856e4..b6afa939b2 100644 --- a/packages/server/src/server/agent/agent-timeline-store.test.ts +++ b/packages/server/src/server/agent/agent-timeline-store.test.ts @@ -2,6 +2,36 @@ import { describe, expect, it } from "vitest"; import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js"; describe("InMemoryAgentTimelineStore", () => { + it("enriches a submitted prompt without allocating a second sequence", () => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("agent-1", { epoch: "epoch-1" }); + store.append("agent-1", { + type: "user_message", + text: "Accepted prompt", + clientMessageId: "client-message-1", + }); + + const updated = store.enrichSubmittedUserMessage("agent-1", "client-message-1", { + messageId: "provider-message-1", + }); + + expect(updated).toMatchObject({ + seq: 1, + item: { + type: "user_message", + text: "Accepted prompt", + clientMessageId: "client-message-1", + messageId: "provider-message-1", + }, + }); + expect(store.getRows("agent-1")).toEqual([updated]); + expect(store.fetch("agent-1", { direction: "tail", limit: 1 }).window).toEqual({ + minSeq: 1, + maxSeq: 1, + nextSeq: 2, + }); + }); + it("returns a bounded reset window when an after cursor is behind retained history", () => { const store = new InMemoryAgentTimelineStore(); store.initialize("agent-1", { diff --git a/packages/server/src/server/agent/agent-timeline-store.ts b/packages/server/src/server/agent/agent-timeline-store.ts index f374b17089..d49341cfc6 100644 --- a/packages/server/src/server/agent/agent-timeline-store.ts +++ b/packages/server/src/server/agent/agent-timeline-store.ts @@ -167,6 +167,31 @@ export class InMemoryAgentTimelineStore { return this.requireState(agentId).rows.map(cloneRow); } + getSubmittedUserMessage(agentId: string, clientMessageId: string): AgentTimelineRow | null { + const row = this.requireState(agentId).rows.find( + (candidate) => + candidate.item.type === "user_message" && + candidate.item.clientMessageId === clientMessageId, + ); + return row ? cloneRow(row) : null; + } + + enrichSubmittedUserMessage( + agentId: string, + clientMessageId: string, + enrichment: { messageId: string }, + ): AgentTimelineRow | null { + const state = this.requireState(agentId); + const row = state.rows.find( + (candidate) => + candidate.item.type === "user_message" && + candidate.item.clientMessageId === clientMessageId, + ); + if (!row || row.item.type !== "user_message") return null; + row.item = { ...row.item, messageId: enrichment.messageId }; + return cloneRow(row); + } + getEpoch(agentId: string): string { return this.requireState(agentId).epoch; } diff --git a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts index e4c29c958a..53a2177272 100644 --- a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts +++ b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts @@ -30,7 +30,7 @@ interface CreateAgentLifecycleDispatchDependencies { listActiveWorkspaces: () => Promise; archiveWorkspaceRecord: (workspaceId: string) => Promise; emit: (message: SessionOutboundMessage) => void; - emitAgentRemove: (agentId: string) => void; + emitAgentRemove: (agentId: string) => Promise; emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable) => Promise; markWorkspaceArchiving: (workspaceIds: Iterable, archivingAt: string) => void; clearWorkspaceArchiving: (workspaceIds: Iterable) => void; @@ -222,7 +222,7 @@ export class CreateAgentLifecycleDispatch { ); if (options.agentId) { - this.dependencies.emitAgentRemove(options.agentId); + await this.dependencies.emitAgentRemove(options.agentId); } } } 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 ddc96bf0b0..4ae8a43e96 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 @@ -66,6 +66,7 @@ interface CodexClientLike { type CodexTestSession = AgentSession & { connected: boolean; currentThreadId: string | null; + currentTurnId: string | null; activeForegroundTurnId: string | null; client: CodexClientLike | null; }; @@ -2734,8 +2735,14 @@ describe("Codex app-server provider", () => { } }); - test("rejects an interrupt until Codex identifies the accepted turn", async () => { - const appServer = createFakeCodexAppServer(); + test("waits for Codex to identify an accepted turn before interrupting it", async () => { + const interruptedTurns: unknown[] = []; + const appServer = createFakeCodexAppServer({ + "turn/interrupt": async (params) => { + interruptedTurns.push(params); + return {}; + }, + }); const session = new CodexAppServerAgentSession( createConfig({ cwd: "/workspace/project" }), null, @@ -2747,11 +2754,11 @@ describe("Codex app-server provider", () => { const resultPromise = session.run("Start working."); await appServer.waitForTurnStart(); - await expect(session.interrupt()).rejects.toThrow( - "Cannot interrupt Codex before turn/started identifies the active turn", - ); - + const interruptPromise = session.interrupt(); appServer.startsTurn({ threadId: "thread-1", turnId: "turn-identified-late" }); + await interruptPromise; + + expect(interruptedTurns).toEqual([{ threadId: "thread-1", turnId: "turn-identified-late" }]); appServer.completeTurn(); await resultPromise; appServer.assertNoErrors(); @@ -2760,6 +2767,88 @@ describe("Codex app-server provider", () => { } }); + test("does not interrupt after the accepted turn terminates before identification", async () => { + const interruptedTurns: unknown[] = []; + const appServer = createFakeCodexAppServer({ + "turn/interrupt": async (params) => { + interruptedTurns.push(params); + return {}; + }, + }); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + null, + createTestLogger(), + async () => appServer.child, + ); + + try { + const resultPromise = session.run("Finish before identification."); + await appServer.waitForTurnStart(); + const interruptPromise = session.interrupt(); + appServer.completeTurn(); + + await expect(interruptPromise).rejects.toThrow( + "Cannot interrupt Codex before turn/started identifies the active turn", + ); + await resultPromise; + expect(interruptedTurns).toEqual([]); + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }); + + test("interrupts the next turn after a prior identification wait is abandoned", async () => { + let turnStartCount = 0; + const interruptedTurns: unknown[] = []; + const appServer = createFakeCodexAppServer({ + "turn/start": async () => { + turnStartCount += 1; + return {}; + }, + "turn/interrupt": async (params) => { + interruptedTurns.push(params); + return {}; + }, + }); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + null, + createTestLogger(), + async () => appServer.child, + ); + + try { + const firstResult = session.run("First turn."); + await vi.waitFor(() => expect(turnStartCount).toBe(1)); + const firstInterrupt = session.interrupt(); + appServer.completeTurn({ turnId: "turn-first" }); + await expect(firstInterrupt).rejects.toThrow( + "Cannot interrupt Codex before turn/started identifies the active turn", + ); + await firstResult; + + const secondResult = session.run("Second turn."); + await vi.waitFor(() => expect(turnStartCount).toBe(2)); + const secondInterrupt = session.interrupt(); + appServer.startsTurn({ threadId: "thread-1", turnId: "turn-first" }); + + await vi.waitFor(() => expect(session.currentTurnId).toBeNull()); + expect(interruptedTurns).toEqual([]); + + appServer.startsTurn({ threadId: "thread-1", turnId: "turn-second" }); + await secondInterrupt; + + expect(interruptedTurns).toEqual([{ threadId: "thread-1", turnId: "turn-second" }]); + appServer.completeTurn({ turnId: "turn-second" }); + await secondResult; + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }); + test("rejects an interrupt before Codex initializes the thread", async () => { const appServer = createFakeCodexAppServer(); const session = new CodexAppServerAgentSession( 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 fe1e83406a..d5b20cc5df 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 @@ -1988,6 +1988,7 @@ const TurnCompletedNotificationSchema = z threadId: z.string().optional(), turn: z .object({ + id: z.string().optional(), status: z.string(), error: z .object({ @@ -2263,6 +2264,7 @@ type ParsedCodexNotification = | { kind: "turn_started"; turnId: string; threadId: string | null } | { kind: "turn_completed"; + turnId: string | null; status: string; errorMessage: string | null; threadId: string | null; @@ -2413,6 +2415,7 @@ const CodexNotificationSchema = z.union([ .transform( ({ params }): ParsedCodexNotification => ({ kind: "turn_completed", + turnId: params.turn.id ?? null, status: params.turn.status, errorMessage: params.turn.error?.message ?? null, threadId: params.threadId ?? null, @@ -2833,6 +2836,7 @@ const CodexNotificationSchema = z.union([ .transform( ({ params }): ParsedCodexNotification => ({ kind: "turn_completed", + turnId: null, status: "interrupted", errorMessage: null, threadId: getCodexEventThreadId(params), @@ -2853,6 +2857,7 @@ const CodexNotificationSchema = z.union([ .transform( ({ params }): ParsedCodexNotification => ({ kind: "turn_completed", + turnId: null, status: "completed", errorMessage: null, threadId: getCodexEventThreadId(params), @@ -3108,6 +3113,12 @@ export class CodexAppServerAgentSession implements AgentSession { private currentMode: string; private currentThreadId: string | null = null; private currentTurnId: string | null = null; + private readonly finalizedNativeTurnIds = new Set(); + private pendingForegroundTurnIdentification: { + foregroundTurnId: string; + promise: Promise; + resolve: (turnId: string | null) => void; + } | null = null; private client: CodexAppServerClient | null = null; private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private nextTurnOrdinal = 0; @@ -3862,6 +3873,16 @@ export class CodexAppServerAgentSession implements AgentSession { this.activeForegroundTurnId = turnId; this.activeClientMessageId = options?.clientMessageId ?? null; this.currentTurnId = null; + this.pendingForegroundTurnIdentification?.resolve(null); + let resolveTurnIdentification!: (identifiedTurnId: string | null) => void; + const turnIdentification = new Promise((resolvePromise) => { + resolveTurnIdentification = resolvePromise; + }); + this.pendingForegroundTurnIdentification = { + foregroundTurnId: turnId, + promise: turnIdentification, + resolve: resolveTurnIdentification, + }; this.logTurnStartSummary({ turnId, @@ -3875,6 +3896,8 @@ export class CodexAppServerAgentSession implements AgentSession { await this.client.request("turn/start", turnStart.params, TURN_START_TIMEOUT_MS); return { turnId }; } catch (error) { + this.pendingForegroundTurnIdentification?.resolve(null); + this.pendingForegroundTurnIdentification = null; this.activeForegroundTurnId = null; this.activeClientMessageId = null; throw error; @@ -4264,14 +4287,26 @@ export class CodexAppServerAgentSession implements AgentSession { if (!this.client || !this.currentThreadId) { throw new Error("Cannot interrupt Codex before the active thread is initialized"); } - if (!this.currentTurnId) { + let turnId = this.currentTurnId; + const foregroundTurnId = this.activeForegroundTurnId; + const pendingIdentification = this.pendingForegroundTurnIdentification; + // turn/start is accepted before Codex publishes the native turn id. Keep the + // interrupt attached to this foreground turn until that ordered notification arrives. + if ( + !turnId && + foregroundTurnId && + pendingIdentification?.foregroundTurnId === foregroundTurnId + ) { + turnId = await pendingIdentification.promise; + } + if (!turnId || (foregroundTurnId && this.activeForegroundTurnId !== foregroundTurnId)) { throw new Error("Cannot interrupt Codex before turn/started identifies the active turn"); } await this.client.request( "turn/interrupt", { threadId: this.currentThreadId, - turnId: this.currentTurnId, + turnId, }, INTERRUPT_TIMEOUT_MS, ); @@ -4288,6 +4323,8 @@ export class CodexAppServerAgentSession implements AgentSession { this.subscribers.clear(); this.activeForegroundTurnId = null; this.activeClientMessageId = null; + this.pendingForegroundTurnIdentification?.resolve(null); + this.pendingForegroundTurnIdentification = null; if (this.client) { await this.client.dispose(); } @@ -5299,7 +5336,18 @@ export class CodexAppServerAgentSession implements AgentSession { this.emitSubAgentActivityUpdate(subAgentCallId, "running"); return; } + if (this.finalizedNativeTurnIds.has(parsed.turnId)) { + return; + } this.currentTurnId = parsed.turnId; + const pendingIdentification = this.pendingForegroundTurnIdentification; + if ( + pendingIdentification && + pendingIdentification.foregroundTurnId === this.activeForegroundTurnId + ) { + pendingIdentification.resolve(parsed.turnId); + this.pendingForegroundTurnIdentification = null; + } this.resetTurnTrackingState(); this.emitEvent({ type: "turn_started", provider: CODEX_PROVIDER }); } @@ -5318,6 +5366,10 @@ export class CodexAppServerAgentSession implements AgentSession { this.emitSubAgentActivityUpdate(subAgentCallId, status); return; } + const finalizedTurnId = parsed.turnId ?? this.currentTurnId; + if (finalizedTurnId) { + this.finalizedNativeTurnIds.add(finalizedTurnId); + } if (parsed.status === "failed") { this.emitEvent({ type: "turn_failed", @@ -5338,6 +5390,9 @@ export class CodexAppServerAgentSession implements AgentSession { } this.activeForegroundTurnId = null; this.activeClientMessageId = null; + this.currentTurnId = null; + this.pendingForegroundTurnIdentification?.resolve(null); + this.pendingForegroundTurnIdentification = null; this.pendingSubAgentNotificationsByThreadId.clear(); this.resetTurnTrackingState(); } 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 0e7488e9aa..b72379abcc 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 @@ -51,7 +51,7 @@ export interface FakeCodexAppServer { waitForTurnStart(): Promise; nextResponse(): Promise; startsTurn(params: { threadId: string; turnId?: string }): void; - completeTurn(params?: { threadId?: string }): void; + completeTurn(params?: { threadId?: string; turnId?: string }): void; startsSubAgent(params: { callId: string; threadId: string; @@ -323,7 +323,10 @@ export function createFakeCodexAppServer( child.stdout.write( `${JSON.stringify({ method: "turn/completed", - params: { threadId: params.threadId ?? "thread-1", turn: { status: "completed" } }, + params: { + threadId: params.threadId ?? "thread-1", + turn: { id: params.turnId, status: "completed" }, + }, })}\n`, ); }, diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts index c4377cb1a9..99ce0596c3 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts @@ -68,6 +68,79 @@ describe("MockLoadTestAgentClient", () => { await session.interrupt(); }); + test("can withhold the provider user-message echo until an immediate interrupt", async () => { + vi.useFakeTimers(); + const client = new MockLoadTestAgentClient(); + const session = await client.createSession({ + provider: "mock", + cwd: process.cwd(), + model: "ten-second-stream", + }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await session.startTurn("Withhold synthetic user message until interrupted.", { + clientMessageId: "client-message-1", + }); + await vi.advanceTimersByTimeAsync(60_000); + expect(events.some((event) => event.type === "timeline")).toBe(false); + + await session.interrupt(); + expect(events.map((event) => event.type)).toEqual(["turn_canceled"]); + }); + + test("can emit the provider user-message echo before accepting the turn", async () => { + const client = new MockLoadTestAgentClient(); + const session = await client.createSession({ + provider: "mock", + cwd: process.cwd(), + model: "ten-second-stream", + }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await session.startTurn("Emit synthetic user message before accepting turn.", { + clientMessageId: "client-message-1", + }); + + expect(events).toContainEqual({ + type: "timeline", + provider: "mock", + turnId: expect.any(String), + item: { + type: "user_message", + text: "Emit synthetic user message before accepting turn.", + messageId: expect.any(String), + clientMessageId: "client-message-1", + }, + }); + await session.interrupt(); + }); + + test("can place the provider echo beyond a bounded timeline tail", async () => { + const client = new MockLoadTestAgentClient(); + const session = await client.createSession({ + provider: "mock", + cwd: process.cwd(), + model: "ten-second-stream", + }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await session.run("Emit 205 assistant messages before synthetic user message.", { + clientMessageId: "client-message-1", + }); + + const timelineItems = events.flatMap((event) => + event.type === "timeline" ? [event.item] : [], + ); + expect(timelineItems.filter((item) => item.type === "assistant_message")).toHaveLength(205); + expect(timelineItems.at(-1)).toMatchObject({ + type: "user_message", + clientMessageId: "client-message-1", + }); + }); + test("returns schema-shaped JSON for structured branch-name generation", async () => { vi.useFakeTimers(); const client = new MockLoadTestAgentClient(); 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 fd2086efe2..6a893c39ad 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 @@ -250,6 +250,22 @@ function parseUserMessageDelayMs(prompt: AgentPromptInput): number { return Number.isSafeInteger(delayMs) ? Math.min(delayMs, 2_000) : 0; } +function shouldWithholdUserMessageUntilInterrupt(prompt: AgentPromptInput): boolean { + return /withhold synthetic user message until interrupted/i.test(promptToText(prompt)); +} + +function shouldEmitUserMessageBeforeTurnAcceptance(prompt: AgentPromptInput): boolean { + return /emit synthetic user message before accepting turn/i.test(promptToText(prompt)); +} + +function parseAssistantMessagesBeforeUserMessage(prompt: AgentPromptInput): number | null { + const match = /emit (\d+) assistant messages before synthetic user message/i.exec( + promptToText(prompt), + ); + const count = Number(match?.[1]); + return Number.isSafeInteger(count) && count > 0 ? Math.min(count, 500) : null; +} + function parseLargeAgentStreamPayloadPrompt( prompt: AgentPromptInput, ): LargeAgentStreamPayloadRequest | null { @@ -687,9 +703,7 @@ export class MockLoadTestAgentSession implements AgentSession { this.schedule(turn, 0); } }; - const userMessageId = randomUUID(); - const userMessageDelayMs = parseUserMessageDelayMs(prompt); - const userMessageTimer = setTimeout(() => { + const emitUserMessage = () => { if (this.activeTurn?.turnId !== turnId) { return; } @@ -701,10 +715,44 @@ export class MockLoadTestAgentSession implements AgentSession { item: { type: "user_message", text: promptToText(prompt), - messageId: userMessageId, + messageId: randomUUID(), ...(options?.clientMessageId ? { clientMessageId: options.clientMessageId } : {}), }, }); + }; + if (shouldEmitUserMessageBeforeTurnAcceptance(prompt)) { + emitUserMessage(); + scheduleTurn(); + return { turnId }; + } + if (shouldWithholdUserMessageUntilInterrupt(prompt)) { + return { turnId }; + } + const assistantMessagesBeforeUserMessage = parseAssistantMessagesBeforeUserMessage(prompt); + if (assistantMessagesBeforeUserMessage !== null) { + turn.timer = setTimeout(async () => { + if (this.activeTurn !== turn) return; + this.emitTurnStarted(turn); + for (let index = 0; index < assistantMessagesBeforeUserMessage; index += 1) { + this.emitTimeline(turnId, { + type: "assistant_message", + text: `Synthetic pre-echo message ${index + 1}`, + messageId: `${turn.assistantMessageId}-${index + 1}`, + }); + await new Promise((resolvePromise) => setImmediate(resolvePromise)); + if (this.activeTurn !== turn) return; + } + emitUserMessage(); + await new Promise((resolvePromise) => setImmediate(resolvePromise)); + if (this.activeTurn !== turn) return; + this.finishTurnWithText(turn, "Synthetic pre-echo stream complete"); + }, 0); + turn.timer.unref?.(); + return { turnId }; + } + const userMessageDelayMs = parseUserMessageDelayMs(prompt); + const userMessageTimer = setTimeout(() => { + emitUserMessage(); if (userMessageDelayMs > 0) scheduleTurn(); }, userMessageDelayMs); userMessageTimer.unref?.(); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 2a1005cb75..f2a49d06ff 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1077,7 +1077,7 @@ export async function createPaseoDaemon( listActiveWorkspaces: listActiveWorkspacesExternal, archiveWorkspaceRecord: archiveWorkspaceRecordExternal, emit: emitExternalSessionMessage, - emitAgentRemove: () => undefined, + emitAgentRemove: async () => undefined, emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal, markWorkspaceArchiving: markWorkspaceArchivingExternal, clearWorkspaceArchiving: clearWorkspaceArchivingExternal, diff --git a/packages/server/src/server/messages.test.ts b/packages/server/src/server/messages.test.ts index 411d81c789..93e872b3cf 100644 --- a/packages/server/src/server/messages.test.ts +++ b/packages/server/src/server/messages.test.ts @@ -4,6 +4,22 @@ import type { AgentStreamEvent } from "./agent/agent-sdk-types.js"; import { SessionInboundMessageSchema, serializeAgentStreamEvent } from "./messages.js"; describe("serializeAgentStreamEvent", () => { + test("preserves daemon turn identity on lifecycle events", () => { + expect( + serializeAgentStreamEvent({ + type: "turn_canceled", + provider: "codex", + turnId: "turn-accepted-1", + reason: "interrupted", + }), + ).toEqual({ + type: "turn_canceled", + provider: "codex", + turnId: "turn-accepted-1", + reason: "interrupted", + }); + }); + test("accepts create_agent_request env records", () => { const parsed = SessionInboundMessageSchema.parse({ type: "create_agent_request", diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 2682979fe1..34ee6eae74 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1644,7 +1644,8 @@ export class Session { private async buildAgentPayload(agent: ManagedAgent): Promise { const storedRecord = await this.agentStorage.get(agent.id); const title = storedRecord?.title ?? null; - const payload = toAgentPayload(agent, { title }); + const currentAgent = this.agentManager.getAgent(agent.id) ?? agent; + const payload = toAgentPayload(currentAgent, { title }); const storedUpdatedAt = storedRecord ? resolveStoredAgentPayloadUpdatedAt(storedRecord) : null; if (storedUpdatedAt) { const liveUpdatedAt = Date.parse(payload.updatedAt); @@ -2360,7 +2361,7 @@ export class Session { }, }); - this.agentUpdates.removeAgent(agentId); + await this.agentUpdates.removeAgent(agentId); if (knownWorkspaceId) { await this.emitWorkspaceUpdateForWorkspaceId(knownWorkspaceId); diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 8a87e5064d..35860a5a78 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -22,7 +22,7 @@ import type { AgentUpdatesService } from "./session/agent-updates/agent-updates- import type { AgentSnapshotPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import { createTerminalManager } from "../terminal/terminal-manager.js"; -import { AgentManager } from "./agent/agent-manager.js"; +import { AgentManager, type AgentManagerEvent, type ManagedAgent } from "./agent/agent-manager.js"; import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js"; import type { AgentClient, @@ -79,6 +79,14 @@ const UNREGISTERED_CWD = path.resolve("/tmp/unregistered"); const terminalManagers: TerminalManager[] = []; +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + async function flushWorkspaceUpdateBackgroundWork(): Promise { await waitForImmediate(); } @@ -536,6 +544,8 @@ function createSessionForWorkspaceTests( onWorkspaceRecovered?: SessionOptions["onWorkspaceRecovered"]; workspaceGitService?: ReturnType; terminalManager?: TerminalManager | null; + agentManager?: { [K in keyof SessionOptions["agentManager"]]?: unknown }; + agentStorage?: { [K in keyof SessionOptions["agentStorage"]]?: unknown }; projectRegistry?: SessionOptions["projectRegistry"]; workspaceRegistry?: SessionOptions["workspaceRegistry"]; github?: ForgeService; @@ -565,6 +575,7 @@ function createSessionForWorkspaceTests( unarchiveSnapshot: async () => true, clearAgentAttention: async () => {}, notifyAgentState: () => {}, + ...options.agentManager, }); const workspaceRegistry: SessionOptions["workspaceRegistry"] = options.workspaceRegistry ?? { initialize: async () => {}, @@ -653,6 +664,7 @@ function createSessionForWorkspaceTests( }) : null, upsert: async () => {}, + ...options.agentStorage, }), projectRegistry: options.projectRegistry ?? { initialize: async () => {}, @@ -722,6 +734,77 @@ function createSessionForWorkspaceTests( return session; } +test("agent updates pair a stored revision with current live state", async () => { + const idle = makeManagedAgent({ + id: "agent-coherent", + cwd: REPO_CWD, + workspaceId: "ws-repo-running", + lifecycle: "idle", + updatedAt: "2026-07-31T10:00:00.000Z", + }) as unknown as ManagedAgent; + const running = makeManagedAgent({ + id: "agent-coherent", + cwd: REPO_CWD, + workspaceId: "ws-repo-running", + lifecycle: "running", + updatedAt: "2026-07-31T10:00:01.000Z", + }) as unknown as ManagedAgent; + const stored = makeStoredAgent({ + id: "agent-coherent", + cwd: REPO_CWD, + updatedAt: "2026-07-31T10:00:02.000Z", + }); + const storageReadStarted = deferred(); + const storageRead = deferred(); + const emittedAgentUpdate = + deferred["payload"]>(); + let current = idle; + let forwardAgentEvent: ((event: AgentManagerEvent) => void) | null = null; + const session = createSessionForWorkspaceTests({ + onMessage: (message) => { + if (message.type === "agent_update") emittedAgentUpdate.resolve(message.payload); + }, + agentManager: { + subscribe: (listener: (event: AgentManagerEvent) => void) => { + forwardAgentEvent = listener; + return () => {}; + }, + getAgent: () => current, + }, + agentStorage: { + get: async () => { + storageReadStarted.resolve(); + return storageRead.promise; + }, + }, + }); + session.projectRegistry.get = async () => + createPersistedProjectRecord({ + projectId: "proj-repo-running", + rootPath: REPO_CWD, + kind: "non_git", + displayName: "repo", + createdAt: "2026-07-31T10:00:00.000Z", + updatedAt: "2026-07-31T10:00:00.000Z", + }); + activateAgentUpdatesSubscription(session, "sub-coherent"); + if (!forwardAgentEvent) throw new Error("Agent event listener was not installed"); + + forwardAgentEvent({ type: "agent_state", agent: idle }); + await storageReadStarted.promise; + current = running; + storageRead.resolve(stored); + + await expect(emittedAgentUpdate.promise).resolves.toMatchObject({ + kind: "upsert", + agent: { + id: "agent-coherent", + status: "running", + updatedAt: "2026-07-31T10:00:02.000Z", + }, + }); +}); + test("client heartbeat clears attention for the focused terminal", async () => { const clearedTerminalIds: string[] = []; const session = createSessionForWorkspaceTests({ diff --git a/packages/server/src/server/session/agent-updates/agent-updates-service.test.ts b/packages/server/src/server/session/agent-updates/agent-updates-service.test.ts index 3a5fa2e3ce..e3ae732cb9 100644 --- a/packages/server/src/server/session/agent-updates/agent-updates-service.test.ts +++ b/packages/server/src/server/session/agent-updates/agent-updates-service.test.ts @@ -16,6 +16,14 @@ import type { StoredAgentRecord } from "../../agent/agent-storage.js"; type AgentUpdatePayload = Extract["payload"]; +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + function makeAgentPayload(input: { id: string; workspaceId?: string; @@ -87,6 +95,7 @@ function buildHarness() { const workspaceUpdates: string[] = []; const loggedErrors: unknown[][] = []; const payloadById = new Map(); + const queuedPayloadBuilds: Promise[] = []; const projectByWorkspaceId = new Map(); let providerVisible: (provider: string) => boolean = () => true; let buildAgentPayloadError: Error | null = null; @@ -94,6 +103,10 @@ function buildHarness() { const service = createAgentUpdatesService({ emit: (message) => emitted.push(message), buildAgentPayload: async (agent) => { + const queuedPayload = queuedPayloadBuilds.shift(); + if (queuedPayload) { + return queuedPayload; + } if (buildAgentPayloadError) { throw buildAgentPayloadError; } @@ -142,6 +155,9 @@ function buildHarness() { failBuildAgentPayload(error: Error) { buildAgentPayloadError = error; }, + queuePayloadBuilds(...payloads: Promise[]) { + queuedPayloadBuilds.push(...payloads); + }, agentUpdates(): AgentUpdatePayload[] { return emitted .filter((message) => message.type === "agent_update") @@ -238,6 +254,48 @@ describe("matchesAgentUpdatesFilter", () => { }); describe("forwardLiveAgent", () => { + test("emits snapshots for one agent in forward call order", async () => { + const h = buildHarness(); + h.service.beginSubscription({ subscriptionId: "sub", filter: {} }); + h.service.flushBootstrapped("sub"); + const idle = makeAgentPayload({ id: "a", workspaceId: "ws-1", status: "idle" }); + const running = makeAgentPayload({ id: "a", workspaceId: "ws-1", status: "running" }); + h.register(running); + const delayedIdleBuild = deferred(); + h.queuePayloadBuilds(delayedIdleBuild.promise, Promise.resolve(running)); + + const idleForward = h.service.forwardLiveAgent(h.managed("a")); + const runningForward = h.service.forwardLiveAgent(h.managed("a")); + await new Promise((resolve) => setImmediate(resolve)); + delayedIdleBuild.resolve(idle); + await Promise.all([idleForward, runningForward]); + + expect( + h.agentUpdates().map((update) => update.kind === "upsert" && update.agent.status), + ).toEqual(["idle", "running"]); + }); + + test("emits removal after an earlier queued upsert for the same agent", async () => { + const h = buildHarness(); + h.service.beginSubscription({ subscriptionId: "sub", filter: {} }); + h.service.flushBootstrapped("sub"); + const running = makeAgentPayload({ id: "a", workspaceId: "ws-1", status: "running" }); + h.register(running); + const delayedBuild = deferred(); + h.queuePayloadBuilds(delayedBuild.promise); + + const runningForward = h.service.forwardLiveAgent(h.managed("a")); + const removal = h.service.removeAgent("a"); + await new Promise((resolve) => setImmediate(resolve)); + delayedBuild.resolve(running); + await Promise.all([runningForward, removal]); + + expect(h.agentUpdates()).toEqual([ + { kind: "upsert", agent: expect.objectContaining({ id: "a" }), project: makeProject() }, + { kind: "remove", agentId: "a" }, + ]); + }); + test("emits an upsert for a matching agent and updates its workspace", async () => { const h = buildHarness(); h.service.beginSubscription({ subscriptionId: "sub", filter: {} }); @@ -394,7 +452,7 @@ describe("bootstrap buffering", () => { }); }); - test("skips a buffered upsert that is not newer than the snapshot", async () => { + test("skips a buffered upsert that is older than the snapshot", async () => { const h = buildHarness(); h.service.beginSubscription({ subscriptionId: "sub", filter: {} }); @@ -419,11 +477,39 @@ describe("bootstrap buffering", () => { ]); }); + test("replays a buffered live update with the same revision as the snapshot", async () => { + const h = buildHarness(); + h.service.beginSubscription({ subscriptionId: "sub", filter: {} }); + h.register( + makeAgentPayload({ + id: "equal-revision", + workspaceId: "ws-1", + status: "running", + updatedAt: "2026-03-02T00:00:00.000Z", + }), + ); + + await h.service.forwardLiveAgent(h.managed("equal-revision")); + h.service.flushBootstrapped("sub", { + snapshotUpdatedAtByAgentId: new Map([ + ["equal-revision", Date.parse("2026-03-02T00:00:00.000Z")], + ]), + }); + + expect(h.agentUpdates()).toEqual([ + { + kind: "upsert", + agent: expect.objectContaining({ id: "equal-revision", status: "running" }), + project: makeProject(), + }, + ]); + }); + test("a removed agent is always replayed, even against a snapshot", async () => { const h = buildHarness(); h.service.beginSubscription({ subscriptionId: "sub", filter: {} }); - h.service.removeAgent("a"); + await h.service.removeAgent("a"); expect(h.agentUpdates()).toEqual([]); // buffered h.service.flushBootstrapped("sub", { @@ -479,18 +565,18 @@ describe("subscription lifecycle", () => { expect(h.service.hasSubscription()).toBe(false); }); - test("removeAgent is a no-op without a subscription", () => { + test("removeAgent is a no-op without a subscription", async () => { const h = buildHarness(); - h.service.removeAgent("a"); + await h.service.removeAgent("a"); expect(h.agentUpdates()).toEqual([]); }); - test("removeAgent emits a remove for a live subscription", () => { + test("removeAgent emits a remove for a live subscription", async () => { const h = buildHarness(); h.service.beginSubscription({ subscriptionId: "sub", filter: {} }); h.service.flushBootstrapped("sub"); - h.service.removeAgent("a"); + await h.service.removeAgent("a"); expect(h.agentUpdates()).toEqual([{ kind: "remove", agentId: "a" }]); }); diff --git a/packages/server/src/server/session/agent-updates/agent-updates-service.ts b/packages/server/src/server/session/agent-updates/agent-updates-service.ts index 4630823cca..45d256c0aa 100644 --- a/packages/server/src/server/session/agent-updates/agent-updates-service.ts +++ b/packages/server/src/server/session/agent-updates/agent-updates-service.ts @@ -46,7 +46,7 @@ export interface AgentUpdatesService { hasSubscription(): boolean; forwardLiveAgent(agent: ManagedAgent): Promise; emitStoredRecord(record: StoredAgentRecord): Promise; - removeAgent(agentId: string): void; + removeAgent(agentId: string): Promise; dispose(): void; } @@ -150,6 +150,7 @@ function agentUpdateTargetId(update: AgentUpdatePayload): string { export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentUpdatesService { let subscription: AgentUpdatesSubscriptionState | null = null; + const liveAgentUpdateTails = new Map>(); function bufferOrEmit(sub: AgentUpdatesSubscriptionState, payload: AgentUpdatePayload): void { if (payload.kind === "upsert" && !deps.isProviderVisibleToClient(payload.agent.provider)) { @@ -195,7 +196,7 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU const snapshotUpdatedAt = options?.snapshotUpdatedAtByAgentId?.get(payload.agent.id); if (typeof snapshotUpdatedAt === "number") { const updateUpdatedAt = Date.parse(payload.agent.updatedAt); - if (!Number.isNaN(updateUpdatedAt) && updateUpdatedAt <= snapshotUpdatedAt) { + if (!Number.isNaN(updateUpdatedAt) && updateUpdatedAt < snapshotUpdatedAt) { continue; } } @@ -218,13 +219,6 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU return subscription !== null; } - function removeAgent(agentId: string): void { - if (!subscription) { - return; - } - bufferOrEmit(subscription, { kind: "remove", agentId }); - } - async function emitStoredRecord(record: StoredAgentRecord): Promise { const payload = deps.buildStoredAgentPayload(record); const sub = subscription; @@ -264,7 +258,7 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU return payload; } - async function forwardLiveAgent(agent: ManagedAgent): Promise { + async function emitLiveAgentUpdate(agent: ManagedAgent): Promise { try { const sub = subscription; const payload = await deps.buildAgentPayload(agent); @@ -309,6 +303,33 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU } } + function enqueueAgentUpdate( + agentId: string, + emitUpdate: () => void | Promise, + ): Promise { + const previous = liveAgentUpdateTails.get(agentId) ?? Promise.resolve(); + const next = previous.catch(() => undefined).then(emitUpdate); + liveAgentUpdateTails.set(agentId, next); + void next.finally(() => { + if (liveAgentUpdateTails.get(agentId) === next) { + liveAgentUpdateTails.delete(agentId); + } + }); + return next; + } + + function forwardLiveAgent(agent: ManagedAgent): Promise { + return enqueueAgentUpdate(agent.id, () => emitLiveAgentUpdate(agent)); + } + + function removeAgent(agentId: string): Promise { + return enqueueAgentUpdate(agentId, () => { + if (subscription) { + bufferOrEmit(subscription, { kind: "remove", agentId }); + } + }); + } + function dispose(): void { subscription = null; } diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index 6a6cef196d..650e5150f4 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -921,7 +921,7 @@ describe("relay external socket reconnect behavior", () => { await server.close(); }); - test("advertises stable project identity in initial server_info", async () => { + test("advertises current features in initial server_info", async () => { const server = createServer(); const socket = new MockSocket(); @@ -932,6 +932,8 @@ describe("relay external socket reconnect behavior", () => { }); expect(serverInfo.features?.stableProjectIdentity).toBe(true); + expect(serverInfo.features?.canonicalSubmittedPrompts).toBe(true); + expect(serverInfo.features?.agentTurnIdentity).toBeUndefined(); await server.close(); }); diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index 0b2c079744..9c4ceb0899 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -408,11 +408,14 @@ describe("wire compatibility", () => { ]); }); - test("server info accepts legacy feature payloads without stable project identity", () => { + test("server info strips unknown legacy features while accepting former turn identity", () => { const parsed = ServerInfoStatusPayloadSchema.parse({ status: "server_info", serverId: "legacy-server", - features: { workspaceGithubClone: true }, + features: { + workspaceGithubClone: true, + agentTurnIdentity: true, + }, }); expect(parsed).toEqual({ @@ -420,7 +423,7 @@ describe("wire compatibility", () => { serverId: "legacy-server", hostname: null, version: null, - features: {}, + features: { agentTurnIdentity: true }, }); });