diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index b881744f5..b852e340a 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -87,14 +87,42 @@ its completion advances `seqEnd`, followed by a merged assistant message. The ap remaining page through the existing stream reducer. It must not append full projected text to a live prefix. -Optimistic user prompts occupy stable timeline slots. Catch-up never extracts, delays, or reinserts -them. A canonical user row replaces its matching slot in place; an unmatched prompt stays exactly -where the user submitted it. Other canonical rows are applied after the already-present timeline -instead of relocating visible user messages around newly fetched history. +Every path that sends a message to an agent — composer send, dictation accept-and-send, queued +send-now, and the automatic queue drain in `HostRuntime` — goes through +`dispatchComposerAgentMessage` with a submission writer. There is no second transport for the same +product action: calling `client.sendAgentMessage` directly skips the submitted row and the pending +footer, and permanently drops attachments because the daemon does not echo them back. + +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 or +a property inferred from message identity. The transaction registry holds every unresolved send and +records RPC acceptance and provider acknowledgement independently. Provider acknowledgement exists +solely so a later transport error cannot roll back a prompt already observed canonically. + +The daemon's accepted response already waits for the correlated run start, but its response and the +directory update reach client state separately. An accepted transaction remains active until the +directory observes that run or canonical ingestion acknowledges the prompt, bridging those ordered +authorities without inspecting timeline snapshots. Either signal clears only an RPC-accepted +transaction, regardless of which arrived first; it cannot settle a fresh send. +Overlapping sends settle independently rather than collapsing to one newest pending message. Canonical submitted user rows carry the provider's `messageId` and Paseo's optional -`clientMessageId`. Clients reconcile optimistic prompts by `clientMessageId`. Content matching is -limited to the dated compatibility path for daemon timelines created before that field existed. +`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. +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 +arrived first. Generic reducers and consumers do not reimplement message identity matching. + +Ordinary bootstrap, same-epoch reset, and catch-up replacement preserve unmatched locally submitted +rows because a provider may never echo them. A known epoch change or rewind replaces history and +drops acknowledged local rows omitted by the new canonical epoch; every transaction not yet +acknowledged by the provider, and no other local row, crosses that destructive boundary. + +Canonical replacement owns both timeline lanes. A matching local row keeps its presentation ID and +payload while taking the canonical row's ordered position. If a live assistant head is the +canonical assistant prefix, it stays in the head lane. No row may be returned in both lanes. ## Relevant code diff --git a/packages/app/e2e/agent-message-submission.spec.ts b/packages/app/e2e/agent-message-submission.spec.ts new file mode 100644 index 000000000..092159b9b --- /dev/null +++ b/packages/app/e2e/agent-message-submission.spec.ts @@ -0,0 +1,685 @@ +import type { Locator, Page } from "@playwright/test"; +import { expect, test as baseTest } from "./fixtures"; +import { awaitToolCall, expectAgentIdle } from "./helpers/agent-stream"; +import { gateNextAgentMessage } from "./helpers/agent-message-gate"; +import { + attachImageFromMenu, + expectComposerDraft, + expectComposerEditable, + expectAttachmentPill, + expectComposerVisible, + fillComposerDraft, + sendDraftToQueue, + startRunningMockAgent, +} from "./helpers/composer"; +import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; +import { readScrollMetrics } from "./helpers/agent-bottom-anchor"; +import { seedWorkspace } from "./helpers/seed-client"; +import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; +import { getServerId } from "./helpers/server-id"; +import { buildHostWorkspaceRoute } from "@/utils/host-routes"; +import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace"; +import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; +import { selectModel } from "./helpers/app"; + +const IMAGE = { + name: "message-submission.png", + mimeType: "image/png", + buffer: Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + "base64", + ), +}; + +interface MessageGeometry { + x: number; + y: number; + width: number; + height: number; +} + +interface SubmissionScenario { + gate: Awaited>; +} + +interface DraftCreateScenario { + workspaceId: string; + agentCreatedDelay: Awaited>; +} + +interface RejectionScenario { + errorMessage: string; +} + +interface UnrelatedRunningScenario { + gate: Awaited>; + agent: Awaited>; +} + +const test = baseTest.extend<{ + submissionScenario: SubmissionScenario; + draftCreateScenario: DraftCreateScenario; + rejectionScenario: RejectionScenario; + unrelatedRunningScenario: UnrelatedRunningScenario; +}>({ + submissionScenario: async ({ page }, provide, testInfo) => { + const gate = await gateNextAgentMessage(page); + const agent = await seedMockAgentWorkspace({ + repoPrefix: `message-submission-${testInfo.workerIndex}-`, + title: "Message submission regression", + model: "ten-second-stream", + }); + await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); + await expectComposerVisible(page); + await expectAgentIdle(page); + await provide({ gate }); + await agent.cleanup(); + }, + draftCreateScenario: async ({ page }, provide, testInfo) => { + const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page); + const workspace = await seedWorkspace({ + repoPrefix: `message-create-handoff-${testInfo.workerIndex}-`, + }); + await provide({ workspaceId: workspace.workspaceId, agentCreatedDelay }); + agentCreatedDelay.release(); + await workspace.cleanup(); + }, + rejectionScenario: async ({ page }, provide, testInfo) => { + const errorMessage = "Requested mock prompt rejection"; + const agent = await seedMockAgentWorkspace({ + repoPrefix: `message-rejection-${testInfo.workerIndex}-`, + title: "Message rejection regression", + model: "ten-second-stream", + featureValues: { mockPromptRejections: 1 }, + }); + await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); + await expectComposerVisible(page); + await expectAgentIdle(page); + await provide({ errorMessage }); + await agent.cleanup(); + }, + unrelatedRunningScenario: async ({ page }, provide, testInfo) => { + const gate = await gateNextAgentMessage(page); + const agent = await seedMockAgentWorkspace({ + repoPrefix: `unrelated-running-${testInfo.workerIndex}-`, + title: "Unrelated running transition", + model: "one-minute-stream", + }); + await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); + await expectComposerVisible(page); + await expectAgentIdle(page); + await provide({ gate, agent }); + await agent.cleanup(); + }, +}); + +async function submitMessageWithImage(page: Page, prompt: string): Promise { + await attachImageFromMenu(page, IMAGE); + await expectAttachmentPill(page, "composer-image-attachment-pill"); + const composer = page.getByRole("textbox", { name: "Message agent..." }).first(); + await composer.fill(prompt); + await composer.press("Enter"); + const nextFrame = await composer.evaluate( + (composerElement, submittedPrompt) => + new Promise<{ + rowPresent: boolean; + workingPresent: boolean; + composerValue: string | null; + attachmentPresent: boolean; + }>((resolve) => { + requestAnimationFrame(() => { + const rows = Array.from(document.querySelectorAll('[data-testid="user-message"]')); + const composerInput = composerElement as HTMLInputElement | HTMLTextAreaElement; + resolve({ + rowPresent: rows.some((row) => row.textContent?.includes(submittedPrompt)), + workingPresent: Boolean( + document.querySelector('[data-testid="turn-working-indicator"]'), + ), + composerValue: composerInput.value, + attachmentPresent: Boolean( + document.querySelector('[data-testid="composer-image-attachment-pill"]'), + ), + }); + }); + }), + prompt, + ); + expect(nextFrame).toEqual({ + rowPresent: true, + workingPresent: true, + composerValue: "", + attachmentPresent: false, + }); + return page.getByTestId("user-message").filter({ hasText: prompt }).last(); +} + +async function submitImageOnlyMessage(page: Page): Promise { + await attachImageFromMenu(page, IMAGE); + await expectAttachmentPill(page, "composer-image-attachment-pill"); + await page.getByRole("textbox", { name: "Message agent..." }).first().press("Enter"); + const userMessage = page.getByTestId("user-message").last(); + await expect(userMessage).toBeVisible(); + await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible(); + return userMessage; +} + +async function expectPendingSubmission(page: Page, userMessage: Locator): Promise { + await expect(userMessage).toBeVisible(); + await expect(page.getByTestId("turn-working-indicator")).toBeVisible(); + await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toHaveValue(""); + await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0); + await expect(userMessage.getByTestId("user-message-timestamp")).toBeAttached(); + await expect(userMessage.getByTestId("user-message-trailing-row")).toHaveCSS("opacity", "0"); + await expect(userMessage).toHaveAttribute("aria-busy", "true"); + await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible(); +} + +async function readMessageGeometry(page: Page, userMessage: Locator): Promise { + const box = await userMessage.boundingBox(); + if (!box) throw new Error("Submitted user message has no browser geometry"); + const { offsetY } = await readScrollMetrics(page); + return { x: box.x, y: box.y + offsetY, width: box.width, height: box.height }; +} + +async function beginWorkingFooterContinuityCheck(page: Page): Promise<() => Promise> { + await expect(page.getByTestId("turn-working-indicator")).toBeVisible(); + await page.evaluate(() => { + const state = { active: true, sawMissing: false }; + const windowState = window as unknown as Record; + windowState.__messageSubmissionFooterContinuity = state; + const checkFrame = () => { + if (!state.active) return; + if (!document.querySelector('[data-testid="turn-working-indicator"]')) { + state.sawMissing = true; + } + requestAnimationFrame(checkFrame); + }; + requestAnimationFrame(checkFrame); + }); + + return async () => { + const sawMissing = await page.evaluate(() => { + const windowState = window as unknown as Record; + const state = windowState.__messageSubmissionFooterContinuity as + | { active: boolean; sawMissing: boolean } + | undefined; + if (!state) throw new Error("Working-footer continuity check was not started"); + state.active = false; + delete windowState.__messageSubmissionFooterContinuity; + return state.sawMissing; + }); + expect(sawMissing).toBe(false); + }; +} + +async function expectAcceptedSubmission( + page: Page, + userMessage: Locator, + submittedGeometry: MessageGeometry, +): Promise { + await expect(page.getByTestId("turn-working-indicator")).toBeVisible(); + await expect(userMessage).toHaveAttribute("aria-busy", "false", { timeout: 30_000 }); + await expect(page.getByTestId("turn-working-indicator")).toBeVisible(); + expect(await readMessageGeometry(page, userMessage)).toEqual(submittedGeometry); +} + +async function submitMessageThatWillBeRejected(page: Page, prompt: string): Promise { + await attachImageFromMenu(page, IMAGE); + await expectAttachmentPill(page, "composer-image-attachment-pill"); + const composer = page.getByRole("textbox", { name: "Message agent..." }).first(); + await composer.fill(prompt); + await composer.press("Enter"); +} + +async function expectRejectedSubmissionRestored( + page: Page, + input: { prompt: string; errorMessage: string }, +): Promise { + await expect(page.getByText(input.errorMessage)).toBeVisible({ timeout: 30_000 }); + await expectComposerDraft(page, input.prompt); + await expectComposerEditable(page); + await expectAttachmentPill(page, "composer-image-attachment-pill"); + await expect(page.getByRole("button", { name: "Send message" })).toBeEnabled(); + await expect(page.getByTestId("user-message").filter({ hasText: input.prompt })).toHaveCount(0); + await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0); +} + +async function retryRestoredSubmission(page: Page, prompt: string): Promise { + await page.getByRole("textbox", { name: "Message agent..." }).first().press("Enter"); + const userMessage = page.getByTestId("user-message").filter({ hasText: prompt }); + await expect(userMessage).toHaveCount(1); + await expect(userMessage).toHaveAttribute("aria-busy", "false", { timeout: 30_000 }); + await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible(); + await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0); +} + +async function queueMessage(page: Page, prompt: string): Promise { + await fillComposerDraft(page, prompt); + await sendDraftToQueue(page); +} + +async function expectQueuedSendFailuresRestored(page: Page, prompts: string[]): Promise { + await expect(page.getByRole("button", { name: "Send queued message now" })).toHaveCount( + prompts.length, + ); + for (const prompt of prompts) { + await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(0); + } +} + +async function expectFailedSubmissionRestored(page: Page, prompt: string): Promise { + await expectComposerDraft(page, prompt); + await expectComposerEditable(page); + await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(0); +} + +async function expectInterruptedTurnOrderAfterReconnect( + page: Page, + testInfo: { workerIndex: number }, +): Promise { + const gate = await installDaemonWebSocketGate(page); + const agent = await seedMockAgentWorkspace({ + repoPrefix: `submission-reconnect-${testInfo.workerIndex}-`, + title: "Submission reconnect ordering", + model: "ten-second-stream", + }); + const prompt = "Keep this prompt before its response."; + try { + await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); + await expectComposerVisible(page); + await agent.client.sendAgentMessage(agent.agentId, "Start the turn that will be interrupted."); + await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible(); + await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible(); + await queueMessage(page, prompt); + gate.setAgentStreamSuppressed(true); + await page.getByRole("button", { name: "Send queued message now" }).click(); + const promptRow = page.getByTestId("user-message").filter({ hasText: prompt }); + await expect(promptRow).toBeVisible(); + await gate.waitForServerMessage("send_agent_message_response"); + await gate.drop(); + await agent.client.waitForFinish(agent.agentId, 30_000); + gate.setAgentStreamSuppressed(false); + gate.forceNextTimelineEpochReset(); + gate.restoreFresh(); + await gate.waitForServerMessage("fetch_agent_timeline_response", 2); + const response = page.getByText("(end of synthetic stream)", { exact: true }).last(); + await expect(promptRow).toBeVisible(); + await expect(response).toBeVisible(); + await expectRenderedBefore(promptRow, response); + } finally { + gate.restore(); + await agent.cleanup(); + } +} + +async function expectCompletedSubmissionClearsAfterMissedRunningTransition( + page: Page, + testInfo: { workerIndex: number }, +): Promise { + const gate = await installDaemonWebSocketGate(page); + const agent = await seedMockAgentWorkspace({ + repoPrefix: `submission-missed-running-${testInfo.workerIndex}-`, + title: "Submission missed running transition", + model: "ten-second-stream", + }); + try { + await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); + await expectComposerVisible(page); + await expectAgentIdle(page); + gate.holdNextClientRequest("send_agent_message_request"); + const userMessage = await submitImageOnlyMessage(page); + await gate.waitForHeldClientRequest(); + gate.setServerMessageSuppressed("agent_status", true); + gate.setServerMessageSuppressed("agent_update", true); + gate.releaseHeldClientRequest(); + await gate.waitForServerMessage("send_agent_message_response"); + await expect(userMessage).toHaveAttribute("aria-busy", "false"); + await gate.drop(); + await agent.client.waitForFinish(agent.agentId, 30_000); + gate.setServerMessageSuppressed("agent_status", false); + gate.setServerMessageSuppressed("agent_update", false); + gate.restoreFresh(); + await gate.waitForServerMessage("fetch_agent_timeline_response", 2); + await expect(page.getByText("(end of synthetic stream)", { exact: true }).last()).toBeVisible(); + await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0); + await expect(userMessage).toHaveAttribute("aria-busy", "false"); + } finally { + gate.restore(); + await agent.cleanup(); + } +} + +async function expectProviderAcknowledgementBeforeRpcAcceptanceSettlesSubmission( + page: Page, + testInfo: { workerIndex: number }, +): Promise { + const gate = await installDaemonWebSocketGate(page); + const agent = await seedMockAgentWorkspace({ + repoPrefix: `submission-ack-before-rpc-${testInfo.workerIndex}-`, + title: "Submission acknowledgement before RPC", + model: "ten-second-stream", + }); + const prompt = "Settle this provider-acknowledged submission."; + try { + await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); + await expectComposerVisible(page); + await expectAgentIdle(page); + gate.setServerMessageSuppressed("agent_status", true); + gate.setServerMessageSuppressed("agent_update", true); + gate.holdNextServerMessage("send_agent_message_response"); + const userMessage = await submitMessageWithImage(page, prompt); + await gate.waitForHeldServerMessage(); + await gate.waitForAgentStreamItem("user_message"); + gate.releaseHeldServerMessage(); + await gate.drop(); + await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0); + await expect(userMessage).toHaveAttribute("aria-busy", "false"); + } finally { + gate.restore(); + await agent.cleanup(); + } +} + +async function expectLegacyAssistantStartsAfterInterruptedPrompt( + page: Page, + testInfo: { workerIndex: number }, +): Promise { + const gate = await installDaemonWebSocketGate(page); + const agent = await seedMockAgentWorkspace({ + repoPrefix: `submission-legacy-assistant-${testInfo.workerIndex}-`, + title: "Legacy assistant interrupt boundary", + model: "ten-second-stream", + }); + const prompt = "Start the replacement answer after this prompt."; + try { + await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); + await expectComposerVisible(page); + await agent.client.sendAgentMessage(agent.agentId, "Start the interrupted answer."); + await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible(); + await queueMessage(page, prompt); + gate.setAssistantMessageIdsStripped(true); + gate.setAgentStreamEventSuppressed("turn_canceled", true); + await page.getByRole("button", { name: "Send queued message now" }).click(); + const promptRow = page.getByTestId("user-message").filter({ hasText: prompt }); + const replacementAnswer = page.getByText("(end of synthetic stream)", { exact: true }).last(); + await expect(promptRow).toBeVisible(); + await expect(replacementAnswer).toBeVisible({ timeout: 30_000 }); + await expectRenderedBefore(promptRow, replacementAnswer); + } finally { + gate.setAssistantMessageIdsStripped(false); + gate.setAgentStreamEventSuppressed("turn_canceled", false); + await agent.cleanup(); + } +} + +async function expectStaleCanonicalPagePreservesNewerLiveOutput( + page: Page, + testInfo: { workerIndex: number }, +): Promise { + const gate = await installDaemonWebSocketGate(page); + const agent = await seedMockAgentWorkspace({ + repoPrefix: `submission-stale-canonical-${testInfo.workerIndex}-`, + title: "Stale canonical page race", + model: "one-minute-stream", + }); + try { + await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); + await expectComposerVisible(page); + await agent.client.sendAgentMessage(agent.agentId, "End the snapshot at a tool call."); + await awaitToolCall(page, "read"); + await page + .getByRole("button", { name: /stop|cancel/i }) + .first() + .click(); + await expectAgentIdle(page); + + gate.holdNextServerMessage("fetch_agent_timeline_response"); + gate.requestTimelineTail(agent.agentId); + await gate.waitForHeldServerMessage(); + gate.truncateHeldTimelineAfterLast("tool_call"); + expect(gate.getHeldTimelineLastItemType()).toBe("tool_call"); + + const nextPrompt = "Stream after the stale snapshot."; + await agent.client.sendAgentMessage(agent.agentId, nextPrompt); + const nextPromptRow = page.getByTestId("user-message").filter({ hasText: nextPrompt }); + const liveAssistant = nextPromptRow.locator( + 'xpath=following::*[@data-testid="assistant-message"][1]', + ); + await expect(nextPromptRow).toBeVisible(); + await expect(liveAssistant).toContainText("Cycle 1"); + gate.releaseHeldServerMessage(); + await expect(liveAssistant).toContainText("Cycle 1"); + } finally { + await agent.cleanup(); + } +} + +async function expectCanonicalOrderWinsAcrossOverlappingClients( + page: Page, + testInfo: { workerIndex: number }, +): Promise { + const gate = await installDaemonWebSocketGate(page); + const agent = await seedMockAgentWorkspace({ + repoPrefix: `submission-cross-client-order-${testInfo.workerIndex}-`, + title: "Cross-client submission order", + model: "ten-second-stream", + }); + const localPrompt = "Send this after the other client turn."; + const remotePrompt = "Commit this other client turn first."; + try { + await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); + await expectComposerVisible(page); + await expectAgentIdle(page); + gate.holdNextClientRequest("send_agent_message_request"); + const localRow = await submitMessageWithImage(page, localPrompt); + await gate.waitForHeldClientRequest(); + + await agent.client.sendAgentMessage(agent.agentId, remotePrompt); + await agent.client.waitForFinish(agent.agentId, 30_000); + const remoteRow = page.getByTestId("user-message").filter({ hasText: remotePrompt }); + await expect(remoteRow).toBeVisible(); + + const userMessageCount = gate.getAgentStreamItemCount("user_message"); + gate.releaseHeldClientRequest(); + await gate.waitForAgentStreamItem("user_message", userMessageCount + 1); + await expect(localRow).toHaveAttribute("aria-busy", "false"); + await expect(localRow.getByRole("button", { name: "Open image attachment" })).toBeVisible(); + await expect + .poll(async () => { + const localElement = await localRow.elementHandle(); + if (!localElement) return false; + return remoteRow.evaluate( + (remoteElement, localNode) => + Boolean( + remoteElement.compareDocumentPosition(localNode) & Node.DOCUMENT_POSITION_FOLLOWING, + ), + localElement, + ); + }) + .toBe(true); + } finally { + gate.restore(); + await agent.cleanup(); + } +} + +async function expectRenderedBefore(first: Locator, second: Locator): Promise { + const secondElement = await second.elementHandle(); + if (!secondElement) throw new Error("Expected the second timeline item to be rendered"); + expect( + await first.evaluate( + (firstElement, secondNode) => + Boolean( + firstElement.compareDocumentPosition(secondNode) & Node.DOCUMENT_POSITION_FOLLOWING, + ), + secondElement, + ), + ).toBe(true); +} + +async function openWorkspaceDraft(page: Page, workspaceId: string): Promise { + await page.goto(buildHostWorkspaceRoute(getServerId(), workspaceId)); + await waitForWorkspaceTabsVisible(page); + await page.getByTestId("workspace-new-agent-tab-inline").click(); + await expectComposerVisible(page); +} + +async function expectCreatedAgentHandoff( + page: Page, + prompt: string, + userMessage: Locator, +): Promise { + await expect(page.getByTestId("turn-working-indicator")).toBeVisible(); + await expect(page.getByTestId(/^workspace-tab-agent_/).first()).toBeVisible({ timeout: 30_000 }); + await expect(userMessage).toHaveAttribute("aria-busy", "false", { timeout: 30_000 }); + await expect(page.getByTestId("turn-working-indicator")).toBeVisible(); + await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(1); + await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible(); +} + +interface DraftCreatePendingSubmission { + prompt: string; + userMessage: Locator; +} + +async function beginDraftCreateSubmission( + page: Page, + scenario: DraftCreateScenario, +): Promise { + await openWorkspaceDraft(page, scenario.workspaceId); + await selectModel(page, "one-minute-stream"); + const prompt = "Keep this row through create handoff."; + const userMessage = await submitMessageWithImage(page, prompt); + await scenario.agentCreatedDelay.waitForCreateRequest(); + await scenario.agentCreatedDelay.waitForDelayedCreatedStatus(); + await expectPendingSubmission(page, userMessage); + return { prompt, userMessage }; +} + +async function completeDraftCreateSubmission( + page: Page, + scenario: DraftCreateScenario, + pending: DraftCreatePendingSubmission, +): Promise { + scenario.agentCreatedDelay.release(); + await expectCreatedAgentHandoff(page, pending.prompt, pending.userMessage); +} + +test.describe("Agent message submission", () => { + test("keeps the submitted row stable when the host accepts", async ({ + page, + submissionScenario, + }) => { + const userMessage = await submitMessageWithImage(page, "Hold this submission."); + await expectPendingSubmission(page, userMessage); + await submissionScenario.gate.waitForRequest(); + const submittedGeometry = await readMessageGeometry(page, userMessage); + const finishFooterContinuityCheck = await beginWorkingFooterContinuityCheck(page); + submissionScenario.gate.accept(); + await expectAcceptedSubmission(page, userMessage, submittedGeometry); + await finishFooterContinuityCheck(); + }); + + test("keeps the submitted row stable through draft create handoff", async ({ + page, + draftCreateScenario, + }) => { + test.setTimeout(120_000); + const pending = await beginDraftCreateSubmission(page, draftCreateScenario); + await completeDraftCreateSubmission(page, draftCreateScenario, pending); + }); + + test("restores a rejected submission and accepts its retry", async ({ + page, + rejectionScenario, + }) => { + const prompt = "Restore this rejected submission."; + await submitMessageThatWillBeRejected(page, prompt); + await expectRejectedSubmissionRestored(page, { prompt, ...rejectionScenario }); + await retryRestoredSubmission(page, prompt); + }); + + test("restores overlapping queued sends when their connection fails", async ({ + page, + }, testInfo) => { + test.setTimeout(120_000); + const gate = await gateNextAgentMessage(page); + const agent = await startRunningMockAgent(page, { + prefix: `overlapping-queued-send-${testInfo.workerIndex}-`, + model: "one-minute-stream", + prompt: "Keep the agent running while messages queue.", + }); + const prompts = ["Restore the first queued send.", "Restore the second queued send."]; + try { + await queueMessage(page, prompts[0]); + await queueMessage(page, prompts[1]); + await page.getByRole("button", { name: "Send queued message now" }).first().click(); + await gate.waitForRequest(1); + await page.getByRole("button", { name: "Send queued message now" }).first().click(); + await gate.waitForRequest(2); + await gate.disconnect(); + await expectQueuedSendFailuresRestored(page, prompts); + } finally { + await agent.cleanup(); + } + }); + + test("does not accept a failed submission from an unrelated running turn", async ({ + page, + unrelatedRunningScenario, + }) => { + const prompt = "Restore this unsent prompt."; + await submitMessageThatWillBeRejected(page, prompt); + await unrelatedRunningScenario.gate.waitForRequest(); + await unrelatedRunningScenario.agent.client.sendAgentMessage( + unrelatedRunningScenario.agent.agentId, + "Start an unrelated turn.", + ); + await expect( + page.getByTestId("user-message").filter({ hasText: "Start an unrelated turn." }), + ).toBeVisible(); + await unrelatedRunningScenario.gate.disconnect(); + await expectFailedSubmissionRestored(page, prompt); + }); + + test("keeps a submitted prompt before its response when canonical history arrives", async ({ + page, + }, testInfo) => { + test.setTimeout(90_000); + await expectInterruptedTurnOrderAfterReconnect(page, testInfo); + }); + + test("clears an attachment-only submission when canonical history arrives after a missed running transition", async ({ + page, + }, testInfo) => { + test.setTimeout(90_000); + await expectCompletedSubmissionClearsAfterMissedRunningTransition(page, testInfo); + }); + + test("clears a provider acknowledgement that arrives before RPC acceptance", async ({ + page, + }, testInfo) => { + test.setTimeout(90_000); + await expectProviderAcknowledgementBeforeRpcAcceptanceSettlesSubmission(page, testInfo); + }); + + test("keeps an old-daemon replacement answer after its interrupted prompt", async ({ + page, + }, testInfo) => { + test.setTimeout(90_000); + await expectLegacyAssistantStartsAfterInterruptedPrompt(page, testInfo); + }); + + test("preserves newer live output when a stale canonical page arrives", async ({ + page, + }, testInfo) => { + test.setTimeout(90_000); + await expectStaleCanonicalPagePreservesNewerLiveOutput(page, testInfo); + }); + + test("uses canonical order when another client turn overtakes a held submission", async ({ + page, + }, testInfo) => { + await expectCanonicalOrderWinsAcrossOverlappingClients(page, testInfo); + }); +}); diff --git a/packages/app/e2e/helpers/agent-message-gate.ts b/packages/app/e2e/helpers/agent-message-gate.ts new file mode 100644 index 000000000..866d25770 --- /dev/null +++ b/packages/app/e2e/helpers/agent-message-gate.ts @@ -0,0 +1,86 @@ +import type { Page, WebSocketRoute } from "@playwright/test"; +import { daemonWsRoutePattern } from "./daemon-port"; + +type WebSocketMessage = string | Buffer; + +interface SendAgentMessageRequest { + type: "send_agent_message_request"; + requestId: string; + agentId: string; +} + +function readSendRequest(message: WebSocketMessage): SendAgentMessageRequest | null { + if (typeof message !== "string") return null; + try { + const envelope = JSON.parse(message) as { + type?: unknown; + message?: Record; + }; + const request = envelope.type === "session" ? envelope.message : null; + if ( + request?.type !== "send_agent_message_request" || + typeof request.requestId !== "string" || + typeof request.agentId !== "string" + ) { + return null; + } + return { + type: "send_agent_message_request", + requestId: request.requestId, + agentId: request.agentId, + }; + } catch { + return null; + } +} + +export async function gateNextAgentMessage(page: Page) { + let serverSocket: WebSocketRoute | null = null; + let browserSocket: WebSocketRoute | null = null; + const heldMessages: Array = []; + const requests: SendAgentMessageRequest[] = []; + const requestWaiters = new Set<() => void>(); + + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + browserSocket = ws; + const server = ws.connectToServer(); + serverSocket = server; + + ws.onMessage((message) => { + const request = readSendRequest(message); + if (request) { + heldMessages.push(message); + requests.push(request); + for (const resolve of requestWaiters) resolve(); + requestWaiters.clear(); + return; + } + server.send(message); + }); + + server.onMessage((message) => ws.send(message)); + }); + + const waitForRequest = async (count = 1): Promise => { + while (requests.length < count) { + await new Promise((resolve) => requestWaiters.add(resolve)); + } + return requests[count - 1]; + }; + + return { + waitForRequest, + accept(index = 0) { + const heldMessage = heldMessages[index]; + if (!serverSocket || !heldMessage) { + throw new Error("No held send-agent-message request to accept"); + } + serverSocket.send(heldMessage); + heldMessages[index] = null; + }, + async disconnect(): Promise { + if (!browserSocket) throw new Error("No browser daemon socket to disconnect"); + await browserSocket.close({ code: 1008, reason: "Dropped by submission test." }); + }, + }; +} diff --git a/packages/app/e2e/helpers/daemon-websocket-gate.ts b/packages/app/e2e/helpers/daemon-websocket-gate.ts index a6c7f3867..c1e57f7ad 100644 --- a/packages/app/e2e/helpers/daemon-websocket-gate.ts +++ b/packages/app/e2e/helpers/daemon-websocket-gate.ts @@ -16,6 +16,20 @@ interface ClientRequest { type?: unknown; subscribe?: unknown; page?: { cursor?: unknown }; + payload?: unknown; +} + +function readSessionMessage(message: string | Buffer): ClientRequest | null { + if (typeof message !== "string") return null; + try { + const envelope = JSON.parse(message) as { + type?: unknown; + message?: ClientRequest; + }; + return envelope.message ?? envelope; + } catch { + return null; + } } function readClientRequest(message: string | Buffer): ClientRequest | null { @@ -38,15 +52,111 @@ function directoryForRequest(request: ClientRequest): keyof DirectoryBootstrapCo return null; } +function stripAssistantMessageId( + message: string | Buffer, + enabled: boolean, + messageType: unknown, +): string | Buffer { + if (!enabled || messageType !== "agent_stream" || typeof message !== "string") return message; + const envelope = JSON.parse(message) as { + message?: { payload?: { event?: { type?: unknown; item?: Record } } }; + payload?: { event?: { type?: unknown; item?: Record } }; + }; + const event = (envelope.message?.payload ?? envelope.payload)?.event; + if (event?.type !== "timeline" || event.item?.type !== "assistant_message") return message; + delete event.item.messageId; + return JSON.stringify(envelope); +} + +function stripMessageSubmissionDisposition( + message: string | Buffer, + enabled: boolean, + messageType: unknown, +): string | Buffer { + if (!enabled || messageType !== "send_agent_message_response" || typeof message !== "string") { + return message; + } + const envelope = JSON.parse(message) as { + message?: { payload?: Record }; + payload?: Record; + }; + const payload = envelope.message?.payload ?? envelope.payload; + if (!payload) return message; + delete payload.outOfBand; + return JSON.stringify(envelope); +} + +function forceTimelineReset(message: string | Buffer, enabled: boolean): string | Buffer { + if (!enabled || typeof message !== "string") return message; + const envelope = JSON.parse(message) as { + message?: { payload?: Record }; + payload?: Record; + }; + const payload = envelope.message?.payload ?? envelope.payload; + if (!payload) return message; + payload.epoch = `playwright-reset-${Date.now()}`; + payload.reset = true; + return JSON.stringify(envelope); +} + +function readAgentStreamEventType(message: ClientRequest | null): string | null { + if (message?.type !== "agent_stream" || !message.payload || typeof message.payload !== "object") { + return null; + } + const event = (message.payload as { event?: { type?: unknown } }).event; + return typeof event?.type === "string" ? event.type : null; +} + +function readAgentStreamItemType(message: ClientRequest | null): string | null { + if (message?.type !== "agent_stream" || !message.payload || typeof message.payload !== "object") { + return null; + } + const event = (message.payload as { event?: { type?: unknown; item?: { type?: unknown } } }) + .event; + return event?.type === "timeline" && typeof event.item?.type === "string" + ? event.item.type + : null; +} + +function shouldSuppressServerMessage(input: { + message: ClientRequest | null; + messageTypes: ReadonlySet; + agentStreamEventTypes: ReadonlySet; + suppressAgentStream: boolean; +}): boolean { + const messageType = typeof input.message?.type === "string" ? input.message.type : null; + if (messageType && input.messageTypes.has(messageType)) return true; + if (input.suppressAgentStream && messageType === "agent_stream") return true; + const eventType = readAgentStreamEventType(input.message); + return Boolean(eventType && input.agentStreamEventTypes.has(eventType)); +} + export async function installDaemonWebSocketGate(page: Page) { let acceptingConnections = true; + let reconnectWithFreshClient = false; + let suppressAgentStream = false; + let forceTimelineEpochReset = false; + let stripAssistantMessageIds = false; + let stripSubmissionDisposition = false; + 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 heldServerMessage: { browser: WebSocketRoute; message: string | Buffer } | null = null; + let resolveHeldServerMessage: (() => void) | null = null; + const suppressedServerMessageTypes = new Set(); + const suppressedAgentStreamEventTypes = new Set(); const activeSockets = new Set(); + let latestServer: WebSocketRoute | null = null; const directoryStarts: DirectoryRequestStartCounts = { subscribed: { agents: 0, workspaces: 0 }, unsubscribed: { agents: 0, workspaces: 0 }, total: { agents: 0, workspaces: 0 }, }; const clientRequestCounts = new Map(); + const serverMessageCounts = new Map(); + const agentStreamItemCounts = new Map(); + const serverMessageWaiters = new Set<() => void>(); await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { if (!acceptingConnections) { @@ -56,9 +166,20 @@ export async function installDaemonWebSocketGate(page: Page) { activeSockets.add(ws); const server = ws.connectToServer(); + latestServer = server; ws.onMessage((message) => { if (!acceptingConnections) return; + if (reconnectWithFreshClient && typeof message === "string") { + const hello = readClientRequest(message); + if (hello?.type === "hello") { + const parsed = JSON.parse(message) as { clientId?: string }; + parsed.clientId = `${parsed.clientId ?? "playwright"}-fresh-${Date.now()}`; + reconnectWithFreshClient = false; + server.send(JSON.stringify(parsed)); + return; + } + } const request = readClientRequest(message); if (typeof request?.type === "string") { clientRequestCounts.set(request.type, (clientRequestCounts.get(request.type) ?? 0) + 1); @@ -69,6 +190,12 @@ export async function installDaemonWebSocketGate(page: Page) { directoryStarts.total[directory] += 1; } } + if (request?.type === heldClientRequestType) { + heldClientRequest = { server, message }; + resolveHeldClientRequest?.(); + resolveHeldClientRequest = null; + return; + } try { server.send(message); } catch { @@ -78,8 +205,55 @@ export async function installDaemonWebSocketGate(page: Page) { server.onMessage((message) => { if (!acceptingConnections) return; + const serverMessage = readSessionMessage(message); + let outboundMessage = stripAssistantMessageId( + message, + stripAssistantMessageIds, + serverMessage?.type, + ); + outboundMessage = stripMessageSubmissionDisposition( + outboundMessage, + stripSubmissionDisposition, + serverMessage?.type, + ); + const shouldForceTimelineReset = + forceTimelineEpochReset && serverMessage?.type === "fetch_agent_timeline_response"; + outboundMessage = forceTimelineReset(outboundMessage, shouldForceTimelineReset); + if (shouldForceTimelineReset) forceTimelineEpochReset = false; + if (typeof serverMessage?.type === "string") { + serverMessageCounts.set( + serverMessage.type, + (serverMessageCounts.get(serverMessage.type) ?? 0) + 1, + ); + for (const resolve of serverMessageWaiters) resolve(); + serverMessageWaiters.clear(); + } + const agentStreamItemType = readAgentStreamItemType(serverMessage); + if (agentStreamItemType) { + agentStreamItemCounts.set( + agentStreamItemType, + (agentStreamItemCounts.get(agentStreamItemType) ?? 0) + 1, + ); + for (const resolve of serverMessageWaiters) resolve(); + serverMessageWaiters.clear(); + } + if (serverMessage?.type === heldServerMessageType) { + heldServerMessage = { browser: ws, message: outboundMessage }; + resolveHeldServerMessage?.(); + resolveHeldServerMessage = null; + return; + } + if ( + shouldSuppressServerMessage({ + message: serverMessage, + messageTypes: suppressedServerMessageTypes, + agentStreamEventTypes: suppressedAgentStreamEventTypes, + suppressAgentStream, + }) + ) + return; try { - ws.send(message); + ws.send(outboundMessage); } catch { activeSockets.delete(ws); } @@ -100,6 +274,125 @@ export async function installDaemonWebSocketGate(page: Page) { restore(): void { acceptingConnections = true; }, + restoreFresh(): void { + reconnectWithFreshClient = true; + acceptingConnections = true; + }, + holdNextClientRequest(type: string): void { + heldClientRequestType = type; + heldClientRequest = null; + }, + waitForHeldClientRequest(): Promise { + if (heldClientRequest) return Promise.resolve(); + return new Promise((resolve) => { + resolveHeldClientRequest = resolve; + }); + }, + releaseHeldClientRequest(): void { + if (!heldClientRequest) throw new Error("No held client request to release"); + heldClientRequest.server.send(heldClientRequest.message); + heldClientRequest = null; + heldClientRequestType = null; + }, + holdNextServerMessage(type: string): void { + heldServerMessageType = type; + heldServerMessage = null; + }, + waitForHeldServerMessage(): Promise { + if (heldServerMessage) return Promise.resolve(); + return new Promise((resolve) => { + resolveHeldServerMessage = resolve; + }); + }, + releaseHeldServerMessage(): void { + if (!heldServerMessage) throw new Error("No held server message to release"); + heldServerMessage.browser.send(heldServerMessage.message); + heldServerMessage = null; + heldServerMessageType = null; + }, + requestTimelineTail(agentId: string): void { + if (!latestServer) throw new Error("No daemon WebSocket is connected"); + latestServer.send( + JSON.stringify({ + type: "session", + message: { + type: "fetch_agent_timeline_request", + agentId, + requestId: `playwright-timeline-${Date.now()}`, + direction: "tail", + limit: 0, + projection: "projected", + }, + }), + ); + }, + getHeldTimelineLastItemType(): string | null { + if (!heldServerMessage) throw new Error("No held server message to inspect"); + const response = readSessionMessage(heldServerMessage.message); + const payload = response?.payload; + if (!payload || typeof payload !== "object") return null; + const entries = (payload as { entries?: unknown }).entries; + if (!Array.isArray(entries)) return null; + const last = entries.at(-1) as { item?: { type?: unknown } } | undefined; + return typeof last?.item?.type === "string" ? last.item.type : null; + }, + truncateHeldTimelineAfterLast(itemType: string): void { + if (!heldServerMessage || typeof heldServerMessage.message !== "string") { + throw new Error("No held text server message to truncate"); + } + const envelope = JSON.parse(heldServerMessage.message) as { + message?: { payload?: Record }; + payload?: Record; + }; + const payload = envelope.message?.payload ?? envelope.payload; + if (!payload) throw new Error("Held message has no payload"); + const entries = payload.entries; + if (!Array.isArray(entries)) throw new Error("Held message is not a timeline response"); + const index = entries.findLastIndex( + (entry) => + typeof entry === "object" && + entry !== null && + (entry as { item?: { type?: unknown } }).item?.type === itemType, + ); + if (index < 0) throw new Error(`Timeline response has no ${itemType} item`); + const retained = entries.slice(0, index + 1) as Array<{ seqEnd?: unknown }>; + const lastSeq = retained.at(-1)?.seqEnd; + if (typeof lastSeq !== "number") throw new Error("Timeline entry has no sequence end"); + payload.entries = retained; + payload.endCursor = { epoch: payload.epoch, seq: lastSeq }; + payload.hasNewer = false; + if (payload.window && typeof payload.window === "object") { + (payload.window as Record).maxSeq = lastSeq; + (payload.window as Record).nextSeq = lastSeq + 1; + } + heldServerMessage.message = JSON.stringify(envelope); + }, + setServerMessageSuppressed(type: string, suppressed: boolean): void { + if (suppressed) { + suppressedServerMessageTypes.add(type); + } else { + suppressedServerMessageTypes.delete(type); + } + }, + setAgentStreamEventSuppressed(type: string, suppressed: boolean): void { + if (suppressed) { + suppressedAgentStreamEventTypes.add(type); + } else { + suppressedAgentStreamEventTypes.delete(type); + } + }, + setAssistantMessageIdsStripped(stripped: boolean): void { + stripAssistantMessageIds = stripped; + }, + setMessageSubmissionDispositionStripped(stripped: boolean): void { + stripSubmissionDisposition = stripped; + }, + setAgentStreamSuppressed(suppressed: boolean): void { + suppressAgentStream = suppressed; + }, + forceNextTimelineEpochReset(): void { + forceTimelineEpochReset = true; + }, getDirectoryRequestStartCounts(): DirectoryRequestStartCounts { return { subscribed: { ...directoryStarts.subscribed }, @@ -110,5 +403,18 @@ export async function installDaemonWebSocketGate(page: Page) { getClientRequestCount(type: string): number { return clientRequestCounts.get(type) ?? 0; }, + getAgentStreamItemCount(type: string): number { + return agentStreamItemCounts.get(type) ?? 0; + }, + async waitForServerMessage(type: string, count = 1): Promise { + while ((serverMessageCounts.get(type) ?? 0) < count) { + await new Promise((resolve) => serverMessageWaiters.add(resolve)); + } + }, + async waitForAgentStreamItem(type: string, count = 1): Promise { + while ((agentStreamItemCounts.get(type) ?? 0) < count) { + await new Promise((resolve) => serverMessageWaiters.add(resolve)); + } + }, }; } diff --git a/packages/app/e2e/out-of-band-command.codex.real.spec.ts b/packages/app/e2e/out-of-band-command.codex.real.spec.ts new file mode 100644 index 000000000..2df34398d --- /dev/null +++ b/packages/app/e2e/out-of-band-command.codex.real.spec.ts @@ -0,0 +1,52 @@ +import { mkdtempSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "./fixtures"; +import { submitMessage } from "./helpers/composer"; +import { cleanupRewindFlow, launchAgent, type AgentHandle } from "./helpers/rewind-flow"; +import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; + +test.describe("Codex out-of-band commands", () => { + test.setTimeout(300_000); + + test("settles the submitted row when a goal command completes without a turn", async ({ + page, + }) => { + const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-codex-command-"))); + let handle: AgentHandle | undefined; + + try { + handle = await launchAgent({ page, provider: "codex", cwd, mode: "full-access" }); + await submitMessage(page, "/goal clear"); + const command = page.getByTestId("user-message").filter({ hasText: "/goal clear" }); + + await expect(command).toBeVisible(); + await expect(command).toHaveAttribute("aria-busy", "false", { timeout: 30_000 }); + await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0); + } finally { + await cleanupRewindFlow({ handle, cwd }); + } + }); + + test("settles the submitted row when an older daemon omits submission disposition", async ({ + page, + }) => { + const gate = await installDaemonWebSocketGate(page); + gate.setMessageSubmissionDispositionStripped(true); + const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-codex-command-compat-"))); + let handle: AgentHandle | undefined; + + try { + handle = await launchAgent({ page, provider: "codex", cwd, mode: "full-access" }); + await submitMessage(page, "/goal clear"); + const command = page.getByTestId("user-message").filter({ hasText: "/goal clear" }); + + await expect(command).toBeVisible(); + await expect(command).toHaveAttribute("aria-busy", "false", { timeout: 30_000 }); + await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0); + } finally { + gate.restore(); + await cleanupRewindFlow({ handle, cwd }); + } + }); +}); diff --git a/packages/app/e2e/rewind-menu.ui-contract.spec.ts b/packages/app/e2e/rewind-menu.ui-contract.spec.ts index 158f40ae1..7c12f3f5b 100644 --- a/packages/app/e2e/rewind-menu.ui-contract.spec.ts +++ b/packages/app/e2e/rewind-menu.ui-contract.spec.ts @@ -1,6 +1,7 @@ import type { Locator } from "@playwright/test"; import { expect, test, type Page } from "./fixtures"; import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; +import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; import { composerLocator, expectComposerDraft, @@ -23,7 +24,152 @@ async function expectUserMessageVisible(page: Page, text: string): Promise await expect(userMessage(page, text)).toBeVisible(); } +async function rewriteCachedMessageAsLegacyRow(page: Page, prompt: string): Promise { + await expect + .poll(() => + page.evaluate((messageText) => { + const raw = localStorage.getItem("@paseo:replica-cache"); + if (!raw) return false; + const cache = JSON.parse(raw) as { + hosts?: Array<{ timeline?: { items?: Array> } | null }>; + }; + for (const host of cache.hosts ?? []) { + for (const item of host.timeline?.items ?? []) { + if (item.kind === "user_message" && item.text === messageText && item.messageId) { + return true; + } + } + } + return false; + }, prompt), + ) + .toBe(true); + + await page.evaluate((messageText) => { + const key = "@paseo:replica-cache"; + const raw = localStorage.getItem(key); + if (!raw) throw new Error("Replica cache was not persisted"); + const cache = JSON.parse(raw) as { + hosts?: Array<{ timeline?: { items?: Array> } | null }>; + }; + const cachedMessage = cache.hosts + ?.flatMap((host) => host.timeline?.items ?? []) + .find((item) => item.kind === "user_message" && item.text === messageText); + if (!cachedMessage) throw new Error("Cached user message was not found"); + delete cachedMessage.messageId; + localStorage.setItem(key, JSON.stringify(cache)); + }, prompt); +} + +async function waitForCurrentSubmissionExcludedFromCache( + page: Page, + prompt: string, +): Promise { + await expect + .poll(() => + page.evaluate((messageText) => { + const raw = localStorage.getItem("@paseo:replica-cache"); + if (!raw) return false; + const cache = JSON.parse(raw) as { + hosts?: Array<{ timeline?: { items?: Array> } | null }>; + }; + return !cache.hosts + ?.flatMap((host) => host.timeline?.items ?? []) + .some( + (item) => + item.kind === "user_message" && + item.text === messageText && + typeof item.clientMessageId === "string" && + item.messageId === undefined, + ); + }, prompt), + ) + .toBe(true); +} + +async function waitForCachedMessageWithoutProviderId(page: Page, prompt: string): Promise { + await expect + .poll(() => + page.evaluate((messageText) => { + const raw = localStorage.getItem("@paseo:replica-cache"); + if (!raw) return false; + const cache = JSON.parse(raw) as { + hosts?: Array<{ timeline?: { items?: Array> } | null }>; + }; + return cache.hosts + ?.flatMap((host) => host.timeline?.items ?? []) + .some( + (item) => + item.kind === "user_message" && + item.text === messageText && + item.messageId === undefined, + ); + }, prompt), + ) + .toBe(true); +} + +async function expectPendingSubmissionNotRestoredAfterReload(page: Page): Promise { + const prompt = "Keep this cached submission pending."; + const gate = await installDaemonWebSocketGate(page); + const session = await seedMockAgentWorkspace({ + repoPrefix: "rewind-current-cache-e2e-", + title: "Current cache submission e2e", + }); + + try { + await openAgentRoute(page, session); + await expectComposerVisible(page); + gate.holdNextClientRequest("send_agent_message_request"); + await submitMessage(page, prompt); + await gate.waitForHeldClientRequest(); + await waitForCurrentSubmissionExcludedFromCache(page, prompt); + await gate.drop(); + await page.reload(); + + await expect(userMessage(page, prompt)).toHaveCount(0); + } finally { + gate.restore(); + await session.cleanup(); + } +} + test.describe("Rewind sheet", () => { + test("does not restore a local-only submission from the display cache", async ({ page }) => { + await expectPendingSubmissionNotRestoredAfterReload(page); + }); + + test("does not invent rewind identity for an ID-less cached message", async ({ page }) => { + const prompt = "Restore this rewind identity from the legacy cache."; + const gate = await installDaemonWebSocketGate(page); + const session = await seedMockAgentWorkspace({ + repoPrefix: "rewind-cache-upgrade-e2e-", + title: "Rewind cache upgrade e2e", + initialPrompt: prompt, + }); + let heldTimelineRequest = false; + + try { + await openAgentRoute(page, session); + await expectUserMessageVisible(page, prompt); + await rewriteCachedMessageAsLegacyRow(page, prompt); + gate.holdNextClientRequest("fetch_agent_timeline_request"); + await page.reload(); + await gate.waitForHeldClientRequest(); + heldTimelineRequest = true; + + const restoredMessage = userMessage(page, prompt); + await expect(restoredMessage).toBeVisible(); + await restoredMessage.hover(); + await expect(restoredMessage.getByTestId("rewind-menu-trigger")).toHaveCount(0); + await waitForCachedMessageWithoutProviderId(page, prompt); + } finally { + if (heldTimelineRequest) gate.releaseHeldClientRequest(); + gate.restore(); + await session.cleanup(); + } + }); + test("rewinds from a user message sheet option", async ({ page }) => { const firstPrompt = "emit 1 coalesced agent stream updates for first rewind turn."; const secondPrompt = "Prepare deleted rewind turn assistant content."; diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 5af665d40..0bc079e78 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -41,6 +41,7 @@ import { } from "@/components/message"; import { PlanCard } from "@/components/plan-card"; import type { StreamItem } from "@/types/stream"; +import type { PendingMessageSubmission } from "@/composer/submission/model"; import type { PendingPermission } from "@/types/shared"; import type { AgentCapabilityFlags, @@ -239,6 +240,7 @@ export interface AgentStreamViewProps { streamItems: StreamItem[]; streamHead?: StreamItem[]; pendingPermissions: Map; + pendingMessageSubmissions?: readonly PendingMessageSubmission[]; routeBottomAnchorRequest?: BottomAnchorRouteRequest | null; isAuthoritativeHistoryReady?: boolean; toast?: ToastApi | null; @@ -265,6 +267,7 @@ const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [ ]; const EMPTY_STREAM_HEAD: StreamItem[] = []; +const EMPTY_PENDING_MESSAGE_SUBMISSIONS: readonly PendingMessageSubmission[] = []; const GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT = 200; function buildChatHistoryAttachment(input: { @@ -327,6 +330,7 @@ const AgentStreamViewComponent = forwardRef settings.autoExpandReasoning); const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel); const viewportRef = useRef(null); + const pendingClientMessageIds = useMemo( + () => new Set(pendingMessageSubmissions.map((submission) => submission.clientMessageId)), + [pendingMessageSubmissions], + ); const isMobile = useIsCompactFormFactor(); const streamRenderStrategy = useMemo( () => @@ -656,7 +664,7 @@ const AgentStreamViewComponent = forwardRef ); }, - [context.capabilities, agentId, client, resolvedServerId], + [context.capabilities, agentId, client, pendingClientMessageIds, resolvedServerId], ); const renderAssistantMessageItem = useCallback( @@ -878,7 +890,8 @@ const AgentStreamViewComponent = forwardRef 0; const pendingPermissionsNode = useMemo( () => renderPendingPermissionsNode({ @@ -1157,6 +1170,9 @@ function agentStreamViewPropsEqual( if (left.streamItems !== right.streamItems) reasons.push("streamItems"); if (left.streamHead !== right.streamHead) reasons.push("streamHead"); if (left.pendingPermissions !== right.pendingPermissions) reasons.push("pendingPermissions"); + if (left.pendingMessageSubmissions !== right.pendingMessageSubmissions) { + reasons.push("pendingMessageSubmissions"); + } if ( !bottomAnchorRouteRequestsEqual(left.routeBottomAnchorRequest, right.routeBottomAnchorRequest) ) { diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 8abe67928..e1870bd72 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -132,6 +132,7 @@ interface UserMessageProps { client?: DaemonClient | null; isFirstInGroup?: boolean; isLastInGroup?: boolean; + isPending?: boolean; disableOuterSpacing?: boolean; } @@ -430,6 +431,7 @@ export const UserMessage = memo(function UserMessage({ client, isFirstInGroup = true, isLastInGroup = true, + isPending = false, disableOuterSpacing, }: UserMessageProps) { const isCompact = useIsCompactFormFactor(); @@ -441,7 +443,7 @@ export const UserMessage = memo(function UserMessage({ const hasText = message.trim().length > 0; const hasImages = images.length > 0; const hasAttachments = attachments.length > 0; - const showTrailingRow = hasText && (isCompact || isNative || isHovered); + const showTrailingRow = !isPending && hasText && (isCompact || isNative || isHovered); const formattedTimestamp = useMemo( () => formatMessageTimestamp(new Date(timestamp)), [timestamp], @@ -494,7 +496,7 @@ export const UserMessage = memo(function UserMessage({ ); return ( - + {hasText ? ( - - {formattedTimestamp} - {capabilities ? ( + + + {formattedTimestamp} + + {capabilities && messageId ? ( void } = {}, ): ComposerSendClient & { calls: FakeSendCall[] } { const calls: FakeSendCall[] = []; return { calls, sendAgentMessage: async (agentId, text, opts) => { - calls.push({ agentId, text, options: opts }); + const call = { agentId, text, options: opts }; + calls.push(call); if (options.rejection) { + options.beforeRejection?.(call); throw options.rejection; } }, @@ -183,7 +195,7 @@ function createFakeSendClient( }; } -interface FakeStream extends AgentStreamWriter { +interface FakeStream extends MessageSubmissionWriter { head: Map; tail: Map; } @@ -192,18 +204,70 @@ function createFakeStream(initialHead: Map = new Map()): F const fake: FakeStream = { head: new Map(initialHead), tail: new Map(), - getTail: (agentId) => fake.tail.get(agentId), - getHead: (agentId) => fake.head.get(agentId), - setHead: (updater) => { - fake.head = updater(fake.head); + begin: (agentId, message) => { + const current = readSubmission(fake, agentId); + const stream = appendSubmittedUserMessage({ + tail: current.tail, + head: current.head, + message, + }); + writeSubmission(fake, agentId, { + ...stream, + submissions: beginMessageSubmission(current.submissions, { + clientMessageId: message.clientMessageId!, + submittedAt: message.timestamp, + }), + }); }, - setTail: (updater) => { - fake.tail = updater(fake.tail); + accept: (agentId, clientMessageId) => { + const current = readSubmission(fake, agentId); + writeSubmission(fake, agentId, { + ...current, + submissions: acceptMessageSubmission(current.submissions, clientMessageId, true, false), + }); + }, + reject: (agentId, clientMessageId) => { + const current = readSubmission(fake, agentId); + const result = rejectMessageSubmission(current.submissions, clientMessageId); + const stream = + result.outcome === "rejected" + ? removeSubmittedUserMessage({ + tail: current.tail, + head: current.head, + clientMessageId, + }) + : current; + writeSubmission(fake, agentId, { ...stream, submissions: result.submissions }); + return result.outcome; }, }; return fake; } +const submissionsByFakeStream = new WeakMap>(); + +interface FakeSubmissionState { + tail: StreamItem[]; + head: StreamItem[]; + submissions: MessageSubmissionRecord[]; +} + +function readSubmission(fake: FakeStream, agentId: string): FakeSubmissionState { + return { + tail: fake.tail.get(agentId) ?? [], + head: fake.head.get(agentId) ?? [], + submissions: submissionsByFakeStream.get(fake)?.get(agentId) ?? [], + }; +} + +function writeSubmission(fake: FakeStream, agentId: string, state: FakeSubmissionState): void { + fake.tail = new Map(fake.tail).set(agentId, state.tail); + fake.head = new Map(fake.head).set(agentId, state.head); + const submissions = submissionsByFakeStream.get(fake) ?? new Map(); + submissions.set(agentId, state.submissions); + submissionsByFakeStream.set(fake, submissions); +} + function createFakeQueue( initial: Map = new Map(), ): QueueWriter & { state: Map } { @@ -337,7 +401,7 @@ describe("pickAndPersistImages", () => { }); describe("dispatchComposerAgentMessage", () => { - it("removes the optimistic prompt when the host rejects it", async () => { + it("removes the submitted prompt when the host rejects it", async () => { const rejection = new Error("Host rejected prompt"); const client = createFakeSendClient({ rejection }); const stream = createFakeStream(); @@ -349,14 +413,54 @@ describe("dispatchComposerAgentMessage", () => { text: "rejected prompt", attachments: [], encodeImages: passthroughEncodeImages, - stream, + submission: stream, }), ).rejects.toBe(rejection); - expect(stream.head.get("agent")).toBeUndefined(); + expect(stream.head.get("agent")).toEqual([]); expect(stream.tail.get("agent") ?? []).toEqual([]); }); + it("rolls back an already-running force send when its RPC fails", async () => { + const stream = createFakeStream(); + const transportError = new Error("Force send failed while the prior turn was running"); + const client = createFakeSendClient({ rejection: transportError }); + + await expect( + dispatchComposerAgentMessage({ + client, + agentId: "agent", + text: "force send", + attachments: [], + encodeImages: passthroughEncodeImages, + submission: stream, + }), + ).rejects.toBe(transportError); + + expect(stream.tail.get("agent") ?? []).toEqual([]); + }); + + it("does not swallow a transport error when submission state is missing", async () => { + const transportError = new Error("Connection lost with unknown submission state"); + const client = createFakeSendClient({ rejection: transportError }); + const submission: MessageSubmissionWriter = { + begin: () => {}, + accept: () => {}, + reject: () => "unknown", + }; + + await expect( + dispatchComposerAgentMessage({ + client, + agentId: "agent", + text: "unknown state", + attachments: [], + encodeImages: passthroughEncodeImages, + submission, + }), + ).rejects.toBe(transportError); + }); + it("sends text + image data + structured attachments and appends user_message to the tail when head is empty", async () => { const client = createFakeSendClient(); const stream = createFakeStream(); @@ -371,7 +475,7 @@ describe("dispatchComposerAgentMessage", () => { { kind: "github_pr", item: prItem }, ], encodeImages: passthroughEncodeImages, - stream, + submission: stream, }); expect(client.calls).toHaveLength(1); @@ -393,7 +497,7 @@ describe("dispatchComposerAgentMessage", () => { }, ]); - expect(stream.head.get("agent")).toBeUndefined(); + expect(stream.head.get("agent")).toEqual([]); const tail = stream.tail.get("agent"); expect(tail).toHaveLength(1); const userMessage = tail?.[0] as Extract; @@ -402,7 +506,8 @@ describe("dispatchComposerAgentMessage", () => { expect(userMessage.images).toEqual([image]); expect(userMessage.attachments).toEqual(call.options.attachments); expect(userMessage.id).toBe(call.options.messageId); - expect(userMessage.optimistic).toBe(true); + expect(userMessage.clientMessageId).toBe(call.options.messageId); + expect(userMessage.messageId).toBeUndefined(); }); it("can send legacy GitHub attachment payloads for old daemons", async () => { @@ -416,7 +521,7 @@ describe("dispatchComposerAgentMessage", () => { attachments: [{ kind: "forge_change_request", item: prItem }], attachmentSubmitFormat: "legacy-github", encodeImages: passthroughEncodeImages, - stream, + submission: stream, }); expect(client.calls[0].options.attachments).toEqual([ @@ -449,11 +554,11 @@ describe("dispatchComposerAgentMessage", () => { text: "next message", attachments: [], encodeImages: passthroughEncodeImages, - stream, + submission: stream, }); expect(stream.head.get("agent")).toHaveLength(2); - expect(stream.tail.get("agent")).toBeUndefined(); + expect(stream.tail.get("agent")).toEqual([]); }); it("submits empty wire arrays when no attachments are provided", async () => { @@ -466,7 +571,7 @@ describe("dispatchComposerAgentMessage", () => { text: "plain message", attachments: [], encodeImages: passthroughEncodeImages, - stream, + submission: stream, }); expect(client.calls[0]?.options).toMatchObject({ @@ -486,7 +591,7 @@ describe("dispatchComposerAgentMessage", () => { text: "review this", attachments: [review], encodeImages: passthroughEncodeImages, - stream, + submission: stream, }); expect(client.calls[0]?.options.attachments).toEqual([review.attachment]); @@ -504,7 +609,7 @@ describe("dispatchComposerAgentMessage", () => { text: "inspect element", attachments: [browserElement], encodeImages: passthroughEncodeImages, - stream, + submission: stream, }); expect(client.calls[0]?.options.attachments).toEqual([ diff --git a/packages/app/src/composer/actions.ts b/packages/app/src/composer/actions.ts index 44fc31747..08cd0d03e 100644 --- a/packages/app/src/composer/actions.ts +++ b/packages/app/src/composer/actions.ts @@ -12,13 +12,8 @@ import { splitComposerAttachmentsForSubmit, type ComposerAttachmentSubmitFormat, } from "@/composer/attachments/submit"; -import { - appendOptimisticUserMessageToStream, - buildOptimisticUserMessage, - generateMessageId, - type StreamItem, - type UserMessageItem, -} from "@/types/stream"; +import { createUserMessage, generateMessageId, type UserMessageItem } from "@/types/stream"; +import type { MessageSubmissionRejectionOutcome } from "@/composer/submission/model"; import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker"; import { i18n } from "@/i18n/i18next"; @@ -51,7 +46,7 @@ export interface ComposerSendClient { images: Array<{ data: string; mimeType: string }>; attachments: ReturnType["attachments"]; }, - ) => Promise; + ) => Promise; uploadFile: (input: { fileName: string; mimeType: string; bytes: Uint8Array }) => Promise<{ requestId: string; file: { @@ -70,11 +65,10 @@ export interface ComposerCancelClient { cancelAgent: (agentId: string) => Promise | void; } -export interface AgentStreamWriter { - getTail: (agentId: string) => StreamItem[] | undefined; - getHead: (agentId: string) => StreamItem[] | undefined; - setHead: (updater: (prev: Map) => Map) => void; - setTail: (updater: (prev: Map) => Map) => void; +export interface MessageSubmissionWriter { + begin: (agentId: string, message: UserMessageItem) => void; + accept: (agentId: string, clientMessageId: string, outOfBand: boolean | undefined) => void; + reject: (agentId: string, clientMessageId: string) => MessageSubmissionRejectionOutcome; } export interface QueueWriter { @@ -169,7 +163,7 @@ export interface DispatchComposerAgentMessageInput { encodeImages: ( images: AttachmentMetadata[], ) => Promise | undefined>; - stream: AgentStreamWriter; + submission: MessageSubmissionWriter; } export async function dispatchComposerAgentMessage( @@ -178,60 +172,30 @@ export async function dispatchComposerAgentMessage( const wirePayload = splitComposerAttachmentsForSubmit(input.attachments, { format: input.attachmentSubmitFormat, }); - const messageId = generateMessageId(); - const userMessage = buildOptimisticUserMessage({ - id: messageId, + const clientMessageId = generateMessageId(); + const userMessage = createUserMessage({ + clientMessageId, text: input.text, timestamp: new Date(), images: wirePayload.images, attachments: wirePayload.attachments, }); - const rollbackOptimisticMessage = appendUserMessageToStream( - input.agentId, - userMessage, - input.stream, - ); + input.submission.begin(input.agentId, userMessage); try { const imagesData = await input.encodeImages(wirePayload.images); - await input.client.sendAgentMessage(input.agentId, input.text, { - messageId, + const result = await input.client.sendAgentMessage(input.agentId, input.text, { + messageId: clientMessageId, images: imagesData ?? [], attachments: wirePayload.attachments, }); + input.submission.accept(input.agentId, clientMessageId, result?.outOfBand); } catch (error) { - rollbackOptimisticMessage(); + const outcome = input.submission.reject(input.agentId, clientMessageId); + if (outcome === "accepted") return; throw error; } } -function appendUserMessageToStream( - agentId: string, - userMessage: UserMessageItem, - stream: AgentStreamWriter, -): () => void { - const result = appendOptimisticUserMessageToStream({ - tail: stream.getTail(agentId) ?? [], - head: stream.getHead(agentId) ?? [], - message: userMessage, - placement: "active-head", - }); - const write = result.changedHead ? stream.setHead : stream.setTail; - const items = result.changedHead ? result.head : result.tail; - write((prev) => new Map(prev).set(agentId, items)); - - return () => { - write((prev) => { - const current = prev.get(agentId); - if (!current) return prev; - const nextItems = current.filter( - (item) => item.id !== userMessage.id || item.kind !== "user_message" || !item.optimistic, - ); - if (nextItems.length === current.length) return prev; - return new Map(prev).set(agentId, nextItems); - }); - }; -} - export interface QueueComposerMessageInput { agentId: string; text: string; diff --git a/packages/app/src/composer/draft/create-flow.test.ts b/packages/app/src/composer/draft/create-flow.test.ts index 41ea2a4d9..471d1fc8b 100644 --- a/packages/app/src/composer/draft/create-flow.test.ts +++ b/packages/app/src/composer/draft/create-flow.test.ts @@ -13,7 +13,7 @@ describe("useDraftAgentCreateFlow", () => { useCreateFlowStore.setState({ pendingByDraftId: {} }); }); - it("renders a prepared new-workspace create attempt as optimistic chat before continuing it", async () => { + it("renders a prepared new-workspace submission before continuing it", async () => { const image: UserMessageImageAttachment = { id: "image-1", mimeType: "image/png", @@ -60,13 +60,13 @@ describe("useDraftAgentCreateFlow", () => { expect(result.current.isSubmitting).toBe(true); expect(result.current.draftAgent).toEqual({ currentAttempt: attempt }); - expect(result.current.optimisticStreamItems).toEqual([ + expect(result.current.submittedStreamItems).toEqual([ { kind: "user_message", id: "msg-prepared", + clientMessageId: "msg-prepared", text: "build this", timestamp: attempt.timestamp, - optimistic: true, images: [image], attachments: [attachment], }, diff --git a/packages/app/src/composer/draft/create-flow.ts b/packages/app/src/composer/draft/create-flow.ts index d4fddc412..ea3638da6 100644 --- a/packages/app/src/composer/draft/create-flow.ts +++ b/packages/app/src/composer/draft/create-flow.ts @@ -8,12 +8,13 @@ import { import { useCreateFlowStore } from "@/stores/create-flow-store"; import { useSessionStore } from "@/stores/session-store"; import { - buildOptimisticUserMessage, + createUserMessage, generateMessageId, type StreamItem, type UserMessageImageAttachment, } from "@/types/stream"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; +import type { PendingMessageSubmission } from "@/composer/submission/model"; const EMPTY_STREAM_ITEMS: StreamItem[] = []; @@ -133,7 +134,7 @@ export function useDraftAgentCreateFlow({ const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : ""; const isSubmitting = machine.tag === "creating"; - const optimisticStreamItems = useMemo(() => { + const submittedStreamItems = useMemo(() => { if (machine.tag !== "creating") { return EMPTY_STREAM_ITEMS; } @@ -147,8 +148,8 @@ export function useDraftAgentCreateFlow({ } return [ - buildOptimisticUserMessage({ - id: machine.attempt.clientMessageId, + createUserMessage({ + clientMessageId: machine.attempt.clientMessageId, text: machine.attempt.text, timestamp: machine.attempt.timestamp, images: machine.attempt.images, @@ -156,6 +157,15 @@ export function useDraftAgentCreateFlow({ }), ]; }, [machine]); + const pendingMessageSubmissions = useMemo(() => { + if (machine.tag !== "creating") return []; + return [ + { + clientMessageId: machine.attempt.clientMessageId, + submittedAt: machine.attempt.timestamp, + }, + ]; + }, [machine]); const draftAgent = useMemo(() => { if (machine.tag !== "creating") { @@ -195,8 +205,8 @@ export function useDraftAgentCreateFlow({ handoffCreatedAgentUserMessage( pendingServerId, createResult.agentId, - buildOptimisticUserMessage({ - id: attempt.clientMessageId, + createUserMessage({ + clientMessageId: attempt.clientMessageId, text: attempt.text, timestamp: attempt.timestamp, images: attempt.images, @@ -326,7 +336,8 @@ export function useDraftAgentCreateFlow({ machine, formErrorMessage, isSubmitting, - optimisticStreamItems, + submittedStreamItems, + pendingMessageSubmissions, draftAgent, handleCreateFromInput, continueCreateFromAttempt, diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 490b0a495..a4aa272df 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -479,7 +479,8 @@ export function WorkspaceDraftAgentTab({ const { formErrorMessage, isSubmitting, - optimisticStreamItems, + submittedStreamItems, + pendingMessageSubmissions, draftAgent, handleCreateFromInput, continueCreateFromAttempt, @@ -642,7 +643,8 @@ export function WorkspaceDraftAgentTab({ agentId={tabId} serverId={serverId} context={draftAgent} - streamItems={optimisticStreamItems} + streamItems={submittedStreamItems} + pendingMessageSubmissions={pendingMessageSubmissions} pendingPermissions={EMPTY_PENDING_PERMISSIONS} onOpenWorkspaceFile={onOpenWorkspaceFile} /> diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 35232cd02..12be75422 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -64,7 +64,6 @@ import { sendQueuedComposerMessageNow, toggleGithubAttachmentFromPicker, uploadFileAttachments, - type AgentStreamWriter, type QueueWriter, type QueuedComposerMessage, } from "@/composer/actions"; @@ -91,6 +90,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import { submitAgentInput } from "@/composer/submit"; +import { createMessageSubmissionWriter } from "@/composer/submission/writer"; import { ComposerKeyboardScopeProvider } from "@/composer/keyboard-scope"; import { useAppSettings } from "@/hooks/use-settings"; import { isWeb, isNative } from "@/constants/platform"; @@ -1079,8 +1079,6 @@ export function Composer({ const queuedMessages = queuedMessagesRaw ?? EMPTY_ARRAY; const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages); - const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); - const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead); const isCompactFormFactor = useIsCompactFormFactor(); const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor); @@ -1283,12 +1281,6 @@ export function Composer({ if (!client) { throw new Error(t("workspace.terminal.hostDisconnected")); } - const stream: AgentStreamWriter = { - getTail: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamTail?.get(id), - getHead: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamHead?.get(id), - setHead: (updater) => setAgentStreamHead(serverId, updater), - setTail: (updater) => setAgentStreamTail(serverId, updater), - }; await dispatchComposerAgentMessage({ client, agentId: targetAgentId, @@ -1298,19 +1290,11 @@ export function Composer({ supportsForgeAttachments: supportsForgeSearch, }), encodeImages, - stream, + submission: createMessageSubmissionWriter(serverId), }); onAttentionPromptSend?.(); }; - }, [ - client, - onAttentionPromptSend, - serverId, - setAgentStreamTail, - setAgentStreamHead, - supportsForgeSearch, - t, - ]); + }, [client, onAttentionPromptSend, serverId, supportsForgeSearch, t]); useEffect(() => { onSubmitMessageRef.current = onSubmitMessage; diff --git a/packages/app/src/composer/submission/model.test.ts b/packages/app/src/composer/submission/model.test.ts new file mode 100644 index 000000000..cab44f8a3 --- /dev/null +++ b/packages/app/src/composer/submission/model.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { + acceptMessageSubmission, + beginMessageSubmission, + getActiveMessageSubmissions, + getSendingClientMessageIds, + observeAcceptedMessageSubmissionsRunning, + observeMessageSubmissionCanonical, + rejectMessageSubmission, +} from "./model"; + +const submittedAt = new Date("2026-07-26T10:00:00.000Z"); + +describe("message submission transactions", () => { + it("tracks every in-flight submission independently", () => { + const first = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt }); + const both = beginMessageSubmission(first, { + clientMessageId: "client-2", + submittedAt: new Date(submittedAt.getTime() + 1), + }); + + expect(getActiveMessageSubmissions(both).map((item) => item.clientMessageId)).toEqual([ + "client-1", + "client-2", + ]); + expect(getSendingClientMessageIds(both)).toEqual(["client-1", "client-2"]); + }); + + it("removes only the RPC-accepted transaction", () => { + const both = beginMessageSubmission( + beginMessageSubmission([], { clientMessageId: "client-1", submittedAt }), + { clientMessageId: "client-2", submittedAt }, + ); + + expect(acceptMessageSubmission(both, "client-1", true, false)).toEqual([ + { + clientMessageId: "client-2", + submittedAt, + rpcAccepted: false, + providerAcknowledged: false, + }, + ]); + }); + + it("bridges an accepted RPC until the correlated running state is observed", () => { + const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt }); + const accepted = acceptMessageSubmission(sending, "client-1", false, false); + + expect(getActiveMessageSubmissions(accepted)).toHaveLength(1); + expect(accepted[0].rpcAccepted).toBe(true); + expect(observeAcceptedMessageSubmissionsRunning(accepted)).toEqual([]); + }); + + 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", false, false); + + expect(observeMessageSubmissionCanonical(accepted, ["client-1"])).toEqual([]); + }); + + it("settles an explicitly out-of-band acceptance without lifecycle inference", () => { + const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt }); + + expect(acceptMessageSubmission(sending, "client-1", false, true)).toEqual([]); + }); + + it("settles an idle acceptance from a daemon without submission disposition", () => { + const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt }); + + expect(acceptMessageSubmission(sending, "client-1", false, undefined)).toEqual([]); + }); + + it("records provider acknowledgement without settling another transaction", () => { + const both = beginMessageSubmission( + beginMessageSubmission([], { clientMessageId: "client-1", submittedAt }), + { clientMessageId: "client-2", submittedAt }, + ); + const observed = observeMessageSubmissionCanonical(both, ["client-1"]); + + expect(observed).toEqual([ + { + clientMessageId: "client-1", + submittedAt, + rpcAccepted: false, + providerAcknowledged: true, + }, + { + clientMessageId: "client-2", + submittedAt, + rpcAccepted: false, + providerAcknowledged: false, + }, + ]); + expect(getSendingClientMessageIds(observed)).toEqual(["client-2"]); + }); + + it("does not roll back a provider-acknowledged prompt on a later transport error", () => { + const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt }); + const observed = observeMessageSubmissionCanonical(sending, ["client-1"]); + + expect(rejectMessageSubmission(observed, "client-1")).toEqual({ + outcome: "accepted", + submissions: [], + }); + }); + + it("rejects an unacknowledged transaction", () => { + const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt }); + + expect(rejectMessageSubmission(sending, "client-1")).toEqual({ + outcome: "rejected", + submissions: [], + }); + }); + + it("does not create duplicate transaction identity", () => { + const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt }); + + expect(() => + beginMessageSubmission(sending, { clientMessageId: "client-1", submittedAt }), + ).toThrow("Message submission already exists"); + }); +}); diff --git a/packages/app/src/composer/submission/model.ts b/packages/app/src/composer/submission/model.ts new file mode 100644 index 000000000..9efc5e66a --- /dev/null +++ b/packages/app/src/composer/submission/model.ts @@ -0,0 +1,108 @@ +export interface PendingMessageSubmission { + clientMessageId: string; + submittedAt: Date; +} + +export type MessageSubmissionRecord = PendingMessageSubmission & { + rpcAccepted: boolean; + providerAcknowledged: boolean; +}; + +const EMPTY_MESSAGE_SUBMISSIONS: readonly MessageSubmissionRecord[] = []; + +export function getActiveMessageSubmissions( + submissions: readonly MessageSubmissionRecord[] | null | undefined, +): readonly PendingMessageSubmission[] { + return submissions ?? EMPTY_MESSAGE_SUBMISSIONS; +} + +export function getSendingClientMessageIds( + submissions: readonly MessageSubmissionRecord[] | null | undefined, +): string[] { + return (submissions ?? []) + .filter((submission) => !submission.providerAcknowledged) + .map((submission) => submission.clientMessageId); +} + +export type MessageSubmissionRejectionOutcome = "rejected" | "accepted" | "unknown"; + +export interface MessageSubmissionRejectionResult { + submissions: MessageSubmissionRecord[]; + outcome: MessageSubmissionRejectionOutcome; +} + +export function beginMessageSubmission( + submissions: readonly MessageSubmissionRecord[], + input: PendingMessageSubmission, +): MessageSubmissionRecord[] { + if (submissions.some((submission) => submission.clientMessageId === input.clientMessageId)) { + throw new Error(`Message submission already exists: ${input.clientMessageId}`); + } + return [...submissions, { ...input, rpcAccepted: false, providerAcknowledged: false }]; +} + +export function acceptMessageSubmission( + submissions: readonly MessageSubmissionRecord[], + clientMessageId: string, + isAgentRunning: boolean, + outOfBand: boolean | undefined, +): MessageSubmissionRecord[] { + const index = submissions.findIndex( + (submission) => submission.clientMessageId === clientMessageId, + ); + if (index < 0) return submissions as MessageSubmissionRecord[]; + // COMPAT(messageSubmissionDisposition): daemons before v0.2.3 omitted outOfBand. + // Their normal-send response follows the ordered running/canonical events, while an + // out-of-band response arrives with the agent still idle. Remove after 2027-01-27. + const legacyOutOfBand = outOfBand === undefined && !isAgentRunning; + if ( + outOfBand === true || + legacyOutOfBand || + isAgentRunning || + submissions[index].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; +} + +export function observeAcceptedMessageSubmissionsRunning( + submissions: readonly MessageSubmissionRecord[], +): MessageSubmissionRecord[] { + const next = submissions.filter((submission) => !submission.rpcAccepted); + return next.length === submissions.length ? (submissions as MessageSubmissionRecord[]) : next; +} + +export function observeMessageSubmissionCanonical( + submissions: readonly MessageSubmissionRecord[], + clientMessageIds: readonly string[], +): MessageSubmissionRecord[] { + if (clientMessageIds.length === 0) return submissions as MessageSubmissionRecord[]; + const observed = new Set(clientMessageIds); + let changed = false; + const next = submissions.flatMap((submission): MessageSubmissionRecord[] => { + if (submission.providerAcknowledged || !observed.has(submission.clientMessageId)) { + return [submission]; + } + changed = true; + return submission.rpcAccepted ? [] : [{ ...submission, providerAcknowledged: true }]; + }); + return changed ? next : (submissions as MessageSubmissionRecord[]); +} + +export function rejectMessageSubmission( + submissions: readonly MessageSubmissionRecord[], + clientMessageId: string, +): MessageSubmissionRejectionResult { + const submission = submissions.find((item) => item.clientMessageId === clientMessageId); + if (!submission) { + return { outcome: "unknown", submissions: submissions as MessageSubmissionRecord[] }; + } + return { + outcome: submission.providerAcknowledged || submission.rpcAccepted ? "accepted" : "rejected", + submissions: submissions.filter((item) => item.clientMessageId !== clientMessageId), + }; +} diff --git a/packages/app/src/composer/submission/writer.ts b/packages/app/src/composer/submission/writer.ts new file mode 100644 index 000000000..9b41c84e7 --- /dev/null +++ b/packages/app/src/composer/submission/writer.ts @@ -0,0 +1,20 @@ +import type { MessageSubmissionWriter } from "@/composer/actions"; +import { useSessionStore } from "@/stores/session-store"; + +/** + * Binds the submission lifecycle to a host session. Every path that sends a message to an + * agent — composer send, queued send-now, automatic queue drain — goes through this so a + * submitted row and its pending state are always created together. + */ +export function createMessageSubmissionWriter(serverId: string): MessageSubmissionWriter { + return { + begin: (agentId, message) => + useSessionStore.getState().beginAgentMessageSubmission(serverId, agentId, message), + accept: (agentId, clientMessageId, outOfBand) => + useSessionStore + .getState() + .acceptAgentMessageSubmission(serverId, agentId, clientMessageId, outOfBand), + reject: (agentId, clientMessageId) => + useSessionStore.getState().rejectAgentMessageSubmission(serverId, agentId, clientMessageId), + }; +} diff --git a/packages/app/src/composer/submit.ts b/packages/app/src/composer/submit.ts index fb892d985..c0c39171c 100644 --- a/packages/app/src/composer/submit.ts +++ b/packages/app/src/composer/submit.ts @@ -51,7 +51,7 @@ export async function submitAgentInput( return "queued"; } - // Clear immediately so optimistic stream updates and composer state stay in sync. + // Clear immediately so the submitted timeline row and composer state stay in sync. if (shouldClearOnSubmit) { input.setUserInput(""); input.setAttachments([]); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 14d36f183..8a627d614 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -53,6 +53,7 @@ import { } from "@/utils/agent-initialization"; import { encodeImages } from "@/utils/encode-images"; import { derivePendingPermissionKey } from "@/utils/agent-snapshots"; +import { getSendingClientMessageIds } from "@/composer/submission/model"; import type { AttachmentMetadata } from "@/attachments/types"; import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts"; import { useToast } from "@/contexts/toast-context"; @@ -191,9 +192,7 @@ type WorkspaceSetupProgressPayload = Extract< type SessionStoreActions = ReturnType; type SetInitializingAgents = SessionStoreActions["setInitializingAgents"]; -type SetAgentStreamTail = SessionStoreActions["setAgentStreamTail"]; -type SetAgentStreamHead = SessionStoreActions["setAgentStreamHead"]; -type ClearAgentStreamHead = SessionStoreActions["clearAgentStreamHead"]; +type SetAgentStreamState = SessionStoreActions["setAgentStreamState"]; type SetAgentTimelineCursor = SessionStoreActions["setAgentTimelineCursor"]; type MarkAgentHistorySynchronized = SessionStoreActions["markAgentHistorySynchronized"]; type SetAgentAuthoritativeHistoryApplied = @@ -236,9 +235,7 @@ function applyTimelineStreamPatches(input: { serverId: string; currentTail: StreamItem[]; currentHead: StreamItem[]; - setAgentStreamTail: SetAgentStreamTail; - setAgentStreamHead: SetAgentStreamHead; - clearAgentStreamHead: ClearAgentStreamHead; + setAgentStreamState: SetAgentStreamState; setAgentTimelineCursor: SetAgentTimelineCursor; }): void { const { @@ -247,32 +244,24 @@ function applyTimelineStreamPatches(input: { serverId, currentTail, currentHead, - setAgentStreamTail, - setAgentStreamHead, - clearAgentStreamHead, + setAgentStreamState, setAgentTimelineCursor, } = input; - if (result.tail !== currentTail) { - setAgentStreamTail(serverId, (prev) => { - const next = new Map(prev); - next.set(agentId, result.tail); - return next; + if ( + result.tail !== currentTail || + result.head !== currentHead || + result.acknowledgedClientMessageIds.length > 0 + ) { + setAgentStreamState(serverId, agentId, { + ...(result.tail !== currentTail ? { tail: result.tail } : {}), + ...(result.head !== currentHead ? { head: result.head } : {}), + ...(result.acknowledgedClientMessageIds.length > 0 + ? { acknowledgedClientMessageIds: result.acknowledgedClientMessageIds } + : {}), }); } - if (result.head !== currentHead) { - if (result.head.length === 0) { - clearAgentStreamHead(serverId, agentId); - } else { - setAgentStreamHead(serverId, (prev) => { - const next = new Map(prev); - next.set(agentId, result.head); - return next; - }); - } - } - if (result.cursorChanged) { setAgentTimelineCursor(serverId, (prev) => { const current = prev.get(agentId); @@ -658,6 +647,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const currentCursor = session?.agentTimelineCursor.get(agentId); const currentTail = session?.agentStreamTail.get(agentId) ?? []; const currentHead = session?.agentStreamHead.get(agentId) ?? []; + const sendingClientMessageIds = getSendingClientMessageIds( + session?.messageSubmissions.get(agentId), + ); setAgentTimelineHasOlder(serverId, (prev) => { if (prev.get(agentId) === payload.hasOlder) { @@ -677,6 +669,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider isInitializing, hasActiveInitDeferred, initRequestDirection: activeInitDeferred?.requestDirection ?? "tail", + sendingClientMessageIds, }); if (result.error) { @@ -696,9 +689,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider serverId, currentTail, currentHead, - setAgentStreamTail, - setAgentStreamHead, - clearAgentStreamHead, + setAgentStreamState, setAgentTimelineCursor, }); @@ -720,13 +711,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider }); }, [ - clearAgentStreamHead, markAgentHistorySynchronized, recoverTimelineGap, serverId, setAgentAuthoritativeHistoryApplied, - setAgentStreamHead, - setAgentStreamTail, + setAgentStreamState, setAgentTimelineCursor, setAgentTimelineHasOlder, setInitializingAgents, @@ -808,7 +797,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider serverId, setAgentStreamState, setAgentTimelineCursor, - setAgents, recoverTimelineGap, }); @@ -825,7 +813,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider ) { voiceRuntime?.onTurnEvent(serverId, agentId, event.type); } - agentStreamReducerQueue.enqueue(agentId, { event: streamEvent, seq, diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 240f300db..7057169f1 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -25,6 +25,7 @@ import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { SidebarCallout } from "@/components/sidebar-callout"; import { Composer } from "@/composer"; +import { getActiveMessageSubmissions } from "@/composer/submission/model"; import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore"; import { getProviderIcon } from "@/components/provider-icons"; import { @@ -442,6 +443,7 @@ export function useDraftPanelDescriptor( } const EMPTY_STREAM_ITEMS: StreamItem[] = []; +const EMPTY_MESSAGE_SUBMISSIONS = [] as const; const EMPTY_PENDING_PERMISSIONS = new Map(); const EMPTY_PENDING_PERMISSION_LIST: PendingPermission[] = []; @@ -1288,6 +1290,11 @@ 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 streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS; const pendingPermissionList = useStoreWithEqualityFn( useSessionStore, @@ -1327,6 +1334,7 @@ const AgentStreamSection = memo(function AgentStreamSection({ routeBottomAnchorRequest={routeBottomAnchorRequest} isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory} toast={toast} + pendingMessageSubmissions={pendingMessageSubmissions} onOpenWorkspaceFile={onOpenWorkspaceFile} /> ); diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 0f4946a4b..6f73dd640 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -80,13 +80,16 @@ class FakeDaemonClient { this.setConnectionState({ status: "disconnected", reason: "client_closed" }); } - async sendAgentMessage(...args: Parameters): Promise { + async sendAgentMessage( + ...args: Parameters + ): ReturnType { this.sentAgentMessages.push(args); for (const waiter of this.sentMessageWaiters) waiter(); const response = this.sendAgentMessageResponses.shift(); if (response) await response; const failure = this.sendAgentMessageFailures.shift(); if (failure) throw failure; + return {}; } async waitForSentMessages(count: number): Promise { @@ -2259,6 +2262,66 @@ describe("HostRuntimeStore", () => { useSessionStore.getState().clearSession(host.serverId); }); + it("submits an automatically drained message through the submission producer", async () => { + const host = makeHost({ serverId: "srv_drain_submission" }); + const fakeClient = new FakeDaemonClient(); + const send = new Deferred(); + fakeClient.sendAgentMessageResponses.push(send.promise); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async () => ({ + client: fakeClient as unknown as DaemonClient, + serverId: host.serverId, + hostname: null, + }), + getClientId: async () => "cid_drain_submission", + }, + }); + const sessionStore = useSessionStore.getState(); + sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + sessionStore.setQueuedMessages( + host.serverId, + new Map([ + [ + "agent", + [ + { + id: "queued-with-attachment", + text: "read this file", + attachments: [ + { + kind: "workspace_file" as const, + path: "src/main.ts", + selection: { kind: "whole_file" as const }, + }, + ], + }, + ], + ], + ]), + ); + + store.drainQueuedAgentMessage(host.serverId, "agent"); + await fakeClient.waitForSentMessages(1); + + // The row and the pending submission must exist while the RPC is still in flight — + // the user sees their message and the working footer immediately, exactly as when + // they press send. + const session = useSessionStore.getState().sessions[host.serverId]; + const tail = session?.agentStreamTail.get("agent") ?? []; + expect(tail).toHaveLength(1); + expect(tail[0]).toMatchObject({ + kind: "user_message", + text: "read this file", + attachments: [{ type: "text", title: "main.ts", text: "Workspace file: src/main.ts" }], + }); + expect(session?.messageSubmissions.get("agent")).toBeDefined(); + + send.resolve(); + useSessionStore.getState().clearSession(host.serverId); + }); + it("restores an automatically drained message when sending fails", async () => { const host = makeHost({ serverId: "srv_failed_queue_drain" }); const fakeClient = new FakeDaemonClient(); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 144632817..d77682a2f 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -49,11 +49,9 @@ import { } from "@/data/push-router"; import { mountBrowserAutomationDaemonClientHandler } from "@/browser-automation/handler"; import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules"; -import { sendQueuedComposerMessageNow } from "@/composer/actions"; -import { - resolveComposerAttachmentSubmitFormat, - splitComposerAttachmentsForSubmit, -} from "@/composer/attachments/submit"; +import { dispatchComposerAgentMessage, sendQueuedComposerMessageNow } from "@/composer/actions"; +import { createMessageSubmissionWriter } from "@/composer/submission/writer"; +import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit"; import { encodeImages } from "@/utils/encode-images"; import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/directory-sync"; import { ReplicaCache } from "@/runtime/replica-cache"; @@ -2071,14 +2069,16 @@ export class HostRuntimeStore { submitMessage: async ({ text, attachments }) => { const supportsForgeAttachments = useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch === true; - const wirePayload = splitComposerAttachmentsForSubmit(attachments, { - format: resolveComposerAttachmentSubmitFormat({ supportsForgeAttachments }), - }); - const images = await encodeImages(wirePayload.images); - await client.sendAgentMessage(agentId, text, { - messageId: next.id, - ...(images && images.length > 0 ? { images } : {}), - attachments: wirePayload.attachments, + await dispatchComposerAgentMessage({ + client, + agentId, + text, + attachments, + attachmentSubmitFormat: resolveComposerAttachmentSubmitFormat({ + supportsForgeAttachments, + }), + encodeImages, + submission: createMessageSubmissionWriter(serverId), }); }, }) diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 7f8582884..b9bfab5c0 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -19,9 +19,10 @@ import { } from "@/stores/session-store"; import type { StreamItem } from "@/types/stream"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import { getSendingClientMessageIds } from "@/composer/submission/model"; const STORAGE_KEY = "@paseo:replica-cache"; -const CACHE_VERSION = 1; +const CACHE_VERSION = 2; const PERSIST_DELAY_MS = 750; const MAX_TIMELINE_ITEMS = 50; const MAX_CACHE_BYTES = 1024 * 1024; @@ -370,7 +371,22 @@ export class ReplicaCache { (workspace) => workspace.workspaceDirectory === focusedAgent.cwd, )) : undefined; - const items = focusedAgentId ? session.agentStreamTail.get(focusedAgentId) : undefined; + const localSubmissionIds = new Set( + getSendingClientMessageIds( + focusedAgentId ? session.messageSubmissions.get(focusedAgentId) : undefined, + ), + ); + const items = focusedAgentId + ? session.agentStreamTail + .get(focusedAgentId) + ?.filter( + (item) => + item.kind !== "user_message" || + item.messageId !== undefined || + !item.clientMessageId || + !localSubmissionIds.has(item.clientMessageId), + ) + : undefined; const timeline = focusedAgent && items ? { diff --git a/packages/app/src/stores/create-flow-store.ts b/packages/app/src/stores/create-flow-store.ts index 5ca6b9532..2056e42c0 100644 --- a/packages/app/src/stores/create-flow-store.ts +++ b/packages/app/src/stores/create-flow-store.ts @@ -109,7 +109,10 @@ export const useCreateFlowStore = create((set) => ({ set((state) => { const next = Object.fromEntries( Object.entries(state.pendingByDraftId).filter( - ([, pending]) => pending.serverId !== serverId || pending.agentId !== agentId, + ([, pending]) => + pending.lifecycle !== "sent" || + pending.serverId !== serverId || + pending.agentId !== agentId, ), ); if (Object.keys(next).length === Object.keys(state.pendingByDraftId).length) { diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 1d61867f2..3a5504310 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -5,10 +5,21 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync"; import type { AgentDirectoryEntry } from "@/types/agent-directory"; import { + appendSubmittedUserMessage, handoffCreatedAgentUserMessageToStream, + removeSubmittedUserMessage, type StreamItem, type UserMessageItem, } from "@/types/stream"; +import { + acceptMessageSubmission, + beginMessageSubmission, + observeAcceptedMessageSubmissionsRunning, + observeMessageSubmissionCanonical, + rejectMessageSubmission, + type MessageSubmissionRecord, + type MessageSubmissionRejectionOutcome, +} from "@/composer/submission/model"; import type { PendingPermission } from "@/types/shared"; import type { ComposerAttachment } from "@/attachments/types"; import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle"; @@ -368,6 +379,7 @@ export interface SessionState { // Stream state (head/tail model) agentStreamTail: Map; agentStreamHead: Map; + messageSubmissions: Map; agentTimelineCursor: Map; agentTimelineHasOlder: Map; agentTimelineOlderFetchInFlight: Map; @@ -459,8 +471,28 @@ interface SessionStoreActions { setAgentStreamState: ( serverId: string, agentId: string, - state: { tail?: StreamItem[]; head?: StreamItem[] }, + state: { + tail?: StreamItem[]; + head?: StreamItem[]; + acknowledgedClientMessageIds?: readonly string[]; + }, ) => void; + beginAgentMessageSubmission: ( + serverId: string, + agentId: string, + message: UserMessageItem, + ) => void; + acceptAgentMessageSubmission: ( + serverId: string, + agentId: string, + clientMessageId: string, + outOfBand: boolean | undefined, + ) => void; + rejectAgentMessageSubmission: ( + serverId: string, + agentId: string, + clientMessageId: string, + ) => MessageSubmissionRejectionOutcome; handoffCreatedAgentUserMessage: ( serverId: string, agentId: string, @@ -567,6 +599,27 @@ type SessionStore = SessionStoreState & SessionStoreActions; const agentLastActivityCoalescer = createAgentLastActivityCoalescer(); +function applyRunningAgentsToAcceptedSubmissions(input: { + previousAgents: Map; + nextAgents: Map; + submissions: Map; +}): Map { + let nextSubmissions = input.submissions; + for (const [agentId, submissions] of input.submissions) { + const previousAgent = input.previousAgents.get(agentId); + const nextAgent = input.nextAgents.get(agentId); + if (!nextAgent || previousAgent?.status === "running" || nextAgent.status !== "running") { + continue; + } + const remaining = observeAcceptedMessageSubmissionsRunning(submissions); + if (remaining === submissions) continue; + if (nextSubmissions === input.submissions) nextSubmissions = new Map(input.submissions); + if (remaining.length > 0) nextSubmissions.set(agentId, remaining); + else nextSubmissions.delete(agentId); + } + return nextSubmissions; +} + // Helper to create initial session state function createInitialSessionState( serverId: string, @@ -588,6 +641,7 @@ function createInitialSessionState( currentAssistantMessage: "", agentStreamTail: new Map(), agentStreamHead: new Map(), + messageSubmissions: new Map(), agentTimelineCursor: new Map(), agentTimelineHasOlder: new Map(), agentTimelineOlderFetchInFlight: new Map(), @@ -1047,10 +1101,27 @@ export const useSessionStore = create()( } } - if (!changedTail && !changedHead) { + const currentSubmissions = session.messageSubmissions.get(agentId) ?? []; + const observedSubmissions = observeMessageSubmissionCanonical( + currentSubmissions, + state.acknowledgedClientMessageIds ?? [], + ); + const changedSubmissions = observedSubmissions !== currentSubmissions; + + if (!changedTail && !changedHead && !changedSubmissions) { return prev; } + let messageSubmissions = session.messageSubmissions; + if (changedSubmissions) { + messageSubmissions = new Map(session.messageSubmissions); + if (observedSubmissions.length > 0) { + messageSubmissions.set(agentId, observedSubmissions); + } else { + messageSubmissions.delete(agentId); + } + } + return { ...prev, sessions: { @@ -1059,12 +1130,129 @@ export const useSessionStore = create()( ...session, agentStreamTail: nextTail, agentStreamHead: nextHead, + messageSubmissions, }, }, }; }); }, + beginAgentMessageSubmission: (serverId, agentId, message) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) return prev; + if (!message.clientMessageId) { + throw new Error("Beginning a message submission requires client identity"); + } + const currentTail = session.agentStreamTail.get(agentId) ?? []; + const currentHead = session.agentStreamHead.get(agentId) ?? []; + const stream = appendSubmittedUserMessage({ + tail: currentTail, + head: currentHead, + message, + }); + const submissions = beginMessageSubmission( + session.messageSubmissions.get(agentId) ?? [], + { clientMessageId: message.clientMessageId, submittedAt: message.timestamp }, + ); + const messageSubmissions = new Map(session.messageSubmissions); + messageSubmissions.set(agentId, submissions); + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { + ...session, + agentStreamTail: + stream.tail === currentTail + ? session.agentStreamTail + : new Map(session.agentStreamTail).set(agentId, stream.tail), + agentStreamHead: + stream.head === currentHead + ? session.agentStreamHead + : new Map(session.agentStreamHead).set(agentId, stream.head), + messageSubmissions, + }, + }, + }; + }); + }, + + acceptAgentMessageSubmission: (serverId, agentId, clientMessageId, outOfBand) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) return prev; + const currentSubmissions = session.messageSubmissions.get(agentId) ?? []; + const submissions = acceptMessageSubmission( + currentSubmissions, + clientMessageId, + session.agents.get(agentId)?.status === "running", + outOfBand, + ); + if (submissions === currentSubmissions) return prev; + const messageSubmissions = new Map(session.messageSubmissions); + if (submissions.length > 0) { + messageSubmissions.set(agentId, submissions); + } else { + messageSubmissions.delete(agentId); + } + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { ...session, messageSubmissions }, + }, + }; + }); + }, + + rejectAgentMessageSubmission: (serverId, agentId, clientMessageId) => { + let outcome: MessageSubmissionRejectionOutcome = "unknown"; + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) return prev; + const currentTail = session.agentStreamTail.get(agentId) ?? []; + const currentHead = session.agentStreamHead.get(agentId) ?? []; + const currentSubmissions = session.messageSubmissions.get(agentId) ?? []; + const result = rejectMessageSubmission(currentSubmissions, clientMessageId); + outcome = result.outcome; + if (outcome === "unknown") return prev; + const stream = + outcome === "rejected" + ? removeSubmittedUserMessage({ + tail: currentTail, + head: currentHead, + clientMessageId, + }) + : { tail: currentTail, head: currentHead }; + const messageSubmissions = new Map(session.messageSubmissions); + if (result.submissions.length > 0) { + messageSubmissions.set(agentId, result.submissions); + } else { + messageSubmissions.delete(agentId); + } + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { + ...session, + agentStreamTail: + stream.tail === currentTail + ? session.agentStreamTail + : new Map(session.agentStreamTail).set(agentId, stream.tail), + agentStreamHead: + stream.head === currentHead + ? session.agentStreamHead + : new Map(session.agentStreamHead).set(agentId, stream.head), + messageSubmissions, + }, + }, + }; + }); + return outcome; + }, + handoffCreatedAgentUserMessage: (serverId, agentId, message) => { let didHandoff = false; set((prev) => { @@ -1298,7 +1486,12 @@ export const useSessionStore = create()( return prev; } const nextAgents = typeof agents === "function" ? agents(session.agents) : agents; - if (session.agents === nextAgents) { + const messageSubmissions = applyRunningAgentsToAcceptedSubmissions({ + previousAgents: session.agents, + nextAgents, + submissions: session.messageSubmissions, + }); + if (session.agents === nextAgents && session.messageSubmissions === messageSubmissions) { return prev; } return { @@ -1308,10 +1501,11 @@ export const useSessionStore = create()( [serverId]: { ...session, agents: nextAgents, - workspaceAgentActivity: buildWorkspaceAgentActivityIndex( - nextAgents, - session.workspaceAgentActivity, - ), + messageSubmissions, + workspaceAgentActivity: + nextAgents === session.agents + ? session.workspaceAgentActivity + : buildWorkspaceAgentActivityIndex(nextAgents, session.workspaceAgentActivity), }, }, }; diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index 491da6763..10eed0188 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; import { - buildOptimisticUserMessage, + createUserMessage, hydrateStreamState, type AgentToolCallItem, type StreamItem, @@ -119,12 +119,12 @@ function makeAssistantItem( }; } -function makeOptimisticUserMessage( +function makeSubmittedUserMessage( text: string, - id = `optimistic-${text.length}`, + id = `submitted-${text.length}`, ): Extract { - return buildOptimisticUserMessage({ - id, + return createUserMessage({ + clientMessageId: id, text, timestamp: new Date(1000), }); @@ -172,6 +172,7 @@ const baseTimelineInput: ProcessTimelineResponseInput = { isInitializing: false, hasActiveInitDeferred: false, initRequestDirection: "tail", + sendingClientMessageIds: [], }; const baseStreamInput: ProcessAgentStreamEventInput = { @@ -181,7 +182,6 @@ const baseStreamInput: ProcessAgentStreamEventInput = { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, timestamp: new Date(2000), }; @@ -293,6 +293,75 @@ describe("processTimelineResponse", () => { expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true); }); + it("keeps a live assistant and submitted head prompt in one lane during replacement", () => { + const submitted = makeSubmittedUserMessage("New prompt", "client-new-prompt"); + const liveAssistant = { + ...makeAssistantItem("Live answer", "answer-1"), + messageId: "answer-1", + }; + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [], + currentHead: [liveAssistant, submitted], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, + sendingClientMessageIds: ["client-new-prompt"], + payload: { + ...baseTimelineInput.payload, + reset: true, + epoch: "epoch-1", + startCursor: { seq: 1 }, + endCursor: { seq: 1 }, + entries: [ + { + ...makeTimelineEntry(1, "Live", "assistant_message"), + item: { + type: "assistant_message", + text: "Live", + messageId: "answer-1", + }, + }, + ], + }, + }); + + expect(result.tail).toEqual([]); + expect(result.head).toEqual([{ ...liveAssistant, text: "Live" }, submitted]); + }); + + it("preserves newer live head items when canonical replacement ends in a tool call", () => { + const liveThought: StreamItem = { + kind: "thought", + id: "live-thought", + text: "newer reasoning", + timestamp: new Date(3000), + status: "loading", + }; + const liveAssistant = makeAssistantItem("newer answer", "live-answer"); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentHead: [liveThought, liveAssistant], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, + payload: { + ...baseTimelineInput.payload, + reset: true, + epoch: "epoch-1", + startCursor: { seq: 1 }, + endCursor: { seq: 1 }, + entries: [ + makeToolCallTimelineEntry(1, "canonical-call", "completed", { + type: "read", + filePath: "/tmp/older.ts", + }), + ], + }, + }); + + expect(result.tail.map((item) => item.kind)).toEqual(["tool_call"]); + expect(result.head).toEqual([liveThought, liveAssistant]); + }); + it("uses the timeline entry timestamp as canonical", () => { const result = processTimelineResponse({ ...baseTimelineInput, @@ -321,12 +390,12 @@ describe("processTimelineResponse", () => { expect(assistant?.timestamp.toISOString()).toBe("2025-01-01T12:00:04.000Z"); }); - it("reconciles an optimistic user message during tail replacement", () => { + it("reconciles a submitted user message during tail replacement", () => { const image = { - id: "optimistic-image", + id: "submitted-image", mimeType: "image/png", storageType: "web-indexeddb" as const, - storageKey: "optimistic-image", + storageKey: "submitted-image", createdAt: 1000, }; const attachment = { @@ -335,8 +404,8 @@ describe("processTimelineResponse", () => { text: "attached context", title: "context.txt", }; - const optimistic = buildOptimisticUserMessage({ - id: "optimistic-create-user", + const submitted = createUserMessage({ + clientMessageId: "submitted-create-user", text: "Analyze this", timestamp: new Date(1000), images: [image], @@ -345,7 +414,7 @@ describe("processTimelineResponse", () => { const result = processTimelineResponse({ ...baseTimelineInput, - currentTail: [optimistic], + currentTail: [submitted], payload: { ...baseTimelineInput.payload, reset: true, @@ -358,6 +427,7 @@ describe("processTimelineResponse", () => { type: "user_message", text: "server-rendered attachment text", messageId: "canonical-create-user", + clientMessageId: "submitted-create-user", }, }, ], @@ -367,13 +437,14 @@ describe("processTimelineResponse", () => { const userMessages = result.tail.filter((item) => item.kind === "user_message"); expect(userMessages).toHaveLength(1); expect(userMessages[0]).toMatchObject({ - id: "canonical-create-user", + id: "submitted-create-user", + clientMessageId: "submitted-create-user", + messageId: "canonical-create-user", text: "Analyze this", timestamp: new Date(1000), images: [image], attachments: [attachment], }); - expect(userMessages[0]?.optimistic).toBeUndefined(); const repeated = processTimelineResponse({ ...baseTimelineInput, @@ -390,6 +461,7 @@ describe("processTimelineResponse", () => { type: "user_message", text: "server-rendered attachment text", messageId: "canonical-create-user", + clientMessageId: "submitted-create-user", }, }, ], @@ -399,24 +471,93 @@ describe("processTimelineResponse", () => { expect(repeated.tail.filter((item) => item.kind === "user_message")).toEqual(userMessages); }); - it("keeps an unmatched optimistic user message during tail replacement", () => { - const optimistic = makeOptimisticUserMessage("still sending", "optimistic-unmatched"); + it("keeps an unmatched submitted user message during tail replacement", () => { + const submitted = makeSubmittedUserMessage("still sending", "submitted-unmatched"); const result = processTimelineResponse({ ...baseTimelineInput, - currentTail: [optimistic], + currentTail: [submitted], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, + sendingClientMessageIds: ["submitted-unmatched"], payload: { ...baseTimelineInput.payload, reset: true, + epoch: "epoch-2", entries: [], }, }); - expect(result.tail).toEqual([optimistic]); + expect(result.tail).toEqual([submitted]); }); - it("does not move an unmatched submission during timeline replacement", () => { - const unmatched = makeOptimisticUserMessage("first submission", "client-first"); + it("keeps every unresolved submission during replacement", () => { + const first = makeSubmittedUserMessage("first pending", "client-first"); + const second = makeSubmittedUserMessage("second pending", "client-second"); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [first, second], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, + sendingClientMessageIds: ["client-first", "client-second"], + payload: { + ...baseTimelineInput.payload, + reset: true, + epoch: "epoch-2", + entries: [], + }, + }); + + expect(result.tail).toEqual([first, second]); + }); + + it("drops an acknowledged local row omitted by a same-epoch replacement", () => { + const acknowledged = createUserMessage({ + clientMessageId: "client-local-only", + text: "provider may not echo this", + timestamp: new Date(1000), + }); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [acknowledged], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, + sendingClientMessageIds: [], + payload: { + ...baseTimelineInput.payload, + reset: true, + epoch: "epoch-1", + entries: [], + }, + }); + + expect(result.tail).toEqual([]); + }); + + it("drops an acknowledged local row omitted by a known epoch change", () => { + const acknowledged = createUserMessage({ + clientMessageId: "client-prior-epoch", + text: "prior prompt", + timestamp: new Date(1000), + }); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [acknowledged], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, + sendingClientMessageIds: [], + payload: { + ...baseTimelineInput.payload, + reset: true, + epoch: "epoch-2", + entries: [], + }, + }); + + expect(result.tail).toEqual([]); + }); + + it("keeps an unmatched submission after the canonical replacement range", () => { + const unmatched = makeSubmittedUserMessage("first submission", "client-first"); const acknowledged: StreamItem[] = [ { kind: "user_message", @@ -437,6 +578,7 @@ describe("processTimelineResponse", () => { const result = processTimelineResponse({ ...baseTimelineInput, currentTail: [unmatched, ...acknowledged], + sendingClientMessageIds: ["client-first"], payload: { ...baseTimelineInput.payload, reset: true, @@ -462,10 +604,10 @@ describe("processTimelineResponse", () => { }, }, { - ...makeTimelineEntry(4, "response to all three submissions"), + ...makeTimelineEntry(4, "response to canonical submissions"), item: { type: "assistant_message", - text: "response to all three submissions", + text: "response to canonical submissions", messageId: "assistant-response", }, }, @@ -480,14 +622,14 @@ describe("processTimelineResponse", () => { text: "text" in item ? item.text : undefined, })), ).toEqual([ - { kind: "user_message", id: "client-first", text: "first submission" }, { kind: "user_message", id: "provider-second", text: "second submission" }, { kind: "user_message", id: "provider-third", text: "third submission" }, { kind: "assistant_message", id: "assistant-response", - text: "response to all three submissions", + text: "response to canonical submissions", }, + { kind: "user_message", id: "client-first", text: "first submission" }, ]); }); @@ -620,11 +762,11 @@ describe("processTimelineResponse", () => { startSeq: 1, endSeq: 1, }; - const optimistic = makeOptimisticUserMessage("sent while catching up", "optimistic-after"); + const submitted = makeSubmittedUserMessage("sent while catching up", "submitted-after"); const result = processTimelineResponse({ ...baseTimelineInput, - currentTail: [optimistic], + currentTail: [submitted], currentCursor: existingCursor, payload: { ...baseTimelineInput.payload, @@ -644,16 +786,19 @@ describe("processTimelineResponse", () => { const userMessages = result.tail.filter((item) => item.kind === "user_message"); expect(userMessages).toHaveLength(1); - expect(userMessages[0]?.id).toBe("canonical-after"); - expect(userMessages[0]?.optimistic).toBeUndefined(); + expect(userMessages[0]).toMatchObject({ + id: "submitted-after", + clientMessageId: "submitted-after", + messageId: "canonical-after", + }); }); - it("reconciles an optimistic user message by client message id", () => { - const optimistic = makeOptimisticUserMessage("local presentation", "client-message"); + it("reconciles a submitted user message by client message id", () => { + const submitted = makeSubmittedUserMessage("local presentation", "client-message"); const result = processTimelineResponse({ ...baseTimelineInput, - currentTail: [optimistic], + currentTail: [submitted], currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, payload: { ...baseTimelineInput.payload, @@ -675,20 +820,21 @@ describe("processTimelineResponse", () => { const userMessages = result.tail.filter((item) => item.kind === "user_message"); expect(userMessages).toEqual([ expect.objectContaining({ - id: "provider-message", + id: "client-message", clientMessageId: "client-message", + messageId: "provider-message", text: "local presentation", }), ]); - expect(userMessages[0]?.optimistic).toBeUndefined(); + expect(result.acknowledgedClientMessageIds).toEqual(["client-message"]); }); - it("reconciles multiple optimistic user messages in canonical order", () => { + it("reconciles multiple submitted user messages in canonical order", () => { const result = processTimelineResponse({ ...baseTimelineInput, currentTail: [ - makeOptimisticUserMessage("first prompt", "optimistic-first"), - makeOptimisticUserMessage("second prompt", "optimistic-second"), + makeSubmittedUserMessage("first prompt", "submitted-first"), + makeSubmittedUserMessage("second prompt", "submitted-second"), ], currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, payload: { @@ -699,14 +845,14 @@ describe("processTimelineResponse", () => { entries: [ { ...makeTimelineEntry(2, "first prompt", "user_message"), - item: { type: "user_message", text: "first prompt", messageId: "optimistic-first" }, + item: { type: "user_message", text: "first prompt", messageId: "submitted-first" }, }, { ...makeTimelineEntry(3, "second prompt", "user_message"), item: { type: "user_message", text: "second prompt", - messageId: "optimistic-second", + messageId: "submitted-second", }, }, ], @@ -716,15 +862,15 @@ describe("processTimelineResponse", () => { expect( result.tail .filter((item) => item.kind === "user_message") - .map((item) => ({ id: item.id, text: item.text, optimistic: item.optimistic })), + .map((item) => ({ id: item.id, text: item.text, messageId: item.messageId })), ).toEqual([ - { id: "optimistic-first", text: "first prompt", optimistic: undefined }, - { id: "optimistic-second", text: "second prompt", optimistic: undefined }, + { id: "submitted-first", text: "first prompt", messageId: "submitted-first" }, + { id: "submitted-second", text: "second prompt", messageId: "submitted-second" }, ]); }); - it("keeps a tail optimistic prompt before a reconciled live assistant head", () => { - const prompt = makeOptimisticUserMessage("new prompt", "optimistic-new-prompt"); + it("keeps a tail submitted prompt before a reconciled live assistant head", () => { + const prompt = makeSubmittedUserMessage("new prompt", "submitted-new-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -763,8 +909,8 @@ describe("processTimelineResponse", () => { ).toEqual(["new prompt", "Hello"]); }); - it("keeps a tail optimistic prompt before a live head flushed by catch-up", () => { - const prompt = makeOptimisticUserMessage("new prompt", "optimistic-new-prompt"); + it("keeps a tail submitted prompt before a live head flushed by catch-up", () => { + const prompt = makeSubmittedUserMessage("new prompt", "submitted-new-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -835,7 +981,6 @@ describe("processTimelineResponse", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); const result = processTimelineResponse({ @@ -913,7 +1058,6 @@ describe("processTimelineResponse", () => { currentTail: [], currentHead: [], currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, - currentAgent: null, }); expect(getAssistantTexts(live.tail)).toHaveLength(1); expect(getAssistantTexts(live.head)).toHaveLength(1); @@ -961,7 +1105,6 @@ describe("processTimelineResponse", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); const result = processTimelineResponse({ @@ -1000,7 +1143,7 @@ describe("processTimelineResponse", () => { }); it("does not move a submitted prompt when catch-up history arrives", () => { - const prompt = makeOptimisticUserMessage("New prompt", "new-prompt"); + const prompt = makeSubmittedUserMessage("New prompt", "new-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -1024,7 +1167,7 @@ describe("processTimelineResponse", () => { }); it("does not move an unmatched head prompt when catch-up history arrives", () => { - const prompt = makeOptimisticUserMessage("New prompt", "new-prompt"); + const prompt = makeSubmittedUserMessage("New prompt", "new-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -1052,8 +1195,8 @@ describe("processTimelineResponse", () => { ]); }); - it("acknowledges a head prompt in place while catch-up history arrives", () => { - const prompt = makeOptimisticUserMessage("New prompt", "new-prompt"); + it("moves an acknowledged head prompt to its catch-up sequence position", () => { + const prompt = makeSubmittedUserMessage("New prompt", "new-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -1084,18 +1227,18 @@ describe("processTimelineResponse", () => { expect([...result.tail, ...result.head].map((item) => item.kind)).toEqual([ "assistant_message", - "user_message", "tool_call", + "user_message", ]); expect( [...result.tail, ...result.head] .filter((item) => item.kind === "user_message") - .map((item) => item.optimistic), - ).toEqual([undefined]); + .map((item) => item.clientMessageId), + ).toEqual(["new-prompt"]); }); it("does not move a prompt around unrelated catch-up history", () => { - const prompt = makeOptimisticUserMessage("New prompt", "new-prompt"); + const prompt = makeSubmittedUserMessage("New prompt", "new-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -1141,7 +1284,7 @@ describe("processTimelineResponse", () => { }); it("does not move a prompt or its live answer around catch-up history", () => { - const prompt = makeOptimisticUserMessage("New prompt", "new-prompt"); + const prompt = makeSubmittedUserMessage("New prompt", "new-prompt"); const live = processAgentStreamEvents({ events: [ makeStreamReducerEvent( @@ -1152,7 +1295,6 @@ describe("processTimelineResponse", () => { currentTail: [prompt], currentHead: [], currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, - currentAgent: null, }); const result = processTimelineResponse({ @@ -1186,7 +1328,7 @@ describe("processTimelineResponse", () => { }); it("does not move a prompt or its live answer around catch-up tool history", () => { - const prompt = makeOptimisticUserMessage("New prompt", "new-prompt"); + const prompt = makeSubmittedUserMessage("New prompt", "new-prompt"); const live = processAgentStreamEvents({ events: [ makeStreamReducerEvent( @@ -1197,7 +1339,6 @@ describe("processTimelineResponse", () => { currentTail: [prompt], currentHead: [], currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, - currentAgent: null, }); const result = processTimelineResponse({ @@ -1228,7 +1369,7 @@ describe("processTimelineResponse", () => { }); it("never moves submitted messages behind a later assistant response", () => { - const unmatched = makeOptimisticUserMessage("first submission", "client-first"); + const unmatched = makeSubmittedUserMessage("first submission", "client-first"); const acknowledged: StreamItem[] = [ { kind: "user_message", @@ -1286,8 +1427,8 @@ describe("processTimelineResponse", () => { ]); }); - it("acknowledges a local prompt in place when a remote user row also arrives", () => { - const prompt = makeOptimisticUserMessage("Local prompt", "local-prompt"); + it("places a local prompt after an earlier remote canonical row", () => { + const prompt = makeSubmittedUserMessage("Local prompt", "local-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -1320,17 +1461,12 @@ describe("processTimelineResponse", () => { }); expect( - result.tail - .filter((item) => item.kind === "user_message") - .map((item) => ({ text: item.text, optimistic: item.optimistic })), - ).toEqual([ - { text: "Local prompt", optimistic: undefined }, - { text: "Remote prompt", optimistic: undefined }, - ]); + result.tail.filter((item) => item.kind === "user_message").map((item) => item.text), + ).toEqual(["Remote prompt", "Local prompt"]); }); - it("keeps an unmatched optimistic prompt when catch-up contains only a remote user row", () => { - const prompt = makeOptimisticUserMessage("Local prompt", "local-prompt"); + it("keeps an unmatched submitted prompt when catch-up contains only a remote user row", () => { + const prompt = makeSubmittedUserMessage("Local prompt", "local-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -1355,17 +1491,12 @@ describe("processTimelineResponse", () => { }); expect( - result.tail - .filter((item) => item.kind === "user_message") - .map((item) => ({ text: item.text, optimistic: item.optimistic })), - ).toEqual([ - { text: "Local prompt", optimistic: true }, - { text: "Remote prompt", optimistic: undefined }, - ]); + result.tail.filter((item) => item.kind === "user_message").map((item) => item.text), + ).toEqual(["Local prompt", "Remote prompt"]); }); it("does not match equal prompt text when canonical client message ids differ", () => { - const prompt = makeOptimisticUserMessage("continue", "local-prompt"); + const prompt = makeSubmittedUserMessage("continue", "local-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -1393,10 +1524,10 @@ describe("processTimelineResponse", () => { expect( result.tail .filter((item) => item.kind === "user_message") - .map((item) => ({ id: item.id, optimistic: item.optimistic })), + .map((item) => ({ id: item.id, messageId: item.messageId })), ).toEqual([ - { id: "local-prompt", optimistic: true }, - { id: "remote-prompt", optimistic: undefined }, + { id: "local-prompt", messageId: undefined }, + { id: "remote-prompt", messageId: "remote-prompt" }, ]); }); @@ -1639,8 +1770,8 @@ describe("processTimelineResponse", () => { }); }); - it("does not reconcile an active optimistic user message from a before-page response", () => { - const optimistic = makeOptimisticUserMessage("active prompt", "optimistic-active"); + it("does not reconcile an active submitted user message from a before-page response", () => { + const submitted = makeSubmittedUserMessage("active prompt", "submitted-active"); const existingCursor: TimelineCursor = { epoch: "epoch-1", startSeq: 3, @@ -1649,7 +1780,7 @@ describe("processTimelineResponse", () => { const result = processTimelineResponse({ ...baseTimelineInput, - currentTail: [optimistic], + currentTail: [submitted], currentCursor: existingCursor, payload: { ...baseTimelineInput.payload, @@ -1672,8 +1803,8 @@ describe("processTimelineResponse", () => { const userMessages = result.tail.filter((item) => item.kind === "user_message"); expect(userMessages).toHaveLength(2); - expect(userMessages.map((item) => item.id)).toEqual(["canonical-before", "optimistic-active"]); - expect(userMessages[1]?.optimistic).toBe(true); + expect(userMessages.map((item) => item.id)).toEqual(["canonical-before", "submitted-active"]); + expect(userMessages[1]?.clientMessageId).toBe("submitted-active"); }); it("leaves the cursor alone when a before page makes no progress", () => { @@ -1813,6 +1944,68 @@ describe("processTimelineResponse", () => { ]); }); + it("removes a reconciled submitted prompt before coalescing a tool call at the pagination seam", () => { + const clientMessageId = "client-boundary-prompt"; + const callId = "toolu_submitted_boundary"; + const currentTail = [ + makeSubmittedUserMessage("Inspect the file", clientMessageId), + ...hydrateStreamState( + [ + { + event: { + type: "timeline", + provider: "claude", + item: makeToolCallTimelineEntry(3, callId, "completed", { + type: "read", + filePath: "/tmp/example.ts", + }).item, + } as AgentStreamEventPayload, + timestamp: new Date(3000), + }, + ], + { source: "canonical" }, + ), + ]; + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail, + currentCursor: { epoch: "epoch-1", startSeq: 3, endSeq: 5 }, + payload: { + ...baseTimelineInput.payload, + direction: "before", + epoch: "epoch-1", + startCursor: { seq: 1 }, + endCursor: { seq: 2 }, + entries: [ + { + ...makeTimelineEntry(1, "Inspect the file", "user_message"), + item: { + type: "user_message", + text: "Inspect the file", + messageId: "provider-boundary-prompt", + clientMessageId, + }, + }, + makeToolCallTimelineEntry(2, callId, "running", { + type: "unknown", + input: { file_path: "/tmp/example.ts" }, + output: null, + }), + ], + }, + }); + + expect(result.tail.filter((item) => item.kind === "user_message")).toHaveLength(1); + expect(getAgentToolCalls(result.tail)).toEqual([ + expect.objectContaining({ + payload: expect.objectContaining({ + data: expect.objectContaining({ callId, status: "completed" }), + }), + }), + ]); + }); + it("does not coalesce tool call lifecycle rows away from the prepend boundary", () => { const callId = "toolu_not_boundary"; const currentTail = hydrateStreamState( @@ -2224,152 +2417,6 @@ describe("processAgentStreamEvent", () => { endSeq: 1, }); }); - - it("derives optimistic idle status on turn_completed for running agent", () => { - const turnCompletedEvent: AgentStreamEventPayload = { - type: "turn_completed", - provider: "claude", - }; - - const result = processAgentStreamEvent({ - ...baseStreamInput, - event: turnCompletedEvent, - currentAgent: { - status: "running", - updatedAt: new Date(1000), - lastActivityAt: new Date(1000), - }, - timestamp: new Date(2000), - }); - - expect(result.agentChanged).toBe(true); - expect(result.agent).not.toBe(null); - expect(result.agent!.status).toBe("idle"); - expect(result.agent!.updatedAt.getTime()).toBe(2000); - expect(result.agent!.lastActivityAt.getTime()).toBe(2000); - }); - - it("derives optimistic error status on turn_failed for running agent", () => { - const turnFailedEvent: AgentStreamEventPayload = { - type: "turn_failed", - provider: "claude", - error: "something broke", - }; - - const result = processAgentStreamEvent({ - ...baseStreamInput, - event: turnFailedEvent, - currentAgent: { - status: "running", - updatedAt: new Date(1000), - lastActivityAt: new Date(1000), - }, - timestamp: new Date(2000), - }); - - expect(result.agentChanged).toBe(true); - expect(result.agent!.status).toBe("error"); - }); - - it("does not derive optimistic idle status on turn_canceled for running agent", () => { - const turnCanceledEvent: AgentStreamEventPayload = { - type: "turn_canceled", - provider: "codex", - reason: "interrupted", - }; - - const result = processAgentStreamEvent({ - ...baseStreamInput, - event: turnCanceledEvent, - currentAgent: { - status: "running", - updatedAt: new Date(1000), - lastActivityAt: new Date(1000), - }, - timestamp: new Date(2000), - }); - - expect(result.agentChanged).toBe(false); - expect(result.agent).toBe(null); - }); - - it("does not change agent when status is not running", () => { - const turnCompletedEvent: AgentStreamEventPayload = { - type: "turn_completed", - provider: "claude", - }; - - const result = processAgentStreamEvent({ - ...baseStreamInput, - event: turnCompletedEvent, - currentAgent: { - status: "idle", - updatedAt: new Date(1000), - lastActivityAt: new Date(1000), - }, - timestamp: new Date(2000), - }); - - expect(result.agentChanged).toBe(false); - expect(result.agent).toBe(null); - }); - - it("does not change agent when no agent is provided", () => { - const turnCompletedEvent: AgentStreamEventPayload = { - type: "turn_completed", - provider: "claude", - }; - - const result = processAgentStreamEvent({ - ...baseStreamInput, - event: turnCompletedEvent, - currentAgent: null, - timestamp: new Date(2000), - }); - - expect(result.agentChanged).toBe(false); - expect(result.agent).toBe(null); - }); - - it("preserves updatedAt when agent timestamp is newer than event", () => { - const turnCompletedEvent: AgentStreamEventPayload = { - type: "turn_completed", - provider: "claude", - }; - - const result = processAgentStreamEvent({ - ...baseStreamInput, - event: turnCompletedEvent, - currentAgent: { - status: "running", - updatedAt: new Date(5000), - lastActivityAt: new Date(5000), - }, - timestamp: new Date(2000), - }); - - expect(result.agentChanged).toBe(true); - expect(result.agent!.updatedAt.getTime()).toBe(5000); - expect(result.agent!.lastActivityAt.getTime()).toBe(5000); - }); - - it("does not produce agent patch for non-terminal events", () => { - const result = processAgentStreamEvent({ - ...baseStreamInput, - event: makeTimelineEvent("just text"), - currentAgent: { - status: "running", - updatedAt: new Date(1000), - lastActivityAt: new Date(1000), - }, - seq: 1, - epoch: "epoch-1", - timestamp: new Date(2000), - }); - - expect(result.agentChanged).toBe(false); - expect(result.agent).toBe(null); - }); }); describe("processAgentStreamEvents", () => { @@ -2382,7 +2429,6 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); expect(result.changedTail).toBe(false); @@ -2410,7 +2456,6 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); expect(result.changedTail).toBe(false); @@ -2433,7 +2478,6 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); expect(result.changedTail).toBe(true); @@ -2451,7 +2495,6 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); expect(result.changedTail).toBe(true); @@ -2469,7 +2512,6 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); expect(result.changedTail).toBe(true); @@ -2501,7 +2543,6 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); expect(result.changedTail).toBe(true); @@ -2527,7 +2568,6 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); expect(result.changedTail).toBe(true); @@ -2552,7 +2592,6 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); expect(result.changedTail).toBe(true); @@ -2644,7 +2683,6 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); expect(getAssistantTexts([...result.tail, ...result.head])).toEqual([ @@ -2655,7 +2693,7 @@ describe("processAgentStreamEvents", () => { ]); }); - it("returns the final optimistic lifecycle patch across a batch", () => { + it("does not derive lifecycle state from a terminal event in a batch", () => { const result = processAgentStreamEvents({ events: [ makeStreamReducerEvent(makeTimelineEvent("Done"), 1), @@ -2669,21 +2707,10 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: { - status: "running", - updatedAt: new Date(1000), - lastActivityAt: new Date(1000), - }, }); expect(result.head).toEqual([]); expect(result.tail).toHaveLength(1); - expect(result.agentChanged).toBe(true); - expect(result.agent).toMatchObject({ - status: "idle", - updatedAt: new Date(3000), - lastActivityAt: new Date(3000), - }); }); it("keeps a live Claude assistant paragraph contiguous when init tail hydration lands mid-stream", () => { @@ -2832,7 +2859,6 @@ describe("createAgentStreamReducerQueue", () => { currentTail, currentHead, currentCursor: undefined, - currentAgent: null, }), commit: (agentId, result) => { currentTail = result.tail; @@ -2874,7 +2900,6 @@ describe("createAgentStreamReducerQueue", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }), commit: (agentId, result) => { commits.push( @@ -2904,7 +2929,6 @@ describe("createAgentStreamReducerQueue", () => { currentTail, currentHead, currentCursor, - currentAgent: null, }), commit: (_agentId, result) => { currentTail = result.tail; @@ -2954,7 +2978,6 @@ describe("createAgentStreamReducerQueue", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }), commit: (agentId, result) => { commits.push( diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index 7458e855d..2690bfb50 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -1,15 +1,15 @@ import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; -import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle"; -import type { Agent } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store"; -import type { AssistantMessageItem, StreamItem, UserMessageItem } from "@/types/stream"; +import type { AssistantMessageItem, StreamItem } from "@/types/stream"; import { applyStreamEvent, flushHeadToTail, hydrateStreamState, isAgentToolCallItem, mergeAgentToolCallItem, + replaceWithCanonicalStream, reduceStreamUpdate, + upsertUserMessageAcrossStream, } from "@/types/stream"; const AGENT_STREAM_REDUCER_FLUSH_DELAY_MS = 16 * 3; @@ -88,6 +88,7 @@ export interface ProcessTimelineResponseInput { isInitializing: boolean; hasActiveInitDeferred: boolean; initRequestDirection: InitRequestDirection; + sendingClientMessageIds: readonly string[]; } export interface ProcessTimelineResponseOutput { @@ -99,6 +100,7 @@ export interface ProcessTimelineResponseOutput { clearInitializing: boolean; error: string | null; sideEffects: TimelineReducerSideEffect[]; + acknowledgedClientMessageIds: string[]; } interface TimelineUnit { @@ -115,6 +117,7 @@ interface TimelinePathResult { cursor: TimelineCursor | null | undefined; cursorChanged: boolean; sideEffects: TimelineReducerSideEffect[]; + acknowledgedClientMessageIds: string[]; } function classifySessionTimelineSeq({ @@ -201,76 +204,35 @@ function shouldResolveTimelineInit({ return responseDirection === initRequestDirection; } -function deriveOptimisticLifecycleStatus( - currentStatus: AgentLifecycleStatus, - event: AgentStreamEventPayload, -): AgentLifecycleStatus | null { - if (currentStatus !== "running") { - return null; - } - switch (event.type) { - case "turn_completed": - return "idle"; - case "turn_failed": - return "error"; - case "turn_canceled": - // A canceled turn can be either a final user cancel or an interrupt before - // a replacement turn starts. The daemon snapshot is authoritative here. - return null; - default: - return null; - } -} - -function preserveReplacePathAssistantHead(params: { - tail: StreamItem[]; - currentHead: StreamItem[]; -}): { - tail: StreamItem[]; - head: StreamItem[]; -} { - const { tail, currentHead } = params; - const liveAssistant = currentHead.findLast( - (item): item is Extract => - item.kind === "assistant_message", - ); - if (!liveAssistant) { - return { tail, head: [] }; - } - const tailAssistant = tail.at(-1); - if (!tailAssistant || tailAssistant.kind !== "assistant_message") { - return { tail, head: currentHead }; - } - if (!liveAssistant.text.startsWith(tailAssistant.text)) { - return { tail, head: [] }; - } - return { - tail: tail.slice(0, -1), - head: [{ ...liveAssistant, text: tailAssistant.text }], - }; -} - function applyTimelineReplacePath(args: { timelineUnits: TimelineUnit[]; payload: ProcessTimelineResponseInput["payload"]; bootstrapPolicy: ReturnType; currentTail: StreamItem[]; currentHead: StreamItem[]; + sendingClientMessageIds: readonly string[]; + preserveLiveHead: boolean; toHydratedEvents: ( units: TimelineUnit[], ) => Array<{ event: AgentStreamEventPayload; timestamp: Date }>; }): TimelinePathResult { - const { timelineUnits, payload, bootstrapPolicy, currentTail, currentHead, toHydratedEvents } = - args; + const { + timelineUnits, + payload, + bootstrapPolicy, + currentTail, + currentHead, + sendingClientMessageIds, + preserveLiveHead, + toHydratedEvents, + } = args; const hydratedTail = hydrateStreamState(toHydratedEvents(timelineUnits), { source: "canonical" }); - const reconciledTail = reconcileLocalUserPresentationAfterReplace({ - canonicalTail: hydratedTail, + const { tail, head, acknowledgedClientMessageIds } = replaceWithCanonicalStream({ + canonical: hydratedTail, previousTail: currentTail, previousHead: currentHead, - }); - const { tail, head } = preserveReplacePathAssistantHead({ - tail: reconciledTail, - currentHead, + sendingClientMessageIds, + preserveLiveHead, }); const cursor: TimelineCursor | null = payload.startCursor && payload.endCursor @@ -284,136 +246,16 @@ function applyTimelineReplacePath(args: { if (bootstrapPolicy.catchUpCursor) { sideEffects.push({ type: "catch_up", cursor: bootstrapPolicy.catchUpCursor }); } - return { tail, head, cursor, cursorChanged: true, sideEffects }; -} - -function collectLocallyPresentedUserMessages(items: StreamItem[]): Array<{ - ordinal: number; - item: UserMessageItem; -}> { - const localUsers: Array<{ ordinal: number; item: UserMessageItem }> = []; - let ordinal = 0; - for (const item of items) { - if (item.kind !== "user_message") { - continue; - } - if (item.optimistic || item.images?.length || item.attachments?.length) { - localUsers.push({ ordinal, item }); - } - ordinal += 1; - } - return localUsers; -} - -function mergeCanonicalUserWithLocalPresentation( - canonical: UserMessageItem, - local: UserMessageItem, -): UserMessageItem { return { - kind: "user_message", - id: canonical.id, - ...(canonical.clientMessageId ? { clientMessageId: canonical.clientMessageId } : {}), - text: local.text, - timestamp: local.timestamp, - ...(local.images && local.images.length > 0 ? { images: local.images } : {}), - ...(local.attachments && local.attachments.length > 0 - ? { attachments: local.attachments } - : {}), + tail, + head, + cursor, + cursorChanged: true, + sideEffects, + acknowledgedClientMessageIds, }; } -interface CanonicalUserMessageIdentity { - messageId?: string; - clientMessageId?: string; - text: string; -} - -function matchesLocalUserMessageIdentity( - canonical: CanonicalUserMessageIdentity, - optimistic: UserMessageItem, -): boolean { - if (canonical.clientMessageId !== undefined) { - return canonical.clientMessageId === optimistic.id; - } - if (canonical.messageId === optimistic.id) { - return true; - } - // COMPAT(userMessageClientId): added in v0.2.0, remove after 2027-01-20 once - // the supported daemon floor emits clientMessageId on submitted user messages. - return canonical.text.length > 0 && canonical.text === optimistic.text; -} - -function reconcileLocalUserPresentationAfterReplace(params: { - canonicalTail: StreamItem[]; - previousTail: StreamItem[]; - previousHead: StreamItem[]; -}): StreamItem[] { - const localUsers = collectLocallyPresentedUserMessages([ - ...params.previousTail, - ...params.previousHead, - ]); - if (localUsers.length === 0) { - return params.canonicalTail; - } - - const canonicalUserIndexes: number[] = []; - params.canonicalTail.forEach((item, index) => { - if (item.kind === "user_message") { - canonicalUserIndexes.push(index); - } - }); - - const nextTail = [...params.canonicalTail]; - const claimedCanonicalIndexes = new Set(); - const unmatched: UserMessageItem[] = []; - - for (const local of localUsers) { - const exactIndex = canonicalUserIndexes.find((index) => { - if (claimedCanonicalIndexes.has(index)) return false; - const canonical = params.canonicalTail[index]; - return ( - canonical?.kind === "user_message" && - matchesLocalUserMessageIdentity( - { - messageId: canonical.id, - clientMessageId: canonical.clientMessageId, - text: canonical.text, - }, - local.item, - ) - ); - }); - const ordinalIndex = canonicalUserIndexes[local.ordinal]; - const ordinalItem = ordinalIndex === undefined ? undefined : params.canonicalTail[ordinalIndex]; - const canonicalIndex = - exactIndex ?? - (ordinalIndex !== undefined && - !claimedCanonicalIndexes.has(ordinalIndex) && - ordinalItem?.kind === "user_message" && - ordinalItem.clientMessageId === undefined - ? ordinalIndex - : undefined); - const canonicalItem = canonicalIndex === undefined ? undefined : nextTail[canonicalIndex]; - if (canonicalIndex === undefined || !canonicalItem || canonicalItem.kind !== "user_message") { - if (local.item.optimistic) { - unmatched.push(local.item); - } - continue; - } - nextTail[canonicalIndex] = mergeCanonicalUserWithLocalPresentation(canonicalItem, local.item); - claimedCanonicalIndexes.add(canonicalIndex); - } - - for (const item of unmatched) { - const insertionIndex = nextTail.findIndex( - (canonical) => canonical.timestamp.getTime() > item.timestamp.getTime(), - ); - nextTail.splice(insertionIndex < 0 ? nextTail.length : insertionIndex, 0, item); - } - - return nextTail; -} - interface IncrementalAcceptResult { acceptedUnits: TimelineUnit[]; cursor: TimelineCursor | undefined; @@ -515,6 +357,34 @@ function mergePrependedCanonicalTail(olderTail: StreamItem[], currentTail: Strea return olderTail; } + const remainingOlder: StreamItem[] = []; + let reconciledCurrent = currentTail; + for (const item of olderTail) { + if (item.kind !== "user_message") { + remainingOlder.push(item); + continue; + } + const result = upsertUserMessageAcrossStream({ + tail: reconciledCurrent, + head: [], + message: item, + insert: "prepend-tail", + presentation: "existing", + }); + if (result.location?.matched) { + remainingOlder.push(result.location.message); + reconciledCurrent = [ + ...result.tail.slice(0, result.location.index), + ...result.tail.slice(result.location.index + 1), + ]; + } else { + remainingOlder.push(item); + } + } + olderTail = remainingOlder; + currentTail = reconciledCurrent; + if (olderTail.length === 0) return currentTail; + const olderLast = olderTail.at(-1); const currentFirst = currentTail[0]; @@ -745,9 +615,24 @@ function applyCanonicalForwardUnit(params: { head: StreamItem[]; unit: TimelineUnit; epoch: string; -}): { tail: StreamItem[]; head: StreamItem[] } { +}): { tail: StreamItem[]; head: StreamItem[]; acknowledgedClientMessageIds: string[] } { const { event, timestamp, seqEnd } = params.unit; const timelineCursor = { epoch: params.epoch, seq: seqEnd }; + if (event.type === "timeline" && event.item.type === "user_message") { + const applied = applyStreamEvent({ + tail: params.tail, + head: params.head, + event, + timestamp, + source: "canonical", + timelineCursor, + }); + return { + tail: applied.tail, + head: applied.head, + acknowledgedClientMessageIds: applied.acknowledgedClientMessageIds ?? [], + }; + } if (params.head.length === 0) { return { tail: reduceStreamUpdate(params.tail, event, timestamp, { @@ -755,6 +640,7 @@ function applyCanonicalForwardUnit(params: { timelineCursor, }), head: params.head, + acknowledgedClientMessageIds: [], }; } const replacedHead = replaceLiveAssistantWithProjectedText({ @@ -763,7 +649,9 @@ function applyCanonicalForwardUnit(params: { timestamp, timelineCursor, }); - if (replacedHead) return { tail: params.tail, head: replacedHead }; + if (replacedHead) { + return { tail: params.tail, head: replacedHead, acknowledgedClientMessageIds: [] }; + } const activeAssistant = params.head.findLast( (item): item is Extract => @@ -781,6 +669,7 @@ function applyCanonicalForwardUnit(params: { source: "canonical", timelineCursor, }), + acknowledgedClientMessageIds: [], }; } @@ -792,7 +681,11 @@ function applyCanonicalForwardUnit(params: { source: "canonical", timelineCursor, }); - return { tail: applied.tail, head: applied.head }; + return { + tail: applied.tail, + head: applied.head, + acknowledgedClientMessageIds: applied.acknowledgedClientMessageIds ?? [], + }; } function applyAcceptedForwardTimelineUnits(params: { @@ -801,7 +694,7 @@ function applyAcceptedForwardTimelineUnits(params: { currentTail: StreamItem[]; currentHead: StreamItem[]; currentEndSeq: number | undefined; -}): { tail: StreamItem[]; head: StreamItem[] } { +}): { tail: StreamItem[]; head: StreamItem[]; acknowledgedClientMessageIds: string[] } { const reconciled = reconcileOverlappingProjectedStreamItems({ tail: params.currentTail, head: params.currentHead, @@ -811,15 +704,19 @@ function applyAcceptedForwardTimelineUnits(params: { }); let tail = reconciled.tail; let head = reconciled.head; + const acknowledgedClientMessageIds = new Set(); for (const unit of params.units) { if (reconciled.reconciledUnits.has(unit)) continue; const applied = applyCanonicalForwardUnit({ tail, head, unit, epoch: params.epoch }); tail = applied.tail; head = applied.head; + for (const clientMessageId of applied.acknowledgedClientMessageIds) { + acknowledgedClientMessageIds.add(clientMessageId); + } } - return { tail, head }; + return { tail, head, acknowledgedClientMessageIds: [...acknowledgedClientMessageIds] }; } function applyTimelineIncrementalPath(args: { @@ -835,9 +732,17 @@ function applyTimelineIncrementalPath(args: { let nextCursor: TimelineCursor | null | undefined = currentCursor; let cursorChanged = false; const sideEffects: TimelineReducerSideEffect[] = []; + let acknowledgedClientMessageIds: string[] = []; if (timelineUnits.length === 0) { - return { tail: nextTail, head: nextHead, cursor: nextCursor, cursorChanged, sideEffects }; + return { + tail: nextTail, + head: nextHead, + cursor: nextCursor, + cursorChanged, + sideEffects, + acknowledgedClientMessageIds, + }; } const { acceptedUnits, cursor, gapCursor } = @@ -874,6 +779,7 @@ function applyTimelineIncrementalPath(args: { }); nextTail = applied.tail; nextHead = applied.head; + acknowledgedClientMessageIds = applied.acknowledgedClientMessageIds; } } @@ -892,7 +798,14 @@ function applyTimelineIncrementalPath(args: { sideEffects.push({ type: "catch_up", cursor: gapCursor }); } - return { tail: nextTail, head: nextHead, cursor: nextCursor, cursorChanged, sideEffects }; + return { + tail: nextTail, + head: nextHead, + cursor: nextCursor, + cursorChanged, + sideEffects, + acknowledgedClientMessageIds, + }; } export function processTimelineResponse( @@ -906,6 +819,7 @@ export function processTimelineResponse( isInitializing, hasActiveInitDeferred, initRequestDirection, + sendingClientMessageIds, } = input; // ------------------------------------------------------------------ @@ -921,6 +835,7 @@ export function processTimelineResponse( clearInitializing: isInitializing, error: payload.error, sideEffects: [], + acknowledgedClientMessageIds: [], }; } @@ -967,7 +882,6 @@ export function processTimelineResponse( hasActiveInitDeferred, }); const replace = bootstrapPolicy.replace; - const sideEffects: TimelineReducerSideEffect[] = []; const timelineResult = replace ? applyTimelineReplacePath({ @@ -976,6 +890,8 @@ export function processTimelineResponse( bootstrapPolicy, currentTail, currentHead, + sendingClientMessageIds, + preserveLiveHead: currentCursor?.epoch === payload.epoch, toHydratedEvents, }) : applyTimelineIncrementalPath({ @@ -1024,6 +940,7 @@ export function processTimelineResponse( clearInitializing, error: null, sideEffects, + acknowledgedClientMessageIds: timelineResult.acknowledgedClientMessageIds, }; } @@ -1038,20 +955,9 @@ export interface ProcessAgentStreamEventInput { currentTail: StreamItem[]; currentHead: StreamItem[]; currentCursor: TimelineCursor | undefined; - currentAgent: { - status: AgentLifecycleStatus; - updatedAt: Date; - lastActivityAt: Date; - } | null; timestamp: Date; } -export interface AgentPatch { - status: AgentLifecycleStatus; - updatedAt: Date; - lastActivityAt: Date; -} - export interface ProcessAgentStreamEventOutput { tail: StreamItem[]; head: StreamItem[]; @@ -1059,8 +965,7 @@ export interface ProcessAgentStreamEventOutput { changedHead: boolean; cursor: TimelineCursor | null; cursorChanged: boolean; - agent: AgentPatch | null; - agentChanged: boolean; + acknowledgedClientMessageIds: string[]; sideEffects: AgentStreamReducerSideEffect[]; } @@ -1079,18 +984,11 @@ interface TimelineSequencingGateResult { sideEffects: AgentStreamReducerSideEffect[]; } -export interface AgentStreamReducerAgentSnapshot { - status: AgentLifecycleStatus; - updatedAt: Date; - lastActivityAt: Date; -} - export interface ProcessAgentStreamEventsInput { events: AgentStreamReducerEvent[]; currentTail: StreamItem[]; currentHead: StreamItem[]; currentCursor: TimelineCursor | undefined; - currentAgent: AgentStreamReducerAgentSnapshot | null; } export type AgentStreamReducerSnapshot = Omit; @@ -1114,20 +1012,6 @@ export interface CreateAgentStreamReducerQueueInput { cancelFlush: (id: number) => void; } -function applyAgentPatch( - currentAgent: AgentStreamReducerAgentSnapshot | null, - patch: AgentPatch | null, -): AgentStreamReducerAgentSnapshot | null { - if (!currentAgent || !patch) { - return currentAgent; - } - return { - status: patch.status, - updatedAt: patch.updatedAt, - lastActivityAt: patch.lastActivityAt, - }; -} - function processTimelineSequencingGate(input: { event: AgentStreamEventPayload; seq: number | undefined; @@ -1201,8 +1085,7 @@ function processTimelineSequencingGate(input: { export function processAgentStreamEvent( input: ProcessAgentStreamEventInput, ): ProcessAgentStreamEventOutput { - const { event, seq, epoch, currentTail, currentHead, currentCursor, currentAgent, timestamp } = - input; + const { event, seq, epoch, currentTail, currentHead, currentCursor, timestamp } = input; const sequencing = processTimelineSequencingGate({ event, seq, epoch, currentCursor }); const timelineCursor = @@ -1213,7 +1096,7 @@ export function processAgentStreamEvent( // ------------------------------------------------------------------ // Apply stream event to tail/head // ------------------------------------------------------------------ - const { tail, head, changedTail, changedHead } = sequencing.shouldApplyStreamEvent + const applied = sequencing.shouldApplyStreamEvent ? applyStreamEvent({ tail: sequencing.resetLiveTimeline ? [] : currentTail, head: sequencing.resetLiveTimeline ? [] : currentHead, @@ -1229,43 +1112,14 @@ export function processAgentStreamEvent( changedHead: false, }; - // ------------------------------------------------------------------ - // Optimistic lifecycle status - // ------------------------------------------------------------------ - let agentPatch: AgentPatch | null = null; - let agentChanged = false; - - if ( - currentAgent && - (event.type === "turn_completed" || - event.type === "turn_canceled" || - event.type === "turn_failed") - ) { - const optimisticStatus = deriveOptimisticLifecycleStatus(currentAgent.status, event); - if (optimisticStatus) { - const nextUpdatedAtMs = Math.max(currentAgent.updatedAt.getTime(), timestamp.getTime()); - const nextLastActivityAtMs = Math.max( - currentAgent.lastActivityAt.getTime(), - timestamp.getTime(), - ); - agentPatch = { - status: optimisticStatus, - updatedAt: new Date(nextUpdatedAtMs), - lastActivityAt: new Date(nextLastActivityAtMs), - }; - agentChanged = true; - } - } - return { - tail, - head, - changedTail, - changedHead, + tail: applied.tail, + head: applied.head, + changedTail: applied.changedTail, + changedHead: applied.changedHead, cursor: sequencing.nextTimelineCursor, cursorChanged: sequencing.cursorChanged, - agent: agentPatch, - agentChanged, + acknowledgedClientMessageIds: applied.acknowledgedClientMessageIds ?? [], sideEffects: sequencing.sideEffects, }; } @@ -1276,12 +1130,10 @@ export function processAgentStreamEvents( let tail = input.currentTail; let head = input.currentHead; let cursor = input.currentCursor; - let agent = input.currentAgent; let changedTail = false; let changedHead = false; let cursorChanged = false; - let agentPatch: AgentPatch | null = null; - let agentChanged = false; + const acknowledgedClientMessageIds = new Set(); const sideEffects: AgentStreamReducerSideEffect[] = []; for (const reducerEvent of input.events) { @@ -1292,7 +1144,6 @@ export function processAgentStreamEvents( currentTail: tail, currentHead: head, currentCursor: cursor, - currentAgent: agent, timestamp: reducerEvent.timestamp, }); @@ -1301,17 +1152,14 @@ export function processAgentStreamEvents( changedTail = changedTail || result.changedTail; changedHead = changedHead || result.changedHead; sideEffects.push(...result.sideEffects); + for (const clientMessageId of result.acknowledgedClientMessageIds) { + acknowledgedClientMessageIds.add(clientMessageId); + } if (result.cursorChanged) { cursor = result.cursor ?? undefined; cursorChanged = true; } - - if (result.agentChanged) { - agentPatch = result.agent; - agentChanged = true; - agent = applyAgentPatch(agent, result.agent); - } } return { @@ -1321,8 +1169,7 @@ export function processAgentStreamEvents( changedHead, cursor: cursor ?? null, cursorChanged, - agent: agentPatch, - agentChanged, + acknowledgedClientMessageIds: [...acknowledgedClientMessageIds], sideEffects, }; } @@ -1405,6 +1252,7 @@ export function createAgentStreamReducerQueue( interface StreamStatePatch { tail?: StreamItem[]; head?: StreamItem[]; + acknowledgedClientMessageIds?: readonly string[]; } export interface CreateSessionAgentStreamReducerQueueInput { @@ -1414,7 +1262,6 @@ export interface CreateSessionAgentStreamReducerQueueInput { serverId: string, state: (prev: Map) => Map, ) => void; - setAgents: (serverId: string, state: (prev: Map) => Map) => void; recoverTimelineGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void; } @@ -1429,31 +1276,29 @@ function cancelAgentStreamReducerFlush(id: number) { export function createSessionAgentStreamReducerQueue( input: CreateSessionAgentStreamReducerQueueInput, ): AgentStreamReducerQueue { - const { serverId, setAgentStreamState, setAgentTimelineCursor, setAgents, recoverTimelineGap } = - input; + const { serverId, setAgentStreamState, setAgentTimelineCursor, recoverTimelineGap } = input; return createAgentStreamReducerQueue({ getSnapshot: (agentId) => { const session = useSessionStore.getState().sessions[serverId]; - const currentAgentEntry = session?.agents.get(agentId); return { currentTail: session?.agentStreamTail.get(agentId) ?? [], currentHead: session?.agentStreamHead.get(agentId) ?? [], currentCursor: session?.agentTimelineCursor.get(agentId), - currentAgent: currentAgentEntry - ? { - status: currentAgentEntry.status, - updatedAt: currentAgentEntry.updatedAt, - lastActivityAt: currentAgentEntry.lastActivityAt, - } - : null, }; }, commit: (agentId, result, events) => { - if (result.changedTail || result.changedHead) { + if ( + result.changedTail || + result.changedHead || + result.acknowledgedClientMessageIds.length > 0 + ) { setAgentStreamState(serverId, agentId, { ...(result.changedTail ? { tail: result.tail } : {}), ...(result.changedHead ? { head: result.head } : {}), + ...(result.acknowledgedClientMessageIds.length > 0 + ? { acknowledgedClientMessageIds: result.acknowledgedClientMessageIds } + : {}), }); } @@ -1486,24 +1331,6 @@ export function createSessionAgentStreamReducerQueue( return next; }); } - - if (result.agentChanged && result.agent) { - const nextAgent = result.agent; - setAgents(serverId, (prev) => { - const current = prev.get(agentId); - if (!current) { - return prev; - } - const next = new Map(prev); - next.set(agentId, { - ...current, - status: nextAgent.status, - updatedAt: nextAgent.updatedAt, - lastActivityAt: nextAgent.lastActivityAt, - }); - return next; - }); - } }, handleSideEffects: (agentId, sideEffects) => { for (const effect of sideEffects) { diff --git a/packages/app/src/timeline/turn-time.test.ts b/packages/app/src/timeline/turn-time.test.ts index d63d2de7d..3945edbcb 100644 --- a/packages/app/src/timeline/turn-time.test.ts +++ b/packages/app/src/timeline/turn-time.test.ts @@ -22,34 +22,16 @@ function assistant(id: string, timestamp: Date): StreamItem { } describe("deriveStreamTurnTiming", () => { - it("reserves a running footer for an optimistic prompt before the host starts the turn", () => { - const optimisticPrompt = { - ...user("optimistic", new Date("2026-05-15T00:00:00.000Z")), - optimistic: true as const, - }; - - const timing = deriveStreamTurnTiming({ - agentStatus: "idle", - tail: [], - head: [optimisticPrompt], - }); - - assert.equal(timing.isActive, true); - }); - - it("does not start elapsed time from an optimistic prompt", () => { - const optimisticPrompt = { - ...user("optimistic", new Date("2026-05-15T00:00:00.000Z")), - optimistic: true as const, - }; + it("starts elapsed time from the submitted prompt", () => { + const submittedAt = new Date("2026-05-15T00:00:00.000Z"); const timing = deriveStreamTurnTiming({ agentStatus: "running", tail: [], - head: [optimisticPrompt], + head: [user("submitted", submittedAt)], }); - assert.equal(timing.runningStartedAt, null); + assert.equal(timing.runningStartedAt, submittedAt); }); it("uses the last user message as the running turn start", () => { diff --git a/packages/app/src/timeline/turn-time.ts b/packages/app/src/timeline/turn-time.ts index 435694524..7ebd77c21 100644 --- a/packages/app/src/timeline/turn-time.ts +++ b/packages/app/src/timeline/turn-time.ts @@ -9,7 +9,6 @@ export interface TurnTiming { export interface StreamTurnTiming { byAssistantId: Map; runningStartedAt: Date | null; - isActive: boolean; } export function deriveStreamTurnTiming(params: { @@ -19,8 +18,6 @@ export function deriveStreamTurnTiming(params: { }): StreamTurnTiming { const byAssistantId = new Map(); let currentUserAt: Date | null = null; - let currentAuthoritativeUserAt: Date | null = null; - let currentUserIsOptimistic = false; let currentLastItemAt: Date | null = null; let currentAssistantIds: string[] = []; @@ -42,8 +39,6 @@ export function deriveStreamTurnTiming(params: { if (item.kind === "user_message") { flushCompletedTurn(); currentUserAt = item.timestamp; - currentAuthoritativeUserAt = item.optimistic ? null : item.timestamp; - currentUserIsOptimistic = item.optimistic === true; currentLastItemAt = null; currentAssistantIds = []; return; @@ -65,7 +60,7 @@ export function deriveStreamTurnTiming(params: { } const isRunning = params.agentStatus === "running"; - const runningStartedAt = isRunning ? currentAuthoritativeUserAt : null; + const runningStartedAt = isRunning ? currentUserAt : null; if (params.agentStatus !== "running") { flushCompletedTurn(); } @@ -73,6 +68,5 @@ export function deriveStreamTurnTiming(params: { return { byAssistantId, runningStartedAt, - isActive: isRunning || currentUserIsOptimistic, }; } diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index fb68d7a02..00d73b7f6 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -4,9 +4,7 @@ import { describe, expect, it } from "vitest"; import { applyStreamEvent, - appendOptimisticUserMessageToStream, - buildOptimisticUserMessage, - clearOptimisticUserMessages, + createUserMessage, handoffCreatedAgentUserMessageToStream, hydrateStreamState, mergeToolCallDetail, @@ -14,6 +12,8 @@ import { type AgentToolCallItem, type StreamItem, isAgentToolCallItem, + upsertUserMessage, + upsertUserMessageAcrossStream, } from "./stream"; import type { AgentProvider, ToolCallDetail } from "@getpaseo/protocol/agent-types"; import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; @@ -21,6 +21,109 @@ import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display" type CanonicalToolStatus = "running" | "completed" | "failed" | "canceled"; +describe("user message identity", () => { + it("adds provider identity without replacing local presentation", () => { + const timestamp = new Date("2026-07-26T10:00:00.000Z"); + const local = createUserMessage({ + clientMessageId: "client-1", + text: "local text", + timestamp, + images: [ + { + id: "image-1", + mimeType: "image/png", + storageType: "web-indexeddb", + storageKey: "image-1.png", + createdAt: timestamp.getTime(), + }, + ], + attachments: [{ type: "text", mimeType: "text/plain", text: "attachment" }], + }); + const canonical = createUserMessage({ + id: "provider-1", + messageId: "provider-1", + clientMessageId: "client-1", + text: "provider text", + timestamp: new Date("2026-07-26T10:00:01.000Z"), + }); + + const first = upsertUserMessage([local], canonical); + const second = upsertUserMessage(first, canonical); + + expect(first).toEqual([ + { + ...local, + messageId: "provider-1", + clientMessageId: "client-1", + }, + ]); + expect(first[0]).toBe(second[0]); + }); + + it("keeps local presentation when a later canonical row omits provider identity", () => { + const timestamp = new Date("2026-07-27T10:00:00.000Z"); + const local = createUserMessage({ + clientMessageId: "client-1", + messageId: "provider-1", + text: "local text", + timestamp, + images: [ + { + id: "image-1", + mimeType: "image/png", + storageType: "web-indexeddb", + storageKey: "image-1.png", + createdAt: timestamp.getTime(), + }, + ], + attachments: [{ type: "text", mimeType: "text/plain", text: "local attachment" }], + }); + const canonicalWithoutProviderIdentity = createUserMessage({ + id: "canonical-page-row", + clientMessageId: "client-1", + text: "provider-shaped text", + timestamp: new Date("2026-07-27T10:00:01.000Z"), + }); + + const result = upsertUserMessage([local], canonicalWithoutProviderIdentity); + + expect(result).toEqual([local]); + }); + + it("matches a submitted message against a legacy canonical row that has no client identity", () => { + // Daemons before v0.2.0 do not echo clientMessageId. During agent creation the + // legacy canonical row can land before the local submission is handed off, so the + // submitted row arrives as `incoming` and must still match by text. + const timestamp = new Date("2026-07-27T11:00:00.000Z"); + const legacyCanonical = createUserMessage({ + id: "provider-1", + messageId: "provider-1", + text: "review this", + timestamp, + }); + const submitted = createUserMessage({ + clientMessageId: "client-1", + text: "review this", + timestamp: new Date("2026-07-27T11:00:01.000Z"), + attachments: [{ type: "text", mimeType: "text/plain", text: "attachment" }], + }); + + const result = handoffCreatedAgentUserMessageToStream({ + tail: [legacyCanonical], + head: [], + message: submitted, + }); + + expect(result.tail).toEqual([ + { + ...submitted, + id: "client-1", + messageId: "provider-1", + }, + ]); + }); +}); + function assistantTimeline( text: string, provider: AgentProvider = "claude", @@ -895,15 +998,15 @@ describe("stream reducer canonical tool calls", () => { assert.strictEqual(todos.items[0]?.text, "Task 1"); }); - it("preserves optimistic user message images when authoritative user message arrives", () => { + it("preserves submitted user message images when authoritative user message arrives", () => { const messageId = "msg-user-images"; - const optimisticTimestamp = new Date("2025-01-01T11:10:00Z"); - const optimisticImages = [ + const submittedTimestamp = new Date("2025-01-01T11:10:00Z"); + const submittedImages = [ { - id: "att-optimistic", + id: "att-submitted", mimeType: "image/jpeg", storageType: "native-file" as const, - storageKey: "/tmp/optimistic.jpg", + storageKey: "/tmp/submitted.jpg", createdAt: Date.now(), }, ]; @@ -911,10 +1014,10 @@ describe("stream reducer canonical tool calls", () => { { kind: "user_message", id: messageId, + clientMessageId: messageId, text: "Analyze this image", - timestamp: optimisticTimestamp, - optimistic: true, - images: optimisticImages, + timestamp: submittedTimestamp, + images: submittedImages, }, ]; const event: AgentStreamEventPayload = { @@ -933,9 +1036,9 @@ describe("stream reducer canonical tool calls", () => { assert.ok(message); assert.strictEqual(message.id, messageId); - assert.deepStrictEqual(message.images, optimisticImages); + assert.deepStrictEqual(message.images, submittedImages); assert.strictEqual(message.text, "Analyze this image"); - assert.strictEqual(message.timestamp.getTime(), optimisticTimestamp.getTime()); + assert.strictEqual(message.timestamp.getTime(), submittedTimestamp.getTime()); }); it("keeps canonical assistant/user/assistant order during replay", () => { @@ -981,7 +1084,7 @@ describe("stream reducer canonical tool calls", () => { ); }); - it("keeps live optimistic assistant merge behavior", () => { + it("keeps live submitted assistant merge behavior", () => { const state: StreamItem[] = [ { kind: "assistant_message", @@ -1117,23 +1220,23 @@ describe("turn lifecycle events", () => { }); it.each(["codex", "opencode", "pi"] satisfies AgentProvider[])( - "replaces an optimistic user message when a live %s provider-owned id echo arrives without text matching", + "replaces a submitted user message when a live %s provider-owned id echo arrives without text matching", (provider) => { - const optimisticTimestamp = new Date("2025-01-01T15:02:00Z"); + const submittedTimestamp = new Date("2025-01-01T15:02:00Z"); const serverTimestamp = new Date("2025-01-01T15:02:01Z"); - const optimistic: StreamItem = { + const submitted: StreamItem = { kind: "user_message", - id: "msg_optimistic", + id: "msg_submitted", + clientMessageId: "msg_submitted", text: "same user text", - timestamp: optimisticTimestamp, - optimistic: true, + timestamp: submittedTimestamp, images: [ { id: "image-1", mimeType: "image/png", storageType: "web-indexeddb", storageKey: "image-1", - createdAt: optimisticTimestamp.getTime(), + createdAt: submittedTimestamp.getTime(), }, ], attachments: [ @@ -1147,7 +1250,7 @@ describe("turn lifecycle events", () => { }; const state = reduceStreamUpdate( - [optimistic], + [submitted], { type: "timeline", provider, @@ -1155,6 +1258,7 @@ describe("turn lifecycle events", () => { type: "user_message", text: "server-owned rendered text", messageId: "provider-owned-id", + clientMessageId: "msg_submitted", }, }, serverTimestamp, @@ -1165,28 +1269,28 @@ describe("turn lifecycle events", () => { assert.strictEqual(userMessages.length, 1); const userMessage = userMessages[0]; invariant(userMessage?.kind === "user_message"); - assert.strictEqual(userMessage.id, "provider-owned-id"); - assert.strictEqual(userMessage.text, optimistic.text); - assert.strictEqual(userMessage.timestamp.getTime(), optimistic.timestamp.getTime()); - assert.strictEqual(userMessage.optimistic, undefined); - assert.deepStrictEqual(userMessage.images, optimistic.images); - assert.deepStrictEqual(userMessage.attachments, optimistic.attachments); + assert.strictEqual(userMessage.id, "msg_submitted"); + assert.strictEqual(userMessage.messageId, "provider-owned-id"); + assert.strictEqual(userMessage.text, submitted.text); + assert.strictEqual(userMessage.timestamp.getTime(), submitted.timestamp.getTime()); + assert.deepStrictEqual(userMessage.images, submitted.images); + assert.deepStrictEqual(userMessage.attachments, submitted.attachments); }, ); - it("replaces one optimistic plain-text user message with the next live server user message", () => { - const optimisticTimestamp = new Date("2025-01-01T15:03:00Z"); + it("replaces one submitted plain-text user message with the next live server user message", () => { + const submittedTimestamp = new Date("2025-01-01T15:03:00Z"); const serverTimestamp = new Date("2025-01-01T15:03:01Z"); - const optimistic: StreamItem = { + const submitted: StreamItem = { kind: "user_message", - id: "msg_optimistic", + id: "msg_submitted", + clientMessageId: "msg_submitted", text: "typed plain text", - timestamp: optimisticTimestamp, - optimistic: true, + timestamp: submittedTimestamp, }; const state = reduceStreamUpdate( - [optimistic], + [submitted], { type: "timeline", provider: "opencode", @@ -1204,20 +1308,20 @@ describe("turn lifecycle events", () => { assert.strictEqual(userMessages.length, 1); const userMessage = userMessages[0]; invariant(userMessage?.kind === "user_message"); - assert.strictEqual(userMessage.id, "msg_opencode_provider_owned"); + assert.strictEqual(userMessage.id, "msg_submitted"); + assert.strictEqual(userMessage.messageId, "msg_opencode_provider_owned"); assert.strictEqual(userMessage.text, "typed plain text"); - assert.strictEqual(userMessage.timestamp.getTime(), optimisticTimestamp.getTime()); - assert.strictEqual(userMessage.optimistic, undefined); + assert.strictEqual(userMessage.timestamp.getTime(), submittedTimestamp.getTime()); }); - it("replaces an optimistic image user message with the next canonical server user message", () => { - const optimisticTimestamp = new Date("2025-01-01T15:03:10Z"); + it("replaces a submitted image user message with the next canonical server user message", () => { + const submittedTimestamp = new Date("2025-01-01T15:03:10Z"); const image = { id: "image-canonical", mimeType: "image/png", storageType: "web-indexeddb" as const, storageKey: "image-canonical", - createdAt: optimisticTimestamp.getTime(), + createdAt: submittedTimestamp.getTime(), }; const attachment = { type: "text" as const, @@ -1225,16 +1329,16 @@ describe("turn lifecycle events", () => { text: "context", title: "context.txt", }; - const optimistic = buildOptimisticUserMessage({ - id: "msg_optimistic_canonical", + const submitted = createUserMessage({ + clientMessageId: "msg_submitted_canonical", text: "Analyze this", - timestamp: optimisticTimestamp, + timestamp: submittedTimestamp, images: [image], attachments: [attachment], }); const state = reduceStreamUpdate( - [optimistic], + [submitted], { type: "timeline", provider: "claude", @@ -1242,7 +1346,7 @@ describe("turn lifecycle events", () => { type: "user_message", text: "server-rendered attachment text", messageId: "provider-owned-canonical", - clientMessageId: optimistic.id, + clientMessageId: submitted.id, }, }, new Date("2025-01-01T15:03:11Z"), @@ -1253,17 +1357,17 @@ describe("turn lifecycle events", () => { assert.strictEqual(userMessages.length, 1); const userMessage = userMessages[0]; invariant(userMessage?.kind === "user_message"); - assert.strictEqual(userMessage.id, "provider-owned-canonical"); + assert.strictEqual(userMessage.id, "msg_submitted_canonical"); + assert.strictEqual(userMessage.messageId, "provider-owned-canonical"); assert.strictEqual(userMessage.text, "Analyze this"); - assert.strictEqual(userMessage.timestamp.getTime(), optimisticTimestamp.getTime()); - assert.strictEqual(userMessage.optimistic, undefined); + assert.strictEqual(userMessage.timestamp.getTime(), submittedTimestamp.getTime()); assert.deepStrictEqual(userMessage.images, [image]); assert.deepStrictEqual(userMessage.attachments, [attachment]); }); - it("places optimistic user messages through one append helper", () => { - const optimistic = buildOptimisticUserMessage({ - id: "msg_append_once", + it("places submitted user messages through the identity producer", () => { + const submitted = createUserMessage({ + clientMessageId: "msg_append_once", text: "append once", timestamp: new Date("2025-01-01T15:03:20Z"), }); @@ -1274,28 +1378,30 @@ describe("turn lifecycle events", () => { timestamp: new Date("2025-01-01T15:03:19Z"), }; - const first = appendOptimisticUserMessageToStream({ + const first = upsertUserMessageAcrossStream({ tail: [], head: [headItem], - message: optimistic, - placement: "active-head", + message: submitted, + insert: "head", + presentation: "existing", }); - const second = appendOptimisticUserMessageToStream({ + const second = upsertUserMessageAcrossStream({ tail: first.tail, head: first.head, - message: optimistic, - placement: "active-head", + message: submitted, + insert: "head", + presentation: "existing", }); assert.deepStrictEqual(first.tail, []); - assert.deepStrictEqual(first.head, [headItem, optimistic]); + assert.deepStrictEqual(first.head, [headItem, submitted]); assert.strictEqual(second.changedHead, false); assert.strictEqual(second.head, first.head); }); - it("hands rich optimistic content to an authoritative create message without duplicating it", () => { + it("hands rich submitted content to its create message without overwriting an earlier user row", () => { const timestamp = new Date("2025-01-01T15:03:20Z"); - const optimistic = buildOptimisticUserMessage({ - id: "client-user", + const submitted = createUserMessage({ + clientMessageId: "client-user", text: "", timestamp, images: [ @@ -1317,32 +1423,44 @@ describe("turn lifecycle events", () => { }, ], }); + const precedingProviderRow: StreamItem = { + kind: "user_message", + id: "provider-system-user", + messageId: "provider-system-user", + text: "provider setup prompt", + timestamp: new Date("2025-01-01T15:03:20.500Z"), + }; const canonical: StreamItem = { kind: "user_message", id: "provider-user", + messageId: "provider-user", + clientMessageId: "client-user", text: "server-rendered attachment text", timestamp: new Date("2025-01-01T15:03:21Z"), }; const handedOff = handoffCreatedAgentUserMessageToStream({ - tail: [canonical], + tail: [precedingProviderRow, canonical], head: [], - message: optimistic, + message: submitted, }); const repeated = handoffCreatedAgentUserMessageToStream({ tail: handedOff.tail, head: handedOff.head, - message: optimistic, + message: submitted, }); assert.deepStrictEqual(handedOff.tail, [ + precedingProviderRow, { kind: "user_message", - id: "provider-user", - text: optimistic.text, - timestamp: optimistic.timestamp, - images: optimistic.images, - attachments: optimistic.attachments, + id: "client-user", + clientMessageId: "client-user", + messageId: "provider-user", + text: submitted.text, + timestamp: submitted.timestamp, + images: submitted.images, + attachments: submitted.attachments, }, ]); assert.deepStrictEqual(handedOff.head, []); @@ -1364,22 +1482,22 @@ describe("turn lifecycle events", () => { ); assert.deepStrictEqual( afterNextUser.filter((item) => item.kind === "user_message").map((item) => item.id), - ["provider-user", "provider-next-user"], + ["provider-system-user", "client-user", "provider-next-user"], ); }); - it("reconciles an optimistic user message that was pending in the streaming head", () => { - const optimistic: StreamItem = { + it("flushes an interrupted head when its submitted prompt becomes canonical", () => { + const submitted: StreamItem = { kind: "user_message", - id: "msg_head_optimistic", + id: "msg_head_submitted", + clientMessageId: "msg_head_submitted", text: "plain text in head", timestamp: new Date("2025-01-01T15:03:02Z"), - optimistic: true, }; const result = applyStreamEvent({ tail: [], - head: [optimistic], + head: [submitted], event: { type: "timeline", provider: "opencode", @@ -1393,33 +1511,80 @@ describe("turn lifecycle events", () => { source: "live", }); - assert.strictEqual(result.head.length, 0); + assert.deepStrictEqual(result.head, []); const userMessages = result.tail.filter((item) => item.kind === "user_message"); assert.strictEqual(userMessages.length, 1); - assert.strictEqual(userMessages[0]?.id, "provider-owned-head"); - assert.strictEqual(userMessages[0]?.optimistic, undefined); + assert.strictEqual(userMessages[0]?.id, "msg_head_submitted"); + assert.strictEqual(userMessages[0]?.messageId, "provider-owned-head"); }); - it("replaces multiple optimistic user messages in FIFO order", () => { - const optimisticTimestamp = new Date("2025-01-01T15:04:00Z"); - const serverTimestamp = new Date("2025-01-01T15:04:01Z"); - const firstOptimistic: StreamItem = { - kind: "user_message", - id: "msg_optimistic_1", - text: "first typed text", - timestamp: optimisticTimestamp, - optimistic: true, + it("keeps a replacement assistant separate after an interrupted prompt is reconciled", () => { + const interruptedAssistant: StreamItem = { + kind: "assistant_message", + id: "interrupted", + text: "old answer", + timestamp: new Date("2025-01-01T15:03:01Z"), }; - const secondOptimistic: StreamItem = { + const submitted = createUserMessage({ + clientMessageId: "msg_interrupt", + text: "replacement prompt", + timestamp: new Date("2025-01-01T15:03:02Z"), + }); + const reconciled = applyStreamEvent({ + tail: [], + head: [interruptedAssistant, submitted], + event: { + type: "timeline", + provider: "opencode", + item: { + type: "user_message", + text: submitted.text, + messageId: "provider-prompt", + clientMessageId: submitted.clientMessageId, + }, + }, + timestamp: new Date("2025-01-01T15:03:03Z"), + }); + const replacement = applyStreamEvent({ + tail: reconciled.tail, + head: reconciled.head, + event: { + type: "timeline", + provider: "opencode", + item: { type: "assistant_message", text: "new answer" }, + }, + timestamp: new Date("2025-01-01T15:03:04Z"), + }); + + expect(replacement.tail.map((item) => item.kind)).toEqual([ + "assistant_message", + "user_message", + ]); + expect(replacement.head).toEqual([ + expect.objectContaining({ kind: "assistant_message", text: "new answer" }), + ]); + }); + + it("replaces multiple submitted user messages in FIFO order", () => { + const submittedTimestamp = new Date("2025-01-01T15:04:00Z"); + const serverTimestamp = new Date("2025-01-01T15:04:01Z"); + const firstSubmitted: StreamItem = { kind: "user_message", - id: "msg_optimistic_2", + id: "msg_submitted_1", + clientMessageId: "msg_submitted_1", + text: "first typed text", + timestamp: submittedTimestamp, + }; + const secondSubmitted: StreamItem = { + kind: "user_message", + id: "msg_submitted_2", + clientMessageId: "msg_submitted_2", text: "second typed text", timestamp: new Date("2025-01-01T15:04:00.500Z"), - optimistic: true, }; const afterFirstEcho = reduceStreamUpdate( - [firstOptimistic, secondOptimistic], + [firstSubmitted, secondSubmitted], { type: "timeline", provider: "opencode", @@ -1427,6 +1592,7 @@ describe("turn lifecycle events", () => { type: "user_message", text: "first server text", messageId: "provider-owned-first", + clientMessageId: "msg_submitted_1", }, }, serverTimestamp, @@ -1441,6 +1607,7 @@ describe("turn lifecycle events", () => { type: "user_message", text: "second server text", messageId: "provider-owned-second", + clientMessageId: "msg_submitted_2", }, }, new Date("2025-01-01T15:04:02Z"), @@ -1450,30 +1617,30 @@ describe("turn lifecycle events", () => { const userMessages = state.filter((item) => item.kind === "user_message"); assert.strictEqual(userMessages.length, 2); assert.deepStrictEqual( - userMessages.map((item) => [item.id, item.text, item.optimistic]), + userMessages.map((item) => [item.id, item.text, item.messageId]), [ - ["provider-owned-first", "first typed text", undefined], - ["provider-owned-second", "second typed text", undefined], + ["msg_submitted_1", "first typed text", "provider-owned-first"], + ["msg_submitted_2", "second typed text", "provider-owned-second"], ], ); }); - it("does not shift later prompts when an earlier optimistic prompt has no canonical echo", () => { + it("does not shift later prompts when an earlier submitted prompt has no canonical echo", () => { const staleTimestamp = new Date("2025-01-01T15:04:00Z"); const submittedTimestamp = new Date("2025-01-01T15:04:01Z"); const stalePrompt: StreamItem = { kind: "user_message", id: "msg_stale", + clientMessageId: "msg_stale", text: "first prompt without an echo", timestamp: staleTimestamp, - optimistic: true, }; const submittedPrompt: StreamItem = { kind: "user_message", id: "msg_submitted", + clientMessageId: "msg_submitted", text: "later submitted prompt", timestamp: submittedTimestamp, - optimistic: true, }; const state = reduceStreamUpdate( @@ -1496,15 +1663,16 @@ describe("turn lifecycle events", () => { stalePrompt, { kind: "user_message", - id: "provider-owned-submitted", + id: "msg_submitted", clientMessageId: submittedPrompt.id, + messageId: "provider-owned-submitted", text: submittedPrompt.text, timestamp: submittedPrompt.timestamp, }, ]); }); - it("appends a live server user message when no optimistic user message is pending", () => { + it("appends a live server user message when no submitted user message is pending", () => { const state = reduceStreamUpdate( [], { @@ -1523,21 +1691,11 @@ describe("turn lifecycle events", () => { const userMessages = state.filter((item) => item.kind === "user_message"); assert.strictEqual(userMessages.length, 1); assert.strictEqual(userMessages[0]?.id, "provider-owned-resume"); - assert.strictEqual(userMessages[0]?.optimistic, undefined); }); - it("does not match a server user message to an optimistic from a rewound turn after pending optimistics are cleared", () => { - const optimistic: StreamItem = { - kind: "user_message", - id: "msg_rewound_optimistic", - text: "rewound text", - timestamp: new Date("2025-01-01T15:04:04Z"), - optimistic: true, - }; - const cleared = clearOptimisticUserMessages([optimistic]); - + it("appends a server user message after a rewound local row was removed", () => { const state = reduceStreamUpdate( - cleared, + [], { type: "timeline", provider: "opencode", @@ -1555,7 +1713,6 @@ describe("turn lifecycle events", () => { assert.strictEqual(userMessages.length, 1); assert.strictEqual(userMessages[0]?.id, "provider-owned-after-rewind"); assert.strictEqual(userMessages[0]?.text, "future server echo"); - assert.strictEqual(userMessages[0]?.optimistic, undefined); }); it("keeps canonical repeated user messages distinct during hydration", () => { diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 76407d84c..8b66ab80b 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -87,22 +87,440 @@ export interface UserMessageItem { kind: "user_message"; id: string; clientMessageId?: string; - text: string; - timestamp: Date; - optimistic?: true; - images?: UserMessageImageAttachment[]; - attachments?: AgentAttachment[]; -} - -export interface OptimisticUserMessageInput { - id: string; + messageId?: string; text: string; timestamp: Date; images?: UserMessageImageAttachment[]; attachments?: AgentAttachment[]; } -export type OptimisticUserMessagePlacement = "tail" | "active-head"; +export interface UserMessageInput { + id?: string; + clientMessageId?: string; + messageId?: string; + text: string; + timestamp: Date; + images?: UserMessageImageAttachment[]; + attachments?: AgentAttachment[]; +} + +export function createUserMessage(input: UserMessageInput): UserMessageItem { + const id = input.id ?? input.clientMessageId ?? input.messageId; + if (!id) { + throw new Error("User message identity is required"); + } + return { + kind: "user_message", + id, + ...(input.clientMessageId ? { clientMessageId: input.clientMessageId } : {}), + ...(input.messageId ? { messageId: input.messageId } : {}), + text: input.text, + timestamp: input.timestamp, + ...(input.images && input.images.length > 0 ? { images: input.images } : {}), + ...(input.attachments && input.attachments.length > 0 + ? { attachments: input.attachments } + : {}), + }; +} + +export function appendSubmittedUserMessage(input: { + tail: StreamItem[]; + head: StreamItem[]; + message: UserMessageItem; +}): { tail: StreamItem[]; head: StreamItem[] } { + const clientMessageId = input.message.clientMessageId; + if (!clientMessageId) { + throw new Error("Submitted user message requires client identity"); + } + const alreadyExists = [...input.tail, ...input.head].some( + (item) => item.kind === "user_message" && item.clientMessageId === clientMessageId, + ); + if (alreadyExists) { + throw new Error(`Submitted user message already exists: ${clientMessageId}`); + } + return input.head.length > 0 + ? { tail: input.tail, head: [...input.head, input.message] } + : { tail: [...input.tail, input.message], head: input.head }; +} + +export function removeSubmittedUserMessage(input: { + tail: StreamItem[]; + head: StreamItem[]; + clientMessageId: string; +}): { tail: StreamItem[]; head: StreamItem[] } { + const remove = (items: StreamItem[]) => { + const next = items.filter( + (item) => item.kind !== "user_message" || item.clientMessageId !== input.clientMessageId, + ); + return next.length === items.length ? items : next; + }; + return { tail: remove(input.tail), head: remove(input.head) }; +} + +// COMPAT(userMessageClientId): added in v0.2.0, remove after 2027-01-20 once the +// supported daemon floor emits clientMessageId on submitted user messages. Until then a +// locally submitted row (clientMessageId, no messageId) and its canonical twin from an +// old daemon (messageId, no clientMessageId) share no identifier, so canonical ingestion +// may match an explicit local candidate by the id supplied over the wire or by text. +function matchesLegacyCanonicalUserMessage( + submitted: UserMessageItem, + canonical: UserMessageItem, +): boolean { + if (submitted.clientMessageId === undefined || submitted.messageId !== undefined) return false; + if (canonical.messageId === undefined) return false; + return canonical.messageId === submitted.clientMessageId || canonical.text === submitted.text; +} + +type UserMessageMatchPolicy = "canonical-incoming" | "handoff"; + +function matchesUserMessage( + existing: UserMessageItem, + incoming: UserMessageItem, + policy: UserMessageMatchPolicy, +): boolean { + if (existing.clientMessageId && incoming.clientMessageId) { + return existing.clientMessageId === incoming.clientMessageId; + } + if (existing.messageId && incoming.messageId) { + return existing.messageId === incoming.messageId; + } + if (matchesLegacyCanonicalUserMessage(existing, incoming)) return true; + return policy === "handoff" && matchesLegacyCanonicalUserMessage(incoming, existing); +} + +export function upsertUserMessage( + items: StreamItem[], + incoming: UserMessageItem, + insertAt = items.length, +): StreamItem[] { + return produceUserMessage(items, incoming, insertAt, "existing").items; +} + +type UserMessagePresentationPolicy = "existing" | "incoming"; + +interface UserMessageProductionResult { + items: StreamItem[]; + index: number; + message: UserMessageItem; + matched: boolean; +} + +function produceUserMessage( + items: StreamItem[], + incoming: UserMessageItem, + insertAt: number | null, + presentationPolicy: UserMessagePresentationPolicy, + matchPolicy: UserMessageMatchPolicy = "canonical-incoming", +): UserMessageProductionResult { + const index = items.findIndex( + (item) => item.kind === "user_message" && matchesUserMessage(item, incoming, matchPolicy), + ); + if (index < 0) { + if (insertAt === null) { + return { items, index: -1, message: incoming, matched: false }; + } + return { + items: [...items.slice(0, insertAt), incoming, ...items.slice(insertAt)], + index: insertAt, + message: incoming, + matched: false, + }; + } + + const existing = items[index]; + if (!existing || existing.kind !== "user_message") { + throw new Error("User message upsert matched a non-user row"); + } + const presentation = presentationPolicy === "incoming" ? incoming : existing; + const merged = createUserMessage({ + ...presentation, + clientMessageId: incoming.clientMessageId ?? existing.clientMessageId, + messageId: incoming.messageId ?? existing.messageId, + }); + if ( + existing.id === merged.id && + existing.clientMessageId === merged.clientMessageId && + existing.messageId === merged.messageId && + existing.text === merged.text && + existing.timestamp === merged.timestamp && + existing.images === merged.images && + existing.attachments === merged.attachments + ) { + return { items, index, message: existing, matched: true }; + } + const next = [...items]; + next[index] = merged; + return { items: next, index, message: merged, matched: true }; +} + +export interface UserMessageStreamUpsertInput { + tail: StreamItem[]; + head: StreamItem[]; + message: UserMessageItem; + insert: "tail" | "head" | "prepend-tail" | "none"; + presentation: UserMessagePresentationPolicy; + matchPolicy?: UserMessageMatchPolicy; +} + +export interface UserMessageStreamUpsertResult extends ApplyStreamEventResult { + location: { + lane: "tail" | "head"; + index: number; + message: UserMessageItem; + matched: boolean; + } | null; +} + +export function upsertUserMessageAcrossStream( + input: UserMessageStreamUpsertInput, +): UserMessageStreamUpsertResult { + const tailResult = produceUserMessage( + input.tail, + input.message, + null, + input.presentation, + input.matchPolicy, + ); + if (tailResult.matched) { + return { + tail: tailResult.items, + head: input.head, + changedTail: tailResult.items !== input.tail, + changedHead: false, + location: { + lane: "tail", + index: tailResult.index, + message: tailResult.message, + matched: true, + }, + }; + } + const headResult = produceUserMessage( + input.head, + input.message, + null, + input.presentation, + input.matchPolicy, + ); + if (headResult.matched) { + return { + tail: input.tail, + head: headResult.items, + changedTail: false, + changedHead: headResult.items !== input.head, + location: { + lane: "head", + index: headResult.index, + message: headResult.message, + matched: true, + }, + }; + } + if (input.insert === "none") { + return { + tail: input.tail, + head: input.head, + changedTail: false, + changedHead: false, + location: null, + }; + } + if (input.insert === "head") { + const inserted = produceUserMessage( + input.head, + input.message, + input.head.length, + input.presentation, + input.matchPolicy, + ); + return { + tail: input.tail, + head: inserted.items, + changedTail: false, + changedHead: true, + location: { + lane: "head", + index: inserted.index, + message: inserted.message, + matched: false, + }, + }; + } + const inserted = produceUserMessage( + input.tail, + input.message, + input.insert === "prepend-tail" ? 0 : input.tail.length, + input.presentation, + input.matchPolicy, + ); + return { + tail: inserted.items, + head: input.head, + changedTail: true, + changedHead: false, + location: { + lane: "tail", + index: inserted.index, + message: inserted.message, + matched: false, + }, + }; +} + +function placeCanonicalUserMessageAtTail( + tail: StreamItem[], + message: UserMessageItem, + insertWhenUnmatched: boolean, +): Pick { + const produced = produceUserMessage(tail, message, null, "existing"); + if (!produced.matched && !insertWhenUnmatched) { + return produced; + } + const preceding = produced.matched + ? [...produced.items.slice(0, produced.index), ...produced.items.slice(produced.index + 1)] + : produced.items; + return { + items: [...preceding, produced.message], + message: produced.message, + matched: produced.matched, + }; +} + +export interface CanonicalStreamReplacementInput { + canonical: StreamItem[]; + previousTail: StreamItem[]; + previousHead: StreamItem[]; + sendingClientMessageIds: readonly string[]; + preserveLiveHead: boolean; +} + +export interface CanonicalStreamReplacementResult { + tail: StreamItem[]; + head: StreamItem[]; + acknowledgedClientMessageIds: string[]; +} + +function removeUserMessageAt(items: UserMessageItem[], index: number): UserMessageItem[] { + return [...items.slice(0, index), ...items.slice(index + 1)]; +} + +function preserveReplacementHead( + tail: StreamItem[], + currentHead: StreamItem[], + preserveLiveHead: boolean, + sendingClientMessageIds: ReadonlySet, +): CanonicalStreamReplacementResult { + const retainedHead = preserveLiveHead + ? currentHead + : currentHead.filter( + (item) => + item.kind === "user_message" && + item.clientMessageId !== undefined && + sendingClientMessageIds.has(item.clientMessageId), + ); + const tailIds = new Set(tail.map((item) => item.id)); + const unreconciledHead = retainedHead.filter( + (item) => item.kind === "assistant_message" || !tailIds.has(item.id), + ); + const liveAssistantIndex = unreconciledHead.findLastIndex( + (item) => item.kind === "assistant_message", + ); + if (liveAssistantIndex < 0) { + return { tail, head: unreconciledHead, acknowledgedClientMessageIds: [] }; + } + + const liveAssistant = unreconciledHead[liveAssistantIndex]; + const tailAssistant = tail.at(-1); + if ( + liveAssistant.kind !== "assistant_message" || + !tailAssistant || + tailAssistant.kind !== "assistant_message" || + !liveAssistant.text.startsWith(tailAssistant.text) + ) { + return { tail, head: unreconciledHead, acknowledgedClientMessageIds: [] }; + } + + const head = [ + ...unreconciledHead.slice(0, liveAssistantIndex), + { ...liveAssistant, text: tailAssistant.text }, + ...unreconciledHead.slice(liveAssistantIndex + 1), + ]; + return { tail: tail.slice(0, -1), head, acknowledgedClientMessageIds: [] }; +} + +export function replaceWithCanonicalStream( + input: CanonicalStreamReplacementInput, +): CanonicalStreamReplacementResult { + const sendingClientMessageIds = new Set(input.sendingClientMessageIds); + let unmatchedTailMessages = input.previousTail.filter( + (item): item is UserMessageItem => + item.kind === "user_message" && item.clientMessageId !== undefined, + ); + let nextHead = input.previousHead; + const nextTail: StreamItem[] = []; + const acknowledgedClientMessageIds = new Set(); + + for (const item of input.canonical) { + if (item.kind !== "user_message") { + nextTail.push(item); + continue; + } + + const tailResult = produceUserMessage(unmatchedTailMessages, item, null, "existing"); + if (tailResult.matched) { + unmatchedTailMessages = removeUserMessageAt(unmatchedTailMessages, tailResult.index); + nextTail.push(tailResult.message); + if ( + tailResult.message.clientMessageId && + sendingClientMessageIds.has(tailResult.message.clientMessageId) + ) { + acknowledgedClientMessageIds.add(tailResult.message.clientMessageId); + } + continue; + } + + const headResult = produceUserMessage(nextHead, item, null, "existing"); + if (headResult.matched) { + nextHead = [ + ...headResult.items.slice(0, headResult.index), + ...headResult.items.slice(headResult.index + 1), + ]; + nextTail.push(headResult.message); + if ( + headResult.message.clientMessageId && + sendingClientMessageIds.has(headResult.message.clientMessageId) + ) { + acknowledgedClientMessageIds.add(headResult.message.clientMessageId); + } + continue; + } + + nextTail.push(item); + } + + for (const local of unmatchedTailMessages) { + if (!local.clientMessageId || !sendingClientMessageIds.has(local.clientMessageId)) { + continue; + } + nextTail.push(local); + } + + nextHead = nextHead.filter((item) => { + if (item.kind !== "user_message" || !item.clientMessageId) return true; + return sendingClientMessageIds.has(item.clientMessageId); + }); + + const replacement = preserveReplacementHead( + nextTail, + nextHead, + input.preserveLiveHead, + sendingClientMessageIds, + ); + return { + ...replacement, + acknowledgedClientMessageIds: [...acknowledgedClientMessageIds], + }; +} export interface AssistantMessageItem { kind: "assistant_message"; @@ -237,124 +655,24 @@ function markThoughtReady(item: ThoughtItem): ThoughtItem { }; } -function buildUserMessageItem(input: { - id: string; - clientMessageId?: string; - text: string; - timestamp: Date; - optimistic?: UserMessageItem | null; -}): UserMessageItem { - if (input.optimistic) { - return { - kind: "user_message", - id: input.id, - ...(input.clientMessageId ? { clientMessageId: input.clientMessageId } : {}), - text: input.optimistic.text, - timestamp: input.optimistic.timestamp, - ...(input.optimistic.images && input.optimistic.images.length > 0 - ? { images: input.optimistic.images } - : {}), - ...(input.optimistic.attachments && input.optimistic.attachments.length > 0 - ? { attachments: input.optimistic.attachments } - : {}), - }; - } - - return { - kind: "user_message", - id: input.id, - ...(input.clientMessageId ? { clientMessageId: input.clientMessageId } : {}), - text: input.text, - timestamp: input.timestamp, - }; -} - -export function buildOptimisticUserMessage(input: OptimisticUserMessageInput): UserMessageItem { - return { - kind: "user_message", - id: input.id, - text: input.text, - timestamp: input.timestamp, - optimistic: true, - ...(input.images && input.images.length > 0 ? { images: input.images } : {}), - ...(input.attachments && input.attachments.length > 0 - ? { attachments: input.attachments } - : {}), - }; -} - -export function appendOptimisticUserMessageToStream(params: { - tail: StreamItem[]; - head: StreamItem[]; - message: UserMessageItem; - placement: OptimisticUserMessagePlacement; -}): ApplyStreamEventResult { - const { tail, head, message, placement } = params; - if (tail.some((item) => item.id === message.id) || head.some((item) => item.id === message.id)) { - return { tail, head, changedTail: false, changedHead: false }; - } - - if (placement === "active-head" && head.length > 0) { - return { - tail, - head: [...head, message], - changedTail: false, - changedHead: true, - }; - } - - return { - tail: [...tail, message], - head, - changedTail: true, - changedHead: false, - }; -} - export function handoffCreatedAgentUserMessageToStream(params: { tail: StreamItem[]; head: StreamItem[]; message: UserMessageItem; }): ApplyStreamEventResult { - const { tail, head, message } = params; - const items = [...tail, ...head]; - const userIndex = items.findIndex((item) => item.kind === "user_message"); - if (userIndex < 0) { - return appendOptimisticUserMessageToStream({ - tail, - head, - message, - placement: "tail", - }); - } - - const userMessage = items[userIndex]; - if (!userMessage || userMessage.kind !== "user_message" || userMessage.optimistic) { - return { tail, head, changedTail: false, changedHead: false }; - } - - const handedOffMessage = buildUserMessageItem({ - id: userMessage.id, - text: message.text, - timestamp: message.timestamp, - optimistic: message, + return upsertUserMessageAcrossStream({ + ...params, + insert: "tail", + presentation: "incoming", + matchPolicy: "handoff", }); - if (userIndex < tail.length) { - const nextTail = [...tail]; - nextTail[userIndex] = handedOffMessage; - return { tail: nextTail, head, changedTail: true, changedHead: false }; - } - - const nextHead = [...head]; - nextHead[userIndex - tail.length] = handedOffMessage; - return { tail, head: nextHead, changedTail: false, changedHead: true }; } function appendUserMessage( state: StreamItem[], text: string, timestamp: Date, - source: StreamUpdateSource, + _source: StreamUpdateSource, messageId?: string, clientMessageId?: string, ): StreamItem[] { @@ -364,37 +682,14 @@ function appendUserMessage( } const chunkSeed = chunk.trim() || chunk; - const entryId = messageId ?? createUniqueTimelineId(state, "user", chunkSeed, timestamp); - const optimisticIndex = state.findIndex( - (entry) => - entry.kind === "user_message" && - entry.optimistic && - (clientMessageId !== undefined - ? entry.id === clientMessageId - : source === "live" || entry.id === messageId || entry.text === chunk), - ); - const optimistic = optimisticIndex >= 0 ? (state[optimisticIndex] as UserMessageItem) : null; - - const nextItem = buildUserMessageItem({ - id: entryId, + const nextItem = createUserMessage({ + id: messageId ?? createUniqueTimelineId(state, "user", chunkSeed, timestamp), clientMessageId, + messageId, text: chunk, timestamp, - optimistic, }); - - if (optimisticIndex >= 0) { - const next = [...state]; - next[optimisticIndex] = nextItem; - return next; - } - - return [...state, nextItem]; -} - -export function clearOptimisticUserMessages(state: StreamItem[]): StreamItem[] { - const next = state.filter((item) => item.kind !== "user_message" || !item.optimistic); - return next.length === state.length ? state : next; + return upsertUserMessage(state, nextItem); } function appendAssistantMessage( @@ -426,8 +721,8 @@ function appendAssistantMessage( return [...state.slice(0, -1), updated]; } - // If the last item is a user_message (optimistic append to head during - // interrupt), look one further back for the streaming assistant_message. + // A submitted user row can follow the streaming assistant during interrupt. + // In that case, look one row further back for the assistant to extend. const secondLast = state[state.length - 2]; if ( source === "live" && @@ -1149,7 +1444,6 @@ export function flushHeadToTail(tail: StreamItem[], head: StreamItem[]): StreamI if (newItems.length === 0) { return tail; } - return [...tail, ...newItems]; } @@ -1177,8 +1471,7 @@ function shouldFlushHead(input: { return true; } - // Find the last streamable item in head (skip trailing non-streamable - // items like an optimistic user_message appended during interrupt). + // Find the last streamable item in head (skip trailing non-streamable items). let lastStreamable: StreamItem | undefined; for (let i = head.length - 1; i >= 0; i--) { if (isStreamableKind(head[i].kind)) { @@ -1209,6 +1502,41 @@ export interface ApplyStreamEventResult { head: StreamItem[]; changedTail: boolean; changedHead: boolean; + acknowledgedClientMessageIds?: string[]; +} + +function applyCanonicalUserMessageEvent(params: { + tail: StreamItem[]; + head: StreamItem[]; + event: AgentStreamEventPayload; + timestamp: Date; +}): ApplyStreamEventResult | null { + const { tail, head, event, timestamp } = params; + if (event.type !== "timeline" || event.item.type !== "user_message") return null; + const normalized = normalizeChunk(event.item.text); + + const flushedTail = head.length > 0 ? flushHeadToTail(tail, head) : tail; + const flushedHead = head.length > 0 ? [] : head; + const canonical = createUserMessage({ + id: + event.item.messageId ?? + createUniqueTimelineId([...tail, ...head], "user", normalized.chunk.trim(), timestamp), + messageId: event.item.messageId, + clientMessageId: event.item.clientMessageId, + text: normalized.chunk, + timestamp, + }); + const reconciled = placeCanonicalUserMessageAtTail(flushedTail, canonical, normalized.hasContent); + return { + tail: reconciled.items, + head: flushedHead, + changedTail: flushedTail !== tail || reconciled.items !== flushedTail, + changedHead: flushedHead !== head, + acknowledgedClientMessageIds: + reconciled.matched && reconciled.message.clientMessageId + ? [reconciled.message.clientMessageId] + : [], + }; } /** @@ -1231,12 +1559,13 @@ export function applyStreamEvent(params: { timelineCursor?: TimelinePosition; }): ApplyStreamEventResult { const { tail, head, event, timestamp } = params; + const canonicalUserResult = applyCanonicalUserMessageEvent({ tail, head, event, timestamp }); + if (canonicalUserResult) return canonicalUserResult; const source = params.source ?? "live"; let nextTail = tail; let nextHead = head; let changedTail = false; let changedHead = false; - const flushHead = () => { if (nextHead.length === 0) { return; diff --git a/packages/app/src/utils/agent-directory-sync.test.ts b/packages/app/src/utils/agent-directory-sync.test.ts index 1ec16bde2..5330f8aa2 100644 --- a/packages/app/src/utils/agent-directory-sync.test.ts +++ b/packages/app/src/utils/agent-directory-sync.test.ts @@ -7,6 +7,7 @@ import { useSessionStore } from "@/stores/session-store"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent"; import { queryClient } from "@/data/query-client"; +import { createUserMessage } from "@/types/stream"; import { applyAgentDirectoryDelta, replaceFetchedAgentDirectory } from "./agent-directory-sync"; function createAgentPayload( @@ -64,6 +65,111 @@ function permission(id: string): AgentPermissionRequest { return { id, provider: "codex", name: id, kind: "tool", title: id }; } +function beginPendingSubmission(serverId: string, agentId: string): string { + const clientMessageId = `client-${agentId}`; + useSessionStore.getState().beginAgentMessageSubmission( + serverId, + agentId, + createUserMessage({ + clientMessageId, + text: "Run this", + timestamp: new Date("2026-07-27T10:00:00.000Z"), + }), + ); + return clientMessageId; +} + +function applyAgentStatus(input: { + serverId: string; + agentId: string; + status: AgentSnapshotPayload["status"]; + updatedAt: string; +}): void { + const agent = createAgentPayload({ + id: input.agentId, + status: input.status, + updatedAt: input.updatedAt, + }); + applyAgentDirectoryDelta({ + serverId: input.serverId, + delta: { kind: "upsert", agent, project: createEntry(agent).project }, + }); +} + +describe("message submission authority", () => { + it("does not settle a submission from an unrelated running transition", () => { + const serverId = "server-running-is-not-submission-ack"; + const agentId = "agent-1"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + applyAgentStatus({ + serverId, + agentId, + status: "idle", + updatedAt: "2026-07-27T10:00:00.000Z", + }); + const clientMessageId = beginPendingSubmission(serverId, agentId); + + applyAgentStatus({ + serverId, + agentId, + status: "running", + updatedAt: "2026-07-27T10:00:01.000Z", + }); + + expect(useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)).toEqual([ + { + clientMessageId, + submittedAt: new Date("2026-07-27T10:00:00.000Z"), + rpcAccepted: false, + providerAcknowledged: false, + }, + ]); + store.clearSession(serverId); + }); + + it("settles provider acknowledgement only when timeline ingestion reports it", () => { + const serverId = "server-explicit-provider-ack"; + const agentId = "agent-1"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + applyAgentStatus({ + serverId, + agentId, + status: "idle", + updatedAt: "2026-07-27T10:00:00.000Z", + }); + const clientMessageId = beginPendingSubmission(serverId, agentId); + store.setAgentStreamState(serverId, agentId, { + tail: [ + createUserMessage({ + id: "provider-message", + messageId: "provider-message", + clientMessageId, + text: "Run this", + timestamp: new Date("2026-07-27T10:00:01.000Z"), + }), + ], + head: [], + }); + + expect( + useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)?.[0] + ?.providerAcknowledged, + ).toBe(false); + + store.setAgentStreamState(serverId, agentId, { + acknowledgedClientMessageIds: [clientMessageId], + }); + + expect( + useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)?.[0] + ?.providerAcknowledged, + ).toBe(true); + store.clearSession(serverId); + }); +}); + describe("replaceFetchedAgentDirectory", () => { it("preserves timeline initialization while replacing directory state", () => { const serverId = "server-initializing"; diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 8e67ea4b5..bed840c0e 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -324,6 +324,11 @@ export interface SendMessageOptions { attachments?: SendAgentMessageRequest["attachments"]; } +export interface SendMessageResult { + /** Undefined when connected to a daemon predating message submission disposition. */ + outOfBand?: boolean; +} + export interface AgentAttentionRequiredNotification { agentId: string; reason: "finished" | "error" | "permission"; @@ -2859,7 +2864,7 @@ export class DaemonClient { agentId: string, text: string, options?: SendMessageOptions, - ): Promise { + ): Promise { const requestId = this.createRequestId(); const messageId = options?.messageId ?? crypto.randomUUID(); const message = SessionInboundMessageSchema.parse({ @@ -2888,6 +2893,7 @@ export class DaemonClient { if (!payload.accepted) { throw new Error(payload.error ?? "sendAgentMessage rejected"); } + return payload.outOfBand === undefined ? {} : { outOfBand: payload.outOfBand }; } async sendMessage(agentId: string, text: string, options?: SendMessageOptions): Promise { diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index d7f21f124..a0fdbd5e7 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -478,7 +478,9 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor latest = result?.agent ?? null; return result; }, - send: (text, options) => daemonClient.sendAgentMessage(id, text, options), + send: async (text, options) => { + await daemonClient.sendAgentMessage(id, text, options); + }, archive: async () => { const result = await daemonClient.archiveAgent(id); if (latest) { diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index c269321f6..dd9772e5c 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -3710,6 +3710,8 @@ export const SendAgentMessageResponseMessageSchema = z.object({ agentId: z.string(), accepted: z.boolean(), error: z.string().nullable(), + // COMPAT(messageSubmissionDisposition): added in v0.2.3, remove optional parsing after 2027-01-27. + outOfBand: z.boolean().optional(), }), }); 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 98d904076..cacdda38e 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 @@ -49,6 +49,25 @@ describe("MockLoadTestAgentClient", () => { }); }); + test("rejects the configured number of prompts before starting a retry", async () => { + const client = new MockLoadTestAgentClient(); + const session = await client.createSession({ + provider: "mock", + cwd: process.cwd(), + model: "ten-second-stream", + featureValues: { mockPromptRejections: 1 }, + }); + + await expect(session.startTurn("Reject this prompt.")).rejects.toThrow( + "Requested mock prompt rejection", + ); + + await expect(session.startTurn("Accept this retry.")).resolves.toEqual({ + turnId: expect.any(String), + }); + await session.interrupt(); + }); + 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 33b9ddfac..ffab16c79 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 @@ -583,6 +583,7 @@ export class MockLoadTestAgentSession implements AgentSession { private modeId: string | null; private modelId: string | null; private readonly rewindError: string | null; + private remainingPromptRejections: number; constructor(options: { config: AgentSessionConfig; sessionId: string; logger?: Logger }) { this.id = options.sessionId; @@ -593,6 +594,13 @@ export class MockLoadTestAgentSession implements AgentSession { typeof options.config.featureValues?.mockRewindError === "string" ? options.config.featureValues.mockRewindError : null; + const requestedPromptRejections = options.config.featureValues?.mockPromptRejections; + this.remainingPromptRejections = + typeof requestedPromptRejections === "number" && + Number.isSafeInteger(requestedPromptRejections) && + requestedPromptRejections > 0 + ? requestedPromptRejections + : 0; } async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise { @@ -611,6 +619,10 @@ export class MockLoadTestAgentSession implements AgentSession { if (this.activeTurn) { throw new Error("Mock load-test provider already has an active turn"); } + if (this.remainingPromptRejections > 0) { + this.remainingPromptRejections -= 1; + throw new Error("Requested mock prompt rejection"); + } const profile = resolveModelProfile(this.modelId); const turnId = randomUUID(); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 2682979fe..51059637a 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -6345,6 +6345,7 @@ export class Session { agentId, accepted: true, error: null, + outOfBand: true, }, }); return; @@ -6372,6 +6373,7 @@ export class Session { agentId, accepted: true, error: null, + outOfBand: false, }, }); } catch (error) {