mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 04:03:05 +00:00
Reapply: Stop completed turns from appearing stuck (#2484)
This commit is contained in:
+34
-6
@@ -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
|
||||
|
||||
|
||||
@@ -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<ReturnType<typeof gateNextAgentMessage>>;
|
||||
}
|
||||
|
||||
interface DraftCreateScenario {
|
||||
workspaceId: string;
|
||||
agentCreatedDelay: Awaited<ReturnType<typeof delayBrowserAgentCreatedStatus>>;
|
||||
}
|
||||
|
||||
interface RejectionScenario {
|
||||
errorMessage: string;
|
||||
}
|
||||
|
||||
interface UnrelatedRunningScenario {
|
||||
gate: Awaited<ReturnType<typeof gateNextAgentMessage>>;
|
||||
agent: Awaited<ReturnType<typeof seedMockAgentWorkspace>>;
|
||||
}
|
||||
|
||||
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<Locator> {
|
||||
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<Locator> {
|
||||
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<void> {
|
||||
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<MessageGeometry> {
|
||||
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<void>> {
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
await page.evaluate(() => {
|
||||
const state = { active: true, sawMissing: false };
|
||||
const windowState = window as unknown as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await fillComposerDraft(page, prompt);
|
||||
await sendDraftToQueue(page);
|
||||
}
|
||||
|
||||
async function expectQueuedSendFailuresRestored(page: Page, prompts: string[]): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<DraftCreatePendingSubmission> {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
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<WebSocketMessage | null> = [];
|
||||
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<SendAgentMessageRequest> => {
|
||||
while (requests.length < count) {
|
||||
await new Promise<void>((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<void> {
|
||||
if (!browserSocket) throw new Error("No browser daemon socket to disconnect");
|
||||
await browserSocket.close({ code: 1008, reason: "Dropped by submission test." });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown> } } };
|
||||
payload?: { event?: { type?: unknown; item?: Record<string, unknown> } };
|
||||
};
|
||||
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<string, unknown> };
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
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<string, unknown> };
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
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<string>;
|
||||
agentStreamEventTypes: ReadonlySet<string>;
|
||||
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<string>();
|
||||
const suppressedAgentStreamEventTypes = new Set<string>();
|
||||
const activeSockets = new Set<WebSocketRoute>();
|
||||
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<string, number>();
|
||||
const serverMessageCounts = new Map<string, number>();
|
||||
const agentStreamItemCounts = new Map<string, number>();
|
||||
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<void> {
|
||||
if (heldClientRequest) return Promise.resolve();
|
||||
return new Promise<void>((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<void> {
|
||||
if (heldServerMessage) return Promise.resolve();
|
||||
return new Promise<void>((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<string, unknown> };
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
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<string, unknown>).maxSeq = lastSeq;
|
||||
(payload.window as Record<string, unknown>).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<void> {
|
||||
while ((serverMessageCounts.get(type) ?? 0) < count) {
|
||||
await new Promise<void>((resolve) => serverMessageWaiters.add(resolve));
|
||||
}
|
||||
},
|
||||
async waitForAgentStreamItem(type: string, count = 1): Promise<void> {
|
||||
while ((agentStreamItemCounts.get(type) ?? 0) < count) {
|
||||
await new Promise<void>((resolve) => serverMessageWaiters.add(resolve));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<void>
|
||||
await expect(userMessage(page, text)).toBeVisible();
|
||||
}
|
||||
|
||||
async function rewriteCachedMessageAsLegacyRow(page: Page, prompt: string): Promise<void> {
|
||||
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<Record<string, unknown>> } | 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<Record<string, unknown>> } | 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<void> {
|
||||
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<Record<string, unknown>> } | 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<void> {
|
||||
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<Record<string, unknown>> } | 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<void> {
|
||||
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.";
|
||||
|
||||
@@ -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<string, PendingPermission>;
|
||||
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<AgentStreamViewHandle, AgentStreamVi
|
||||
streamItems,
|
||||
streamHead: providedStreamHead,
|
||||
pendingPermissions,
|
||||
pendingMessageSubmissions = EMPTY_PENDING_MESSAGE_SUBMISSIONS,
|
||||
routeBottomAnchorRequest = null,
|
||||
isAuthoritativeHistoryReady = true,
|
||||
toast,
|
||||
@@ -341,6 +345,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning);
|
||||
const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel);
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(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<AgentStreamViewHandle, AgentStreamVi
|
||||
<UserMessage
|
||||
serverId={resolvedServerId}
|
||||
agentId={agentId}
|
||||
messageId={item.id}
|
||||
messageId={item.messageId}
|
||||
message={item.text}
|
||||
images={item.images}
|
||||
attachments={item.attachments}
|
||||
@@ -665,10 +673,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
client={client}
|
||||
isFirstInGroup={layoutItem.isFirstInUserGroup}
|
||||
isLastInGroup={layoutItem.isLastInUserGroup}
|
||||
isPending={
|
||||
item.clientMessageId !== undefined &&
|
||||
pendingClientMessageIds.has(item.clientMessageId)
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[context.capabilities, agentId, client, resolvedServerId],
|
||||
[context.capabilities, agentId, client, pendingClientMessageIds, resolvedServerId],
|
||||
);
|
||||
|
||||
const renderAssistantMessageItem = useCallback(
|
||||
@@ -878,7 +890,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
[pendingPermissions, agentId],
|
||||
);
|
||||
|
||||
const showRunningTurnFooter = baseRenderModel.turnTiming.isActive;
|
||||
const showRunningTurnFooter =
|
||||
context.status === "running" || pendingMessageSubmissions.length > 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)
|
||||
) {
|
||||
|
||||
@@ -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 (
|
||||
<View style={containerStyle} testID="user-message">
|
||||
<View style={containerStyle} testID="user-message" aria-busy={isPending}>
|
||||
<View
|
||||
style={userMessageStylesheet.content}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
@@ -538,9 +540,15 @@ export const UserMessage = memo(function UserMessage({
|
||||
) : null}
|
||||
</View>
|
||||
{hasText ? (
|
||||
<View style={trailingRowStyle} pointerEvents={showTrailingRow ? "auto" : "none"}>
|
||||
<Text style={userMessageStylesheet.timestampText}>{formattedTimestamp}</Text>
|
||||
{capabilities ? (
|
||||
<View
|
||||
style={trailingRowStyle}
|
||||
pointerEvents={showTrailingRow ? "auto" : "none"}
|
||||
testID="user-message-trailing-row"
|
||||
>
|
||||
<Text style={userMessageStylesheet.timestampText} testID="user-message-timestamp">
|
||||
{formattedTimestamp}
|
||||
</Text>
|
||||
{capabilities && messageId ? (
|
||||
<RewindMenu
|
||||
capabilities={capabilities}
|
||||
isPending={rewindMutation.isPending}
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { RewindMode } from "./use-rewind-capabilities";
|
||||
import { useRewindComposerRestore } from "./composer-restore";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
|
||||
import { clearOptimisticUserMessages } from "@/types/stream";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
|
||||
interface UseRewindAgentMutationInput {
|
||||
@@ -36,13 +35,6 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
|
||||
}
|
||||
await input.client.rewindAgent(input.agentId, input.messageId, mode);
|
||||
if (mode !== "files") {
|
||||
if (input.serverId) {
|
||||
const session = useSessionStore.getState().sessions[input.serverId];
|
||||
useSessionStore.getState().setAgentStreamState(input.serverId, input.agentId, {
|
||||
tail: clearOptimisticUserMessages(session?.agentStreamTail.get(input.agentId) ?? []),
|
||||
head: clearOptimisticUserMessages(session?.agentStreamHead.get(input.agentId) ?? []),
|
||||
});
|
||||
}
|
||||
const cursor = input.serverId
|
||||
? useSessionStore
|
||||
.getState()
|
||||
|
||||
@@ -6,7 +6,17 @@ import type {
|
||||
UserComposerAttachment,
|
||||
WorkspaceComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
appendSubmittedUserMessage,
|
||||
removeSubmittedUserMessage,
|
||||
type StreamItem,
|
||||
} from "@/types/stream";
|
||||
import {
|
||||
acceptMessageSubmission,
|
||||
beginMessageSubmission,
|
||||
rejectMessageSubmission,
|
||||
type MessageSubmissionRecord,
|
||||
} from "@/composer/submission/model";
|
||||
import {
|
||||
cancelComposerAgent,
|
||||
dispatchComposerAgentMessage,
|
||||
@@ -20,7 +30,7 @@ import {
|
||||
sendQueuedComposerMessageNow,
|
||||
toggleGithubAttachment,
|
||||
toggleGithubAttachmentFromPicker,
|
||||
type AgentStreamWriter,
|
||||
type MessageSubmissionWriter,
|
||||
type AttachmentPersister,
|
||||
type ComposerCancelClient,
|
||||
type ComposerSendClient,
|
||||
@@ -168,14 +178,16 @@ interface FakeSendCall {
|
||||
}
|
||||
|
||||
function createFakeSendClient(
|
||||
options: { rejection?: Error } = {},
|
||||
options: { rejection?: Error; beforeRejection?: (call: FakeSendCall) => 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<string, StreamItem[]>;
|
||||
tail: Map<string, StreamItem[]>;
|
||||
}
|
||||
@@ -192,18 +204,70 @@ function createFakeStream(initialHead: Map<string, StreamItem[]> = 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<FakeStream, Map<string, MessageSubmissionRecord[]>>();
|
||||
|
||||
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<string, QueuedComposerMessage[]> = new Map(),
|
||||
): QueueWriter & { state: Map<string, QueuedComposerMessage[]> } {
|
||||
@@ -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<StreamItem, { kind: "user_message" }>;
|
||||
@@ -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([
|
||||
|
||||
@@ -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<typeof splitComposerAttachmentsForSubmit>["attachments"];
|
||||
},
|
||||
) => Promise<void>;
|
||||
) => Promise<void | { outOfBand?: boolean }>;
|
||||
uploadFile: (input: { fileName: string; mimeType: string; bytes: Uint8Array }) => Promise<{
|
||||
requestId: string;
|
||||
file: {
|
||||
@@ -70,11 +65,10 @@ export interface ComposerCancelClient {
|
||||
cancelAgent: (agentId: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface AgentStreamWriter {
|
||||
getTail: (agentId: string) => StreamItem[] | undefined;
|
||||
getHead: (agentId: string) => StreamItem[] | undefined;
|
||||
setHead: (updater: (prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>) => void;
|
||||
setTail: (updater: (prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>) => 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<Array<{ data: string; mimeType: string }> | 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;
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
|
||||
@@ -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<TDraftAgent, TCreateResult>({
|
||||
const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : "";
|
||||
const isSubmitting = machine.tag === "creating";
|
||||
|
||||
const optimisticStreamItems = useMemo<StreamItem[]>(() => {
|
||||
const submittedStreamItems = useMemo<StreamItem[]>(() => {
|
||||
if (machine.tag !== "creating") {
|
||||
return EMPTY_STREAM_ITEMS;
|
||||
}
|
||||
@@ -147,8 +148,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
}
|
||||
|
||||
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<TDraftAgent, TCreateResult>({
|
||||
}),
|
||||
];
|
||||
}, [machine]);
|
||||
const pendingMessageSubmissions = useMemo<readonly PendingMessageSubmission[]>(() => {
|
||||
if (machine.tag !== "creating") return [];
|
||||
return [
|
||||
{
|
||||
clientMessageId: machine.attempt.clientMessageId,
|
||||
submittedAt: machine.attempt.timestamp,
|
||||
},
|
||||
];
|
||||
}, [machine]);
|
||||
|
||||
const draftAgent = useMemo<TDraftAgent | null>(() => {
|
||||
if (machine.tag !== "creating") {
|
||||
@@ -195,8 +205,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
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<TDraftAgent, TCreateResult>({
|
||||
machine,
|
||||
formErrorMessage,
|
||||
isSubmitting,
|
||||
optimisticStreamItems,
|
||||
submittedStreamItems,
|
||||
pendingMessageSubmissions,
|
||||
draftAgent,
|
||||
handleCreateFromInput,
|
||||
continueCreateFromAttempt,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export async function submitAgentInput<TAttachment>(
|
||||
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([]);
|
||||
|
||||
@@ -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<typeof useSessionStore.getState>;
|
||||
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,
|
||||
|
||||
@@ -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<string, PendingPermission>();
|
||||
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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -80,13 +80,16 @@ class FakeDaemonClient {
|
||||
this.setConnectionState({ status: "disconnected", reason: "client_closed" });
|
||||
}
|
||||
|
||||
async sendAgentMessage(...args: Parameters<DaemonClient["sendAgentMessage"]>): Promise<void> {
|
||||
async sendAgentMessage(
|
||||
...args: Parameters<DaemonClient["sendAgentMessage"]>
|
||||
): ReturnType<DaemonClient["sendAgentMessage"]> {
|
||||
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<void> {
|
||||
@@ -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<void>();
|
||||
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();
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -109,7 +109,10 @@ export const useCreateFlowStore = create<CreateFlowState>((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) {
|
||||
|
||||
@@ -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<string, StreamItem[]>;
|
||||
agentStreamHead: Map<string, StreamItem[]>;
|
||||
messageSubmissions: Map<string, MessageSubmissionRecord[]>;
|
||||
agentTimelineCursor: Map<string, AgentTimelineCursorState>;
|
||||
agentTimelineHasOlder: Map<string, boolean>;
|
||||
agentTimelineOlderFetchInFlight: Map<string, boolean>;
|
||||
@@ -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<string, Agent>;
|
||||
nextAgents: Map<string, Agent>;
|
||||
submissions: Map<string, MessageSubmissionRecord[]>;
|
||||
}): Map<string, MessageSubmissionRecord[]> {
|
||||
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<SessionStore>()(
|
||||
}
|
||||
}
|
||||
|
||||
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<SessionStore>()(
|
||||
...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<SessionStore>()(
|
||||
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<SessionStore>()(
|
||||
[serverId]: {
|
||||
...session,
|
||||
agents: nextAgents,
|
||||
workspaceAgentActivity: buildWorkspaceAgentActivityIndex(
|
||||
nextAgents,
|
||||
session.workspaceAgentActivity,
|
||||
),
|
||||
messageSubmissions,
|
||||
workspaceAgentActivity:
|
||||
nextAgents === session.agents
|
||||
? session.workspaceAgentActivity
|
||||
: buildWorkspaceAgentActivityIndex(nextAgents, session.workspaceAgentActivity),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<StreamItem, { kind: "user_message" }> {
|
||||
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(
|
||||
|
||||
@@ -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<StreamItem, { kind: "assistant_message" }> =>
|
||||
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<typeof deriveBootstrapTailTimelinePolicy>;
|
||||
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<number>();
|
||||
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<StreamItem, { kind: "assistant_message" }> =>
|
||||
@@ -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<string>();
|
||||
|
||||
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<ProcessAgentStreamEventsInput, "events">;
|
||||
@@ -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<string>();
|
||||
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<string, TimelineCursor>) => Map<string, TimelineCursor>,
|
||||
) => void;
|
||||
setAgents: (serverId: string, state: (prev: Map<string, Agent>) => Map<string, Agent>) => 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) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -9,7 +9,6 @@ export interface TurnTiming {
|
||||
export interface StreamTurnTiming {
|
||||
byAssistantId: Map<string, TurnTiming>;
|
||||
runningStartedAt: Date | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function deriveStreamTurnTiming(params: {
|
||||
@@ -19,8 +18,6 @@ export function deriveStreamTurnTiming(params: {
|
||||
}): StreamTurnTiming {
|
||||
const byAssistantId = new Map<string, TurnTiming>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
+478
-149
@@ -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<UserMessageProductionResult, "items" | "message" | "matched"> {
|
||||
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<string>,
|
||||
): 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<string>();
|
||||
|
||||
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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<void> {
|
||||
): Promise<SendMessageResult> {
|
||||
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<void> {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<AgentRunResult> {
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user