Compare commits

..

4 Commits

Author SHA1 Message Date
Mohamed Boudra
acb8506d1f Revert the timeline optimistic and pagination rework (#2596)
* Add npm release track invariant and beta dist-tag procedure

* Revert "Keep older chat history and image previews stable (#2490)"

The timeline optimistic/pagination rework needs more work than main releases can wait for. Reverted together with #2484; both are reapplied on integration/timeline-optimistic-rework.

* Revert "Stop completed turns from appearing stuck (#2484)"

See the #2490 revert. #2490 built on this submission-authority change, so the two revert and reapply as a pair.
2026-07-29 13:35:43 +02:00
Matt Cowger
ab24070075 fix(server): suppress Pi interruption stream error (#2311)
* Handle Pi aborted responses after interruption

* fix(server): suppress late Pi interruption terminal response
2026-07-29 18:41:09 +08:00
Mohamed Boudra
fab975a059 Keep idle agents and their background work alive (#2590)
* fix(server): keep idle agents resident

Remove time-based runtime collection so background work and subsequent prompts are not destroyed during idle periods. Runtimes now close only through explicit lifecycle actions.

* fix(server): preserve explicit close coordination

* fix(server): support partial agent manager adapters
2026-07-29 18:36:44 +08:00
Mohamed Boudra
504b687f89 Keep older chat history and image previews stable (#2490)
* fix(app): keep timeline history and previews stable

Treat persisted timeline replicas as display-only so authoritative pagination always comes from the daemon. Require renewed user intent between older-history pages unless the viewport genuinely remains at the history edge.

Model assistant image acquisition as loading, loaded, or failed so recreating a preview URL cannot be rendered as an error.

* fix(app): keep file image previews current

* fix(app): stabilize history pagination edges

* fix(app): recover transient timeline loads

* fix(app): retain assistant image previews

* Stabilize history pagination lifecycle

* Stabilize history settlement and image previews

* Retry file images after reconnect

* Keep active previews and pagination requests alive

* Replace image hook tests with typed ports

* Integrate timeline hydration with submission authority

* Harden pre-hydration timeline reconciliation

* Make rewind E2E use real scroll intent

* Preserve live timeline state through hydration

* Keep mounted image attachments retained

* Protect preview persistence and lifecycle hydration

* Preserve idless live assistant continuations

* Preserve timeline rows across delayed hydration

* test(app): cover real assistant image files

* Preserve assistant tool ordering during hydration
2026-07-28 22:29:03 +02:00
54 changed files with 1291 additions and 3940 deletions

View File

@@ -21,16 +21,8 @@ the agent runs through `ensureAgentLoaded()`, which resumes the durable provider
same Paseo agent ID. Provider history is not appended again when the canonical timeline is already
primed.
The daemon collects an eligible idle runtime after 30 minutes and sweeps every minute. Only
unarchived, non-internal agents that are exactly `idle`, have no active or pending run, replacement,
or permission, and have not been activated during the idle window are eligible. `running`,
`initializing`, and `error` agents stay resident. An idle parent also stays resident while current
in-memory state shows a running managed child or provider subagent. Otherwise agents are evaluated
independently; collection does not cascade or change parentage.
Active schedules targeting an existing agent protect that agent from collection. Paused, completed,
and new-agent schedules do not. A pane may remain open after collection; its next prompt resumes the
runtime.
Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle
action such as archive, replacement, reload, workspace teardown, or daemon shutdown.
### Cancellation
@@ -59,9 +51,8 @@ The provider still owns the underlying runtime. Paseo keeps an agent record so t
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
Archive is distinct from runtime collection. Archive sets `archivedAt`, invokes the provider's native
archive hook, and cascades to managed children. Runtime collection does none of those things; it only
releases the live runtime and writes `lastStatus: closed` on the still-active record.
Archive sets `archivedAt`, invokes the provider's native archive hook, and cascades to managed
children.
`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). When the agent owns an isolated workspace, auto-archive archives that workspace too; the managed worktree is removed when its final workspace reference is gone.

View File

@@ -38,6 +38,14 @@ There are two supported ways to ship from `main`:
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
2. **Beta flow**: release candidates on the `beta` channel. Betas carry an in-place changelog entry (beta users check it), publish npm only on the explicit `beta` dist-tag, and never move the website download target off the latest stable.
Paseo has one linear release track even though npm dist-tags are independent
pointers. The npm invariant is:
- A beta release moves only `beta`; `latest` remains on the newest stable.
- A stable release moves both `latest` and `beta` to that stable version. This
keeps users who install `@getpaseo/cli@beta` on the newest Paseo release after
a beta is promoted or superseded by a direct stable release.
## Release version decision
Every fresh release starts by classifying the full previous-stable-to-`HEAD`
@@ -76,6 +84,20 @@ npm run release:minor
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
After the stable release succeeds, move npm's `beta` pointer to the new stable
version for every published package. This changes dist-tags only; do not
republish the packages:
```bash
PASEO_VERSION=$(node -p "require('./package.json').version")
for package in highlight relay protocol client server cli; do
npm dist-tag add "@getpaseo/$package@$PASEO_VERSION" beta
done
```
Verify both npm tags now resolve to `PASEO_VERSION` before considering the
stable release complete.
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
The production relay is the Elixir service in [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay), with its own deployment process. Paseo releases and pushes to this repository do not deploy it. The Cloudflare relay code and workflow in this repository are legacy and are not used in production.
@@ -92,6 +114,7 @@ npm run version:all:patch
npm run version:all:minor
npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows)
# Then move npm's beta dist-tag to this stable version using the command above.
```
## Beta flow
@@ -506,6 +529,7 @@ Betas are checkpoints along the way; the entry is the single record for the jump
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). When promoting from beta, overwrite the existing `## X.Y.Z-beta.N` heading in place (heading → `X.Y.Z`, date → promotion day) — do not add a new entry on top of the beta one
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] EAS `Release Mobile` workflow for the same tag is green

View File

@@ -87,42 +87,14 @@ 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.
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.
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.
Canonical submitted user rows carry the provider's `messageId` and Paseo's optional
`clientMessageId`. The user-message producer reconciles them by `clientMessageId`, adds provider
identity to the existing row, and keeps the local presentation in its original timeline slot.
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.
`clientMessageId`. Clients reconcile optimistic prompts by `clientMessageId`. Content matching is
limited to the dated compatibility path for daemon timelines created before that field existed.
## Relevant code

View File

@@ -1,685 +0,0 @@
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);
});
});

View File

@@ -1,86 +0,0 @@
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." });
},
};
}

View File

@@ -16,20 +16,6 @@ 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 {
@@ -52,111 +38,15 @@ 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) {
@@ -166,20 +56,9 @@ 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);
@@ -190,12 +69,6 @@ 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 {
@@ -205,55 +78,8 @@ 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(outboundMessage);
ws.send(message);
} catch {
activeSockets.delete(ws);
}
@@ -274,125 +100,6 @@ 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 },
@@ -403,18 +110,5 @@ 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));
}
},
};
}

View File

@@ -1,52 +0,0 @@
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 });
}
});
});

View File

@@ -1,7 +1,6 @@
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,
@@ -24,152 +23,7 @@ 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.";

View File

@@ -41,7 +41,6 @@ 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,
@@ -240,7 +239,6 @@ export interface AgentStreamViewProps {
streamItems: StreamItem[];
streamHead?: StreamItem[];
pendingPermissions: Map<string, PendingPermission>;
pendingMessageSubmissions?: readonly PendingMessageSubmission[];
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady?: boolean;
toast?: ToastApi | null;
@@ -267,7 +265,6 @@ 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: {
@@ -330,7 +327,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
streamItems,
streamHead: providedStreamHead,
pendingPermissions,
pendingMessageSubmissions = EMPTY_PENDING_MESSAGE_SUBMISSIONS,
routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true,
toast,
@@ -345,10 +341,6 @@ 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(
() =>
@@ -664,7 +656,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
<UserMessage
serverId={resolvedServerId}
agentId={agentId}
messageId={item.messageId}
messageId={item.id}
message={item.text}
images={item.images}
attachments={item.attachments}
@@ -673,14 +665,10 @@ 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, pendingClientMessageIds, resolvedServerId],
[context.capabilities, agentId, client, resolvedServerId],
);
const renderAssistantMessageItem = useCallback(
@@ -890,8 +878,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[pendingPermissions, agentId],
);
const showRunningTurnFooter =
context.status === "running" || pendingMessageSubmissions.length > 0;
const showRunningTurnFooter = baseRenderModel.turnTiming.isActive;
const pendingPermissionsNode = useMemo(
() =>
renderPendingPermissionsNode({
@@ -1170,9 +1157,6 @@ 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)
) {

View File

@@ -132,7 +132,6 @@ interface UserMessageProps {
client?: DaemonClient | null;
isFirstInGroup?: boolean;
isLastInGroup?: boolean;
isPending?: boolean;
disableOuterSpacing?: boolean;
}
@@ -431,7 +430,6 @@ export const UserMessage = memo(function UserMessage({
client,
isFirstInGroup = true,
isLastInGroup = true,
isPending = false,
disableOuterSpacing,
}: UserMessageProps) {
const isCompact = useIsCompactFormFactor();
@@ -443,7 +441,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 = !isPending && hasText && (isCompact || isNative || isHovered);
const showTrailingRow = hasText && (isCompact || isNative || isHovered);
const formattedTimestamp = useMemo(
() => formatMessageTimestamp(new Date(timestamp)),
[timestamp],
@@ -496,7 +494,7 @@ export const UserMessage = memo(function UserMessage({
);
return (
<View style={containerStyle} testID="user-message" aria-busy={isPending}>
<View style={containerStyle} testID="user-message">
<View
style={userMessageStylesheet.content}
onPointerEnter={handlePointerEnter}
@@ -540,15 +538,9 @@ export const UserMessage = memo(function UserMessage({
) : null}
</View>
{hasText ? (
<View
style={trailingRowStyle}
pointerEvents={showTrailingRow ? "auto" : "none"}
testID="user-message-trailing-row"
>
<Text style={userMessageStylesheet.timestampText} testID="user-message-timestamp">
{formattedTimestamp}
</Text>
{capabilities && messageId ? (
<View style={trailingRowStyle} pointerEvents={showTrailingRow ? "auto" : "none"}>
<Text style={userMessageStylesheet.timestampText}>{formattedTimestamp}</Text>
{capabilities ? (
<RewindMenu
capabilities={capabilities}
isPending={rewindMutation.isPending}

View File

@@ -7,6 +7,7 @@ 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 {
@@ -35,6 +36,13 @@ 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()

View File

@@ -6,17 +6,7 @@ import type {
UserComposerAttachment,
WorkspaceComposerAttachment,
} from "@/attachments/types";
import {
appendSubmittedUserMessage,
removeSubmittedUserMessage,
type StreamItem,
} from "@/types/stream";
import {
acceptMessageSubmission,
beginMessageSubmission,
rejectMessageSubmission,
type MessageSubmissionRecord,
} from "@/composer/submission/model";
import type { StreamItem } from "@/types/stream";
import {
cancelComposerAgent,
dispatchComposerAgentMessage,
@@ -30,7 +20,7 @@ import {
sendQueuedComposerMessageNow,
toggleGithubAttachment,
toggleGithubAttachmentFromPicker,
type MessageSubmissionWriter,
type AgentStreamWriter,
type AttachmentPersister,
type ComposerCancelClient,
type ComposerSendClient,
@@ -178,16 +168,14 @@ interface FakeSendCall {
}
function createFakeSendClient(
options: { rejection?: Error; beforeRejection?: (call: FakeSendCall) => void } = {},
options: { rejection?: Error } = {},
): ComposerSendClient & { calls: FakeSendCall[] } {
const calls: FakeSendCall[] = [];
return {
calls,
sendAgentMessage: async (agentId, text, opts) => {
const call = { agentId, text, options: opts };
calls.push(call);
calls.push({ agentId, text, options: opts });
if (options.rejection) {
options.beforeRejection?.(call);
throw options.rejection;
}
},
@@ -195,7 +183,7 @@ function createFakeSendClient(
};
}
interface FakeStream extends MessageSubmissionWriter {
interface FakeStream extends AgentStreamWriter {
head: Map<string, StreamItem[]>;
tail: Map<string, StreamItem[]>;
}
@@ -204,70 +192,18 @@ function createFakeStream(initialHead: Map<string, StreamItem[]> = new Map()): F
const fake: FakeStream = {
head: new Map(initialHead),
tail: new Map(),
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,
}),
});
getTail: (agentId) => fake.tail.get(agentId),
getHead: (agentId) => fake.head.get(agentId),
setHead: (updater) => {
fake.head = updater(fake.head);
},
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;
setTail: (updater) => {
fake.tail = updater(fake.tail);
},
};
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[]> } {
@@ -401,7 +337,7 @@ describe("pickAndPersistImages", () => {
});
describe("dispatchComposerAgentMessage", () => {
it("removes the submitted prompt when the host rejects it", async () => {
it("removes the optimistic prompt when the host rejects it", async () => {
const rejection = new Error("Host rejected prompt");
const client = createFakeSendClient({ rejection });
const stream = createFakeStream();
@@ -413,54 +349,14 @@ describe("dispatchComposerAgentMessage", () => {
text: "rejected prompt",
attachments: [],
encodeImages: passthroughEncodeImages,
submission: stream,
stream,
}),
).rejects.toBe(rejection);
expect(stream.head.get("agent")).toEqual([]);
expect(stream.head.get("agent")).toBeUndefined();
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();
@@ -475,7 +371,7 @@ describe("dispatchComposerAgentMessage", () => {
{ kind: "github_pr", item: prItem },
],
encodeImages: passthroughEncodeImages,
submission: stream,
stream,
});
expect(client.calls).toHaveLength(1);
@@ -497,7 +393,7 @@ describe("dispatchComposerAgentMessage", () => {
},
]);
expect(stream.head.get("agent")).toEqual([]);
expect(stream.head.get("agent")).toBeUndefined();
const tail = stream.tail.get("agent");
expect(tail).toHaveLength(1);
const userMessage = tail?.[0] as Extract<StreamItem, { kind: "user_message" }>;
@@ -506,8 +402,7 @@ describe("dispatchComposerAgentMessage", () => {
expect(userMessage.images).toEqual([image]);
expect(userMessage.attachments).toEqual(call.options.attachments);
expect(userMessage.id).toBe(call.options.messageId);
expect(userMessage.clientMessageId).toBe(call.options.messageId);
expect(userMessage.messageId).toBeUndefined();
expect(userMessage.optimistic).toBe(true);
});
it("can send legacy GitHub attachment payloads for old daemons", async () => {
@@ -521,7 +416,7 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [{ kind: "forge_change_request", item: prItem }],
attachmentSubmitFormat: "legacy-github",
encodeImages: passthroughEncodeImages,
submission: stream,
stream,
});
expect(client.calls[0].options.attachments).toEqual([
@@ -554,11 +449,11 @@ describe("dispatchComposerAgentMessage", () => {
text: "next message",
attachments: [],
encodeImages: passthroughEncodeImages,
submission: stream,
stream,
});
expect(stream.head.get("agent")).toHaveLength(2);
expect(stream.tail.get("agent")).toEqual([]);
expect(stream.tail.get("agent")).toBeUndefined();
});
it("submits empty wire arrays when no attachments are provided", async () => {
@@ -571,7 +466,7 @@ describe("dispatchComposerAgentMessage", () => {
text: "plain message",
attachments: [],
encodeImages: passthroughEncodeImages,
submission: stream,
stream,
});
expect(client.calls[0]?.options).toMatchObject({
@@ -591,7 +486,7 @@ describe("dispatchComposerAgentMessage", () => {
text: "review this",
attachments: [review],
encodeImages: passthroughEncodeImages,
submission: stream,
stream,
});
expect(client.calls[0]?.options.attachments).toEqual([review.attachment]);
@@ -609,7 +504,7 @@ describe("dispatchComposerAgentMessage", () => {
text: "inspect element",
attachments: [browserElement],
encodeImages: passthroughEncodeImages,
submission: stream,
stream,
});
expect(client.calls[0]?.options.attachments).toEqual([

View File

@@ -12,8 +12,13 @@ import {
splitComposerAttachmentsForSubmit,
type ComposerAttachmentSubmitFormat,
} from "@/composer/attachments/submit";
import { createUserMessage, generateMessageId, type UserMessageItem } from "@/types/stream";
import type { MessageSubmissionRejectionOutcome } from "@/composer/submission/model";
import {
appendOptimisticUserMessageToStream,
buildOptimisticUserMessage,
generateMessageId,
type StreamItem,
type UserMessageItem,
} from "@/types/stream";
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
import { i18n } from "@/i18n/i18next";
@@ -46,7 +51,7 @@ export interface ComposerSendClient {
images: Array<{ data: string; mimeType: string }>;
attachments: ReturnType<typeof splitComposerAttachmentsForSubmit>["attachments"];
},
) => Promise<void | { outOfBand?: boolean }>;
) => Promise<void>;
uploadFile: (input: { fileName: string; mimeType: string; bytes: Uint8Array }) => Promise<{
requestId: string;
file: {
@@ -65,10 +70,11 @@ export interface ComposerCancelClient {
cancelAgent: (agentId: string) => Promise<void> | 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 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 QueueWriter {
@@ -163,7 +169,7 @@ export interface DispatchComposerAgentMessageInput {
encodeImages: (
images: AttachmentMetadata[],
) => Promise<Array<{ data: string; mimeType: string }> | undefined>;
submission: MessageSubmissionWriter;
stream: AgentStreamWriter;
}
export async function dispatchComposerAgentMessage(
@@ -172,30 +178,60 @@ export async function dispatchComposerAgentMessage(
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments, {
format: input.attachmentSubmitFormat,
});
const clientMessageId = generateMessageId();
const userMessage = createUserMessage({
clientMessageId,
const messageId = generateMessageId();
const userMessage = buildOptimisticUserMessage({
id: messageId,
text: input.text,
timestamp: new Date(),
images: wirePayload.images,
attachments: wirePayload.attachments,
});
input.submission.begin(input.agentId, userMessage);
const rollbackOptimisticMessage = appendUserMessageToStream(
input.agentId,
userMessage,
input.stream,
);
try {
const imagesData = await input.encodeImages(wirePayload.images);
const result = await input.client.sendAgentMessage(input.agentId, input.text, {
messageId: clientMessageId,
await input.client.sendAgentMessage(input.agentId, input.text, {
messageId,
images: imagesData ?? [],
attachments: wirePayload.attachments,
});
input.submission.accept(input.agentId, clientMessageId, result?.outOfBand);
} catch (error) {
const outcome = input.submission.reject(input.agentId, clientMessageId);
if (outcome === "accepted") return;
rollbackOptimisticMessage();
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;

View File

@@ -13,7 +13,7 @@ describe("useDraftAgentCreateFlow", () => {
useCreateFlowStore.setState({ pendingByDraftId: {} });
});
it("renders a prepared new-workspace submission before continuing it", async () => {
it("renders a prepared new-workspace create attempt as optimistic chat 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.submittedStreamItems).toEqual([
expect(result.current.optimisticStreamItems).toEqual([
{
kind: "user_message",
id: "msg-prepared",
clientMessageId: "msg-prepared",
text: "build this",
timestamp: attempt.timestamp,
optimistic: true,
images: [image],
attachments: [attachment],
},

View File

@@ -8,13 +8,12 @@ import {
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore } from "@/stores/session-store";
import {
createUserMessage,
buildOptimisticUserMessage,
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[] = [];
@@ -134,7 +133,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : "";
const isSubmitting = machine.tag === "creating";
const submittedStreamItems = useMemo<StreamItem[]>(() => {
const optimisticStreamItems = useMemo<StreamItem[]>(() => {
if (machine.tag !== "creating") {
return EMPTY_STREAM_ITEMS;
}
@@ -148,8 +147,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
}
return [
createUserMessage({
clientMessageId: machine.attempt.clientMessageId,
buildOptimisticUserMessage({
id: machine.attempt.clientMessageId,
text: machine.attempt.text,
timestamp: machine.attempt.timestamp,
images: machine.attempt.images,
@@ -157,15 +156,6 @@ 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") {
@@ -205,8 +195,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
handoffCreatedAgentUserMessage(
pendingServerId,
createResult.agentId,
createUserMessage({
clientMessageId: attempt.clientMessageId,
buildOptimisticUserMessage({
id: attempt.clientMessageId,
text: attempt.text,
timestamp: attempt.timestamp,
images: attempt.images,
@@ -336,8 +326,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
machine,
formErrorMessage,
isSubmitting,
submittedStreamItems,
pendingMessageSubmissions,
optimisticStreamItems,
draftAgent,
handleCreateFromInput,
continueCreateFromAttempt,

View File

@@ -479,8 +479,7 @@ export function WorkspaceDraftAgentTab({
const {
formErrorMessage,
isSubmitting,
submittedStreamItems,
pendingMessageSubmissions,
optimisticStreamItems,
draftAgent,
handleCreateFromInput,
continueCreateFromAttempt,
@@ -643,8 +642,7 @@ export function WorkspaceDraftAgentTab({
agentId={tabId}
serverId={serverId}
context={draftAgent}
streamItems={submittedStreamItems}
pendingMessageSubmissions={pendingMessageSubmissions}
streamItems={optimisticStreamItems}
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>

View File

@@ -64,6 +64,7 @@ import {
sendQueuedComposerMessageNow,
toggleGithubAttachmentFromPicker,
uploadFileAttachments,
type AgentStreamWriter,
type QueueWriter,
type QueuedComposerMessage,
} from "@/composer/actions";
@@ -90,7 +91,6 @@ 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,6 +1079,8 @@ 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);
@@ -1281,6 +1283,12 @@ 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,
@@ -1290,11 +1298,19 @@ export function Composer({
supportsForgeAttachments: supportsForgeSearch,
}),
encodeImages,
submission: createMessageSubmissionWriter(serverId),
stream,
});
onAttentionPromptSend?.();
};
}, [client, onAttentionPromptSend, serverId, supportsForgeSearch, t]);
}, [
client,
onAttentionPromptSend,
serverId,
setAgentStreamTail,
setAgentStreamHead,
supportsForgeSearch,
t,
]);
useEffect(() => {
onSubmitMessageRef.current = onSubmitMessage;

View File

@@ -1,123 +0,0 @@
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");
});
});

View File

@@ -1,108 +0,0 @@
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),
};
}

View File

@@ -1,20 +0,0 @@
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),
};
}

View File

@@ -51,7 +51,7 @@ export async function submitAgentInput<TAttachment>(
return "queued";
}
// Clear immediately so the submitted timeline row and composer state stay in sync.
// Clear immediately so optimistic stream updates and composer state stay in sync.
if (shouldClearOnSubmit) {
input.setUserInput("");
input.setAttachments([]);

View File

@@ -53,7 +53,6 @@ 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";
@@ -192,7 +191,9 @@ type WorkspaceSetupProgressPayload = Extract<
type SessionStoreActions = ReturnType<typeof useSessionStore.getState>;
type SetInitializingAgents = SessionStoreActions["setInitializingAgents"];
type SetAgentStreamState = SessionStoreActions["setAgentStreamState"];
type SetAgentStreamTail = SessionStoreActions["setAgentStreamTail"];
type SetAgentStreamHead = SessionStoreActions["setAgentStreamHead"];
type ClearAgentStreamHead = SessionStoreActions["clearAgentStreamHead"];
type SetAgentTimelineCursor = SessionStoreActions["setAgentTimelineCursor"];
type MarkAgentHistorySynchronized = SessionStoreActions["markAgentHistorySynchronized"];
type SetAgentAuthoritativeHistoryApplied =
@@ -235,7 +236,9 @@ function applyTimelineStreamPatches(input: {
serverId: string;
currentTail: StreamItem[];
currentHead: StreamItem[];
setAgentStreamState: SetAgentStreamState;
setAgentStreamTail: SetAgentStreamTail;
setAgentStreamHead: SetAgentStreamHead;
clearAgentStreamHead: ClearAgentStreamHead;
setAgentTimelineCursor: SetAgentTimelineCursor;
}): void {
const {
@@ -244,24 +247,32 @@ function applyTimelineStreamPatches(input: {
serverId,
currentTail,
currentHead,
setAgentStreamState,
setAgentStreamTail,
setAgentStreamHead,
clearAgentStreamHead,
setAgentTimelineCursor,
} = input;
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.tail !== currentTail) {
setAgentStreamTail(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, result.tail);
return next;
});
}
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);
@@ -647,9 +658,6 @@ 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) {
@@ -669,7 +677,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
isInitializing,
hasActiveInitDeferred,
initRequestDirection: activeInitDeferred?.requestDirection ?? "tail",
sendingClientMessageIds,
});
if (result.error) {
@@ -689,7 +696,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
serverId,
currentTail,
currentHead,
setAgentStreamState,
setAgentStreamTail,
setAgentStreamHead,
clearAgentStreamHead,
setAgentTimelineCursor,
});
@@ -711,11 +720,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
});
},
[
clearAgentStreamHead,
markAgentHistorySynchronized,
recoverTimelineGap,
serverId,
setAgentAuthoritativeHistoryApplied,
setAgentStreamState,
setAgentStreamHead,
setAgentStreamTail,
setAgentTimelineCursor,
setAgentTimelineHasOlder,
setInitializingAgents,
@@ -797,6 +808,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
serverId,
setAgentStreamState,
setAgentTimelineCursor,
setAgents,
recoverTimelineGap,
});
@@ -813,6 +825,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
) {
voiceRuntime?.onTurnEvent(serverId, agentId, event.type);
}
agentStreamReducerQueue.enqueue(agentId, {
event: streamEvent,
seq,

View File

@@ -25,7 +25,6 @@ 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 {
@@ -443,7 +442,6 @@ 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[] = [];
@@ -1290,11 +1288,6 @@ 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,
@@ -1334,7 +1327,6 @@ const AgentStreamSection = memo(function AgentStreamSection({
routeBottomAnchorRequest={routeBottomAnchorRequest}
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
toast={toast}
pendingMessageSubmissions={pendingMessageSubmissions}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
);

View File

@@ -80,16 +80,13 @@ class FakeDaemonClient {
this.setConnectionState({ status: "disconnected", reason: "client_closed" });
}
async sendAgentMessage(
...args: Parameters<DaemonClient["sendAgentMessage"]>
): ReturnType<DaemonClient["sendAgentMessage"]> {
async sendAgentMessage(...args: Parameters<DaemonClient["sendAgentMessage"]>): Promise<void> {
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> {
@@ -2262,66 +2259,6 @@ 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();

View File

@@ -49,9 +49,11 @@ import {
} from "@/data/push-router";
import { mountBrowserAutomationDaemonClientHandler } from "@/browser-automation/handler";
import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules";
import { dispatchComposerAgentMessage, sendQueuedComposerMessageNow } from "@/composer/actions";
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit";
import { sendQueuedComposerMessageNow } from "@/composer/actions";
import {
resolveComposerAttachmentSubmitFormat,
splitComposerAttachmentsForSubmit,
} from "@/composer/attachments/submit";
import { encodeImages } from "@/utils/encode-images";
import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/directory-sync";
import { ReplicaCache } from "@/runtime/replica-cache";
@@ -2069,16 +2071,14 @@ export class HostRuntimeStore {
submitMessage: async ({ text, attachments }) => {
const supportsForgeAttachments =
useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch === true;
await dispatchComposerAgentMessage({
client,
agentId,
text,
attachments,
attachmentSubmitFormat: resolveComposerAttachmentSubmitFormat({
supportsForgeAttachments,
}),
encodeImages,
submission: createMessageSubmissionWriter(serverId),
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,
});
},
})

View File

@@ -19,10 +19,9 @@ 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 = 2;
const CACHE_VERSION = 1;
const PERSIST_DELAY_MS = 750;
const MAX_TIMELINE_ITEMS = 50;
const MAX_CACHE_BYTES = 1024 * 1024;
@@ -371,22 +370,7 @@ export class ReplicaCache {
(workspace) => workspace.workspaceDirectory === focusedAgent.cwd,
))
: 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 items = focusedAgentId ? session.agentStreamTail.get(focusedAgentId) : undefined;
const timeline =
focusedAgent && items
? {

View File

@@ -109,10 +109,7 @@ export const useCreateFlowStore = create<CreateFlowState>((set) => ({
set((state) => {
const next = Object.fromEntries(
Object.entries(state.pendingByDraftId).filter(
([, pending]) =>
pending.lifecycle !== "sent" ||
pending.serverId !== serverId ||
pending.agentId !== agentId,
([, pending]) => pending.serverId !== serverId || pending.agentId !== agentId,
),
);
if (Object.keys(next).length === Object.keys(state.pendingByDraftId).length) {

View File

@@ -5,21 +5,10 @@ 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";
@@ -379,7 +368,6 @@ 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>;
@@ -471,28 +459,8 @@ interface SessionStoreActions {
setAgentStreamState: (
serverId: string,
agentId: string,
state: {
tail?: StreamItem[];
head?: StreamItem[];
acknowledgedClientMessageIds?: readonly string[];
},
state: { tail?: StreamItem[]; head?: StreamItem[] },
) => 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,
@@ -599,27 +567,6 @@ 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,
@@ -641,7 +588,6 @@ function createInitialSessionState(
currentAssistantMessage: "",
agentStreamTail: new Map(),
agentStreamHead: new Map(),
messageSubmissions: new Map(),
agentTimelineCursor: new Map(),
agentTimelineHasOlder: new Map(),
agentTimelineOlderFetchInFlight: new Map(),
@@ -1101,27 +1047,10 @@ export const useSessionStore = create<SessionStore>()(
}
}
const currentSubmissions = session.messageSubmissions.get(agentId) ?? [];
const observedSubmissions = observeMessageSubmissionCanonical(
currentSubmissions,
state.acknowledgedClientMessageIds ?? [],
);
const changedSubmissions = observedSubmissions !== currentSubmissions;
if (!changedTail && !changedHead && !changedSubmissions) {
if (!changedTail && !changedHead) {
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: {
@@ -1130,129 +1059,12 @@ 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) => {
@@ -1486,12 +1298,7 @@ export const useSessionStore = create<SessionStore>()(
return prev;
}
const nextAgents = typeof agents === "function" ? agents(session.agents) : agents;
const messageSubmissions = applyRunningAgentsToAcceptedSubmissions({
previousAgents: session.agents,
nextAgents,
submissions: session.messageSubmissions,
});
if (session.agents === nextAgents && session.messageSubmissions === messageSubmissions) {
if (session.agents === nextAgents) {
return prev;
}
return {
@@ -1501,11 +1308,10 @@ export const useSessionStore = create<SessionStore>()(
[serverId]: {
...session,
agents: nextAgents,
messageSubmissions,
workspaceAgentActivity:
nextAgents === session.agents
? session.workspaceAgentActivity
: buildWorkspaceAgentActivityIndex(nextAgents, session.workspaceAgentActivity),
workspaceAgentActivity: buildWorkspaceAgentActivityIndex(
nextAgents,
session.workspaceAgentActivity,
),
},
},
};

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
import {
createUserMessage,
buildOptimisticUserMessage,
hydrateStreamState,
type AgentToolCallItem,
type StreamItem,
@@ -119,12 +119,12 @@ function makeAssistantItem(
};
}
function makeSubmittedUserMessage(
function makeOptimisticUserMessage(
text: string,
id = `submitted-${text.length}`,
id = `optimistic-${text.length}`,
): Extract<StreamItem, { kind: "user_message" }> {
return createUserMessage({
clientMessageId: id,
return buildOptimisticUserMessage({
id,
text,
timestamp: new Date(1000),
});
@@ -172,7 +172,6 @@ const baseTimelineInput: ProcessTimelineResponseInput = {
isInitializing: false,
hasActiveInitDeferred: false,
initRequestDirection: "tail",
sendingClientMessageIds: [],
};
const baseStreamInput: ProcessAgentStreamEventInput = {
@@ -182,6 +181,7 @@ const baseStreamInput: ProcessAgentStreamEventInput = {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
timestamp: new Date(2000),
};
@@ -293,75 +293,6 @@ 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,
@@ -390,12 +321,12 @@ describe("processTimelineResponse", () => {
expect(assistant?.timestamp.toISOString()).toBe("2025-01-01T12:00:04.000Z");
});
it("reconciles a submitted user message during tail replacement", () => {
it("reconciles an optimistic user message during tail replacement", () => {
const image = {
id: "submitted-image",
id: "optimistic-image",
mimeType: "image/png",
storageType: "web-indexeddb" as const,
storageKey: "submitted-image",
storageKey: "optimistic-image",
createdAt: 1000,
};
const attachment = {
@@ -404,8 +335,8 @@ describe("processTimelineResponse", () => {
text: "attached context",
title: "context.txt",
};
const submitted = createUserMessage({
clientMessageId: "submitted-create-user",
const optimistic = buildOptimisticUserMessage({
id: "optimistic-create-user",
text: "Analyze this",
timestamp: new Date(1000),
images: [image],
@@ -414,7 +345,7 @@ describe("processTimelineResponse", () => {
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [submitted],
currentTail: [optimistic],
payload: {
...baseTimelineInput.payload,
reset: true,
@@ -427,7 +358,6 @@ describe("processTimelineResponse", () => {
type: "user_message",
text: "server-rendered attachment text",
messageId: "canonical-create-user",
clientMessageId: "submitted-create-user",
},
},
],
@@ -437,14 +367,13 @@ describe("processTimelineResponse", () => {
const userMessages = result.tail.filter((item) => item.kind === "user_message");
expect(userMessages).toHaveLength(1);
expect(userMessages[0]).toMatchObject({
id: "submitted-create-user",
clientMessageId: "submitted-create-user",
messageId: "canonical-create-user",
id: "canonical-create-user",
text: "Analyze this",
timestamp: new Date(1000),
images: [image],
attachments: [attachment],
});
expect(userMessages[0]?.optimistic).toBeUndefined();
const repeated = processTimelineResponse({
...baseTimelineInput,
@@ -461,7 +390,6 @@ describe("processTimelineResponse", () => {
type: "user_message",
text: "server-rendered attachment text",
messageId: "canonical-create-user",
clientMessageId: "submitted-create-user",
},
},
],
@@ -471,93 +399,24 @@ describe("processTimelineResponse", () => {
expect(repeated.tail.filter((item) => item.kind === "user_message")).toEqual(userMessages);
});
it("keeps an unmatched submitted user message during tail replacement", () => {
const submitted = makeSubmittedUserMessage("still sending", "submitted-unmatched");
it("keeps an unmatched optimistic user message during tail replacement", () => {
const optimistic = makeOptimisticUserMessage("still sending", "optimistic-unmatched");
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [submitted],
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
sendingClientMessageIds: ["submitted-unmatched"],
currentTail: [optimistic],
payload: {
...baseTimelineInput.payload,
reset: true,
epoch: "epoch-2",
entries: [],
},
});
expect(result.tail).toEqual([submitted]);
expect(result.tail).toEqual([optimistic]);
});
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");
it("does not move an unmatched submission during timeline replacement", () => {
const unmatched = makeOptimisticUserMessage("first submission", "client-first");
const acknowledged: StreamItem[] = [
{
kind: "user_message",
@@ -578,7 +437,6 @@ describe("processTimelineResponse", () => {
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [unmatched, ...acknowledged],
sendingClientMessageIds: ["client-first"],
payload: {
...baseTimelineInput.payload,
reset: true,
@@ -604,10 +462,10 @@ describe("processTimelineResponse", () => {
},
},
{
...makeTimelineEntry(4, "response to canonical submissions"),
...makeTimelineEntry(4, "response to all three submissions"),
item: {
type: "assistant_message",
text: "response to canonical submissions",
text: "response to all three submissions",
messageId: "assistant-response",
},
},
@@ -622,14 +480,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 canonical submissions",
text: "response to all three submissions",
},
{ kind: "user_message", id: "client-first", text: "first submission" },
]);
});
@@ -762,11 +620,11 @@ describe("processTimelineResponse", () => {
startSeq: 1,
endSeq: 1,
};
const submitted = makeSubmittedUserMessage("sent while catching up", "submitted-after");
const optimistic = makeOptimisticUserMessage("sent while catching up", "optimistic-after");
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [submitted],
currentTail: [optimistic],
currentCursor: existingCursor,
payload: {
...baseTimelineInput.payload,
@@ -786,19 +644,16 @@ describe("processTimelineResponse", () => {
const userMessages = result.tail.filter((item) => item.kind === "user_message");
expect(userMessages).toHaveLength(1);
expect(userMessages[0]).toMatchObject({
id: "submitted-after",
clientMessageId: "submitted-after",
messageId: "canonical-after",
});
expect(userMessages[0]?.id).toBe("canonical-after");
expect(userMessages[0]?.optimistic).toBeUndefined();
});
it("reconciles a submitted user message by client message id", () => {
const submitted = makeSubmittedUserMessage("local presentation", "client-message");
it("reconciles an optimistic user message by client message id", () => {
const optimistic = makeOptimisticUserMessage("local presentation", "client-message");
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [submitted],
currentTail: [optimistic],
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
payload: {
...baseTimelineInput.payload,
@@ -820,21 +675,20 @@ describe("processTimelineResponse", () => {
const userMessages = result.tail.filter((item) => item.kind === "user_message");
expect(userMessages).toEqual([
expect.objectContaining({
id: "client-message",
id: "provider-message",
clientMessageId: "client-message",
messageId: "provider-message",
text: "local presentation",
}),
]);
expect(result.acknowledgedClientMessageIds).toEqual(["client-message"]);
expect(userMessages[0]?.optimistic).toBeUndefined();
});
it("reconciles multiple submitted user messages in canonical order", () => {
it("reconciles multiple optimistic user messages in canonical order", () => {
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [
makeSubmittedUserMessage("first prompt", "submitted-first"),
makeSubmittedUserMessage("second prompt", "submitted-second"),
makeOptimisticUserMessage("first prompt", "optimistic-first"),
makeOptimisticUserMessage("second prompt", "optimistic-second"),
],
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
payload: {
@@ -845,14 +699,14 @@ describe("processTimelineResponse", () => {
entries: [
{
...makeTimelineEntry(2, "first prompt", "user_message"),
item: { type: "user_message", text: "first prompt", messageId: "submitted-first" },
item: { type: "user_message", text: "first prompt", messageId: "optimistic-first" },
},
{
...makeTimelineEntry(3, "second prompt", "user_message"),
item: {
type: "user_message",
text: "second prompt",
messageId: "submitted-second",
messageId: "optimistic-second",
},
},
],
@@ -862,15 +716,15 @@ describe("processTimelineResponse", () => {
expect(
result.tail
.filter((item) => item.kind === "user_message")
.map((item) => ({ id: item.id, text: item.text, messageId: item.messageId })),
.map((item) => ({ id: item.id, text: item.text, optimistic: item.optimistic })),
).toEqual([
{ id: "submitted-first", text: "first prompt", messageId: "submitted-first" },
{ id: "submitted-second", text: "second prompt", messageId: "submitted-second" },
{ id: "optimistic-first", text: "first prompt", optimistic: undefined },
{ id: "optimistic-second", text: "second prompt", optimistic: undefined },
]);
});
it("keeps a tail submitted prompt before a reconciled live assistant head", () => {
const prompt = makeSubmittedUserMessage("new prompt", "submitted-new-prompt");
it("keeps a tail optimistic prompt before a reconciled live assistant head", () => {
const prompt = makeOptimisticUserMessage("new prompt", "optimistic-new-prompt");
const result = processTimelineResponse({
...baseTimelineInput,
@@ -909,8 +763,8 @@ describe("processTimelineResponse", () => {
).toEqual(["new prompt", "Hello"]);
});
it("keeps a tail submitted prompt before a live head flushed by catch-up", () => {
const prompt = makeSubmittedUserMessage("new prompt", "submitted-new-prompt");
it("keeps a tail optimistic prompt before a live head flushed by catch-up", () => {
const prompt = makeOptimisticUserMessage("new prompt", "optimistic-new-prompt");
const result = processTimelineResponse({
...baseTimelineInput,
@@ -981,6 +835,7 @@ describe("processTimelineResponse", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
const result = processTimelineResponse({
@@ -1058,6 +913,7 @@ 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);
@@ -1105,6 +961,7 @@ describe("processTimelineResponse", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
const result = processTimelineResponse({
@@ -1143,7 +1000,7 @@ describe("processTimelineResponse", () => {
});
it("does not move a submitted prompt when catch-up history arrives", () => {
const prompt = makeSubmittedUserMessage("New prompt", "new-prompt");
const prompt = makeOptimisticUserMessage("New prompt", "new-prompt");
const result = processTimelineResponse({
...baseTimelineInput,
@@ -1167,7 +1024,7 @@ describe("processTimelineResponse", () => {
});
it("does not move an unmatched head prompt when catch-up history arrives", () => {
const prompt = makeSubmittedUserMessage("New prompt", "new-prompt");
const prompt = makeOptimisticUserMessage("New prompt", "new-prompt");
const result = processTimelineResponse({
...baseTimelineInput,
@@ -1195,8 +1052,8 @@ describe("processTimelineResponse", () => {
]);
});
it("moves an acknowledged head prompt to its catch-up sequence position", () => {
const prompt = makeSubmittedUserMessage("New prompt", "new-prompt");
it("acknowledges a head prompt in place while catch-up history arrives", () => {
const prompt = makeOptimisticUserMessage("New prompt", "new-prompt");
const result = processTimelineResponse({
...baseTimelineInput,
@@ -1227,18 +1084,18 @@ describe("processTimelineResponse", () => {
expect([...result.tail, ...result.head].map((item) => item.kind)).toEqual([
"assistant_message",
"tool_call",
"user_message",
"tool_call",
]);
expect(
[...result.tail, ...result.head]
.filter((item) => item.kind === "user_message")
.map((item) => item.clientMessageId),
).toEqual(["new-prompt"]);
.map((item) => item.optimistic),
).toEqual([undefined]);
});
it("does not move a prompt around unrelated catch-up history", () => {
const prompt = makeSubmittedUserMessage("New prompt", "new-prompt");
const prompt = makeOptimisticUserMessage("New prompt", "new-prompt");
const result = processTimelineResponse({
...baseTimelineInput,
@@ -1284,7 +1141,7 @@ describe("processTimelineResponse", () => {
});
it("does not move a prompt or its live answer around catch-up history", () => {
const prompt = makeSubmittedUserMessage("New prompt", "new-prompt");
const prompt = makeOptimisticUserMessage("New prompt", "new-prompt");
const live = processAgentStreamEvents({
events: [
makeStreamReducerEvent(
@@ -1295,6 +1152,7 @@ describe("processTimelineResponse", () => {
currentTail: [prompt],
currentHead: [],
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
currentAgent: null,
});
const result = processTimelineResponse({
@@ -1328,7 +1186,7 @@ describe("processTimelineResponse", () => {
});
it("does not move a prompt or its live answer around catch-up tool history", () => {
const prompt = makeSubmittedUserMessage("New prompt", "new-prompt");
const prompt = makeOptimisticUserMessage("New prompt", "new-prompt");
const live = processAgentStreamEvents({
events: [
makeStreamReducerEvent(
@@ -1339,6 +1197,7 @@ describe("processTimelineResponse", () => {
currentTail: [prompt],
currentHead: [],
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
currentAgent: null,
});
const result = processTimelineResponse({
@@ -1369,7 +1228,7 @@ describe("processTimelineResponse", () => {
});
it("never moves submitted messages behind a later assistant response", () => {
const unmatched = makeSubmittedUserMessage("first submission", "client-first");
const unmatched = makeOptimisticUserMessage("first submission", "client-first");
const acknowledged: StreamItem[] = [
{
kind: "user_message",
@@ -1427,8 +1286,8 @@ describe("processTimelineResponse", () => {
]);
});
it("places a local prompt after an earlier remote canonical row", () => {
const prompt = makeSubmittedUserMessage("Local prompt", "local-prompt");
it("acknowledges a local prompt in place when a remote user row also arrives", () => {
const prompt = makeOptimisticUserMessage("Local prompt", "local-prompt");
const result = processTimelineResponse({
...baseTimelineInput,
@@ -1461,12 +1320,17 @@ describe("processTimelineResponse", () => {
});
expect(
result.tail.filter((item) => item.kind === "user_message").map((item) => item.text),
).toEqual(["Remote prompt", "Local prompt"]);
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 },
]);
});
it("keeps an unmatched submitted prompt when catch-up contains only a remote user row", () => {
const prompt = makeSubmittedUserMessage("Local 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");
const result = processTimelineResponse({
...baseTimelineInput,
@@ -1491,12 +1355,17 @@ describe("processTimelineResponse", () => {
});
expect(
result.tail.filter((item) => item.kind === "user_message").map((item) => item.text),
).toEqual(["Local prompt", "Remote prompt"]);
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 },
]);
});
it("does not match equal prompt text when canonical client message ids differ", () => {
const prompt = makeSubmittedUserMessage("continue", "local-prompt");
const prompt = makeOptimisticUserMessage("continue", "local-prompt");
const result = processTimelineResponse({
...baseTimelineInput,
@@ -1524,10 +1393,10 @@ describe("processTimelineResponse", () => {
expect(
result.tail
.filter((item) => item.kind === "user_message")
.map((item) => ({ id: item.id, messageId: item.messageId })),
.map((item) => ({ id: item.id, optimistic: item.optimistic })),
).toEqual([
{ id: "local-prompt", messageId: undefined },
{ id: "remote-prompt", messageId: "remote-prompt" },
{ id: "local-prompt", optimistic: true },
{ id: "remote-prompt", optimistic: undefined },
]);
});
@@ -1770,8 +1639,8 @@ describe("processTimelineResponse", () => {
});
});
it("does not reconcile an active submitted user message from a before-page response", () => {
const submitted = makeSubmittedUserMessage("active prompt", "submitted-active");
it("does not reconcile an active optimistic user message from a before-page response", () => {
const optimistic = makeOptimisticUserMessage("active prompt", "optimistic-active");
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 3,
@@ -1780,7 +1649,7 @@ describe("processTimelineResponse", () => {
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [submitted],
currentTail: [optimistic],
currentCursor: existingCursor,
payload: {
...baseTimelineInput.payload,
@@ -1803,8 +1672,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", "submitted-active"]);
expect(userMessages[1]?.clientMessageId).toBe("submitted-active");
expect(userMessages.map((item) => item.id)).toEqual(["canonical-before", "optimistic-active"]);
expect(userMessages[1]?.optimistic).toBe(true);
});
it("leaves the cursor alone when a before page makes no progress", () => {
@@ -1944,68 +1813,6 @@ 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(
@@ -2417,6 +2224,152 @@ 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", () => {
@@ -2429,6 +2382,7 @@ describe("processAgentStreamEvents", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
expect(result.changedTail).toBe(false);
@@ -2456,6 +2410,7 @@ describe("processAgentStreamEvents", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
expect(result.changedTail).toBe(false);
@@ -2478,6 +2433,7 @@ describe("processAgentStreamEvents", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
expect(result.changedTail).toBe(true);
@@ -2495,6 +2451,7 @@ describe("processAgentStreamEvents", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
expect(result.changedTail).toBe(true);
@@ -2512,6 +2469,7 @@ describe("processAgentStreamEvents", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
expect(result.changedTail).toBe(true);
@@ -2543,6 +2501,7 @@ describe("processAgentStreamEvents", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
expect(result.changedTail).toBe(true);
@@ -2568,6 +2527,7 @@ describe("processAgentStreamEvents", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
expect(result.changedTail).toBe(true);
@@ -2592,6 +2552,7 @@ describe("processAgentStreamEvents", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
expect(result.changedTail).toBe(true);
@@ -2683,6 +2644,7 @@ describe("processAgentStreamEvents", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
expect(getAssistantTexts([...result.tail, ...result.head])).toEqual([
@@ -2693,7 +2655,7 @@ describe("processAgentStreamEvents", () => {
]);
});
it("does not derive lifecycle state from a terminal event in a batch", () => {
it("returns the final optimistic lifecycle patch across a batch", () => {
const result = processAgentStreamEvents({
events: [
makeStreamReducerEvent(makeTimelineEvent("Done"), 1),
@@ -2707,10 +2669,21 @@ 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", () => {
@@ -2859,6 +2832,7 @@ describe("createAgentStreamReducerQueue", () => {
currentTail,
currentHead,
currentCursor: undefined,
currentAgent: null,
}),
commit: (agentId, result) => {
currentTail = result.tail;
@@ -2900,6 +2874,7 @@ describe("createAgentStreamReducerQueue", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
}),
commit: (agentId, result) => {
commits.push(
@@ -2929,6 +2904,7 @@ describe("createAgentStreamReducerQueue", () => {
currentTail,
currentHead,
currentCursor,
currentAgent: null,
}),
commit: (_agentId, result) => {
currentTail = result.tail;
@@ -2978,6 +2954,7 @@ describe("createAgentStreamReducerQueue", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
}),
commit: (agentId, result) => {
commits.push(

View File

@@ -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 } from "@/types/stream";
import type { AssistantMessageItem, StreamItem, UserMessageItem } 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,7 +88,6 @@ export interface ProcessTimelineResponseInput {
isInitializing: boolean;
hasActiveInitDeferred: boolean;
initRequestDirection: InitRequestDirection;
sendingClientMessageIds: readonly string[];
}
export interface ProcessTimelineResponseOutput {
@@ -100,7 +99,6 @@ export interface ProcessTimelineResponseOutput {
clearInitializing: boolean;
error: string | null;
sideEffects: TimelineReducerSideEffect[];
acknowledgedClientMessageIds: string[];
}
interface TimelineUnit {
@@ -117,7 +115,6 @@ interface TimelinePathResult {
cursor: TimelineCursor | null | undefined;
cursorChanged: boolean;
sideEffects: TimelineReducerSideEffect[];
acknowledgedClientMessageIds: string[];
}
function classifySessionTimelineSeq({
@@ -204,35 +201,76 @@ 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,
sendingClientMessageIds,
preserveLiveHead,
toHydratedEvents,
} = args;
const { timelineUnits, payload, bootstrapPolicy, currentTail, currentHead, toHydratedEvents } =
args;
const hydratedTail = hydrateStreamState(toHydratedEvents(timelineUnits), { source: "canonical" });
const { tail, head, acknowledgedClientMessageIds } = replaceWithCanonicalStream({
canonical: hydratedTail,
const reconciledTail = reconcileLocalUserPresentationAfterReplace({
canonicalTail: hydratedTail,
previousTail: currentTail,
previousHead: currentHead,
sendingClientMessageIds,
preserveLiveHead,
});
const { tail, head } = preserveReplacePathAssistantHead({
tail: reconciledTail,
currentHead,
});
const cursor: TimelineCursor | null =
payload.startCursor && payload.endCursor
@@ -246,16 +284,136 @@ 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 {
tail,
head,
cursor,
cursorChanged: true,
sideEffects,
acknowledgedClientMessageIds,
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 }
: {}),
};
}
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;
@@ -357,34 +515,6 @@ 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];
@@ -615,24 +745,9 @@ function applyCanonicalForwardUnit(params: {
head: StreamItem[];
unit: TimelineUnit;
epoch: string;
}): { tail: StreamItem[]; head: StreamItem[]; acknowledgedClientMessageIds: string[] } {
}): { tail: StreamItem[]; head: StreamItem[] } {
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, {
@@ -640,7 +755,6 @@ function applyCanonicalForwardUnit(params: {
timelineCursor,
}),
head: params.head,
acknowledgedClientMessageIds: [],
};
}
const replacedHead = replaceLiveAssistantWithProjectedText({
@@ -649,9 +763,7 @@ function applyCanonicalForwardUnit(params: {
timestamp,
timelineCursor,
});
if (replacedHead) {
return { tail: params.tail, head: replacedHead, acknowledgedClientMessageIds: [] };
}
if (replacedHead) return { tail: params.tail, head: replacedHead };
const activeAssistant = params.head.findLast(
(item): item is Extract<StreamItem, { kind: "assistant_message" }> =>
@@ -669,7 +781,6 @@ function applyCanonicalForwardUnit(params: {
source: "canonical",
timelineCursor,
}),
acknowledgedClientMessageIds: [],
};
}
@@ -681,11 +792,7 @@ function applyCanonicalForwardUnit(params: {
source: "canonical",
timelineCursor,
});
return {
tail: applied.tail,
head: applied.head,
acknowledgedClientMessageIds: applied.acknowledgedClientMessageIds ?? [],
};
return { tail: applied.tail, head: applied.head };
}
function applyAcceptedForwardTimelineUnits(params: {
@@ -694,7 +801,7 @@ function applyAcceptedForwardTimelineUnits(params: {
currentTail: StreamItem[];
currentHead: StreamItem[];
currentEndSeq: number | undefined;
}): { tail: StreamItem[]; head: StreamItem[]; acknowledgedClientMessageIds: string[] } {
}): { tail: StreamItem[]; head: StreamItem[] } {
const reconciled = reconcileOverlappingProjectedStreamItems({
tail: params.currentTail,
head: params.currentHead,
@@ -704,19 +811,15 @@ 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, acknowledgedClientMessageIds: [...acknowledgedClientMessageIds] };
return { tail, head };
}
function applyTimelineIncrementalPath(args: {
@@ -732,17 +835,9 @@ 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,
acknowledgedClientMessageIds,
};
return { tail: nextTail, head: nextHead, cursor: nextCursor, cursorChanged, sideEffects };
}
const { acceptedUnits, cursor, gapCursor } =
@@ -779,7 +874,6 @@ function applyTimelineIncrementalPath(args: {
});
nextTail = applied.tail;
nextHead = applied.head;
acknowledgedClientMessageIds = applied.acknowledgedClientMessageIds;
}
}
@@ -798,14 +892,7 @@ function applyTimelineIncrementalPath(args: {
sideEffects.push({ type: "catch_up", cursor: gapCursor });
}
return {
tail: nextTail,
head: nextHead,
cursor: nextCursor,
cursorChanged,
sideEffects,
acknowledgedClientMessageIds,
};
return { tail: nextTail, head: nextHead, cursor: nextCursor, cursorChanged, sideEffects };
}
export function processTimelineResponse(
@@ -819,7 +906,6 @@ export function processTimelineResponse(
isInitializing,
hasActiveInitDeferred,
initRequestDirection,
sendingClientMessageIds,
} = input;
// ------------------------------------------------------------------
@@ -835,7 +921,6 @@ export function processTimelineResponse(
clearInitializing: isInitializing,
error: payload.error,
sideEffects: [],
acknowledgedClientMessageIds: [],
};
}
@@ -882,6 +967,7 @@ export function processTimelineResponse(
hasActiveInitDeferred,
});
const replace = bootstrapPolicy.replace;
const sideEffects: TimelineReducerSideEffect[] = [];
const timelineResult = replace
? applyTimelineReplacePath({
@@ -890,8 +976,6 @@ export function processTimelineResponse(
bootstrapPolicy,
currentTail,
currentHead,
sendingClientMessageIds,
preserveLiveHead: currentCursor?.epoch === payload.epoch,
toHydratedEvents,
})
: applyTimelineIncrementalPath({
@@ -940,7 +1024,6 @@ export function processTimelineResponse(
clearInitializing,
error: null,
sideEffects,
acknowledgedClientMessageIds: timelineResult.acknowledgedClientMessageIds,
};
}
@@ -955,9 +1038,20 @@ 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[];
@@ -965,7 +1059,8 @@ export interface ProcessAgentStreamEventOutput {
changedHead: boolean;
cursor: TimelineCursor | null;
cursorChanged: boolean;
acknowledgedClientMessageIds: string[];
agent: AgentPatch | null;
agentChanged: boolean;
sideEffects: AgentStreamReducerSideEffect[];
}
@@ -984,11 +1079,18 @@ 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">;
@@ -1012,6 +1114,20 @@ 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;
@@ -1085,7 +1201,8 @@ function processTimelineSequencingGate(input: {
export function processAgentStreamEvent(
input: ProcessAgentStreamEventInput,
): ProcessAgentStreamEventOutput {
const { event, seq, epoch, currentTail, currentHead, currentCursor, timestamp } = input;
const { event, seq, epoch, currentTail, currentHead, currentCursor, currentAgent, timestamp } =
input;
const sequencing = processTimelineSequencingGate({ event, seq, epoch, currentCursor });
const timelineCursor =
@@ -1096,7 +1213,7 @@ export function processAgentStreamEvent(
// ------------------------------------------------------------------
// Apply stream event to tail/head
// ------------------------------------------------------------------
const applied = sequencing.shouldApplyStreamEvent
const { tail, head, changedTail, changedHead } = sequencing.shouldApplyStreamEvent
? applyStreamEvent({
tail: sequencing.resetLiveTimeline ? [] : currentTail,
head: sequencing.resetLiveTimeline ? [] : currentHead,
@@ -1112,14 +1229,43 @@ 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: applied.tail,
head: applied.head,
changedTail: applied.changedTail,
changedHead: applied.changedHead,
tail,
head,
changedTail,
changedHead,
cursor: sequencing.nextTimelineCursor,
cursorChanged: sequencing.cursorChanged,
acknowledgedClientMessageIds: applied.acknowledgedClientMessageIds ?? [],
agent: agentPatch,
agentChanged,
sideEffects: sequencing.sideEffects,
};
}
@@ -1130,10 +1276,12 @@ 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;
const acknowledgedClientMessageIds = new Set<string>();
let agentPatch: AgentPatch | null = null;
let agentChanged = false;
const sideEffects: AgentStreamReducerSideEffect[] = [];
for (const reducerEvent of input.events) {
@@ -1144,6 +1292,7 @@ export function processAgentStreamEvents(
currentTail: tail,
currentHead: head,
currentCursor: cursor,
currentAgent: agent,
timestamp: reducerEvent.timestamp,
});
@@ -1152,14 +1301,17 @@ 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 {
@@ -1169,7 +1321,8 @@ export function processAgentStreamEvents(
changedHead,
cursor: cursor ?? null,
cursorChanged,
acknowledgedClientMessageIds: [...acknowledgedClientMessageIds],
agent: agentPatch,
agentChanged,
sideEffects,
};
}
@@ -1252,7 +1405,6 @@ export function createAgentStreamReducerQueue(
interface StreamStatePatch {
tail?: StreamItem[];
head?: StreamItem[];
acknowledgedClientMessageIds?: readonly string[];
}
export interface CreateSessionAgentStreamReducerQueueInput {
@@ -1262,6 +1414,7 @@ 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;
}
@@ -1276,29 +1429,31 @@ function cancelAgentStreamReducerFlush(id: number) {
export function createSessionAgentStreamReducerQueue(
input: CreateSessionAgentStreamReducerQueueInput,
): AgentStreamReducerQueue {
const { serverId, setAgentStreamState, setAgentTimelineCursor, recoverTimelineGap } = input;
const { serverId, setAgentStreamState, setAgentTimelineCursor, setAgents, 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 ||
result.acknowledgedClientMessageIds.length > 0
) {
if (result.changedTail || result.changedHead) {
setAgentStreamState(serverId, agentId, {
...(result.changedTail ? { tail: result.tail } : {}),
...(result.changedHead ? { head: result.head } : {}),
...(result.acknowledgedClientMessageIds.length > 0
? { acknowledgedClientMessageIds: result.acknowledgedClientMessageIds }
: {}),
});
}
@@ -1331,6 +1486,24 @@ 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) {

View File

@@ -22,16 +22,34 @@ function assistant(id: string, timestamp: Date): StreamItem {
}
describe("deriveStreamTurnTiming", () => {
it("starts elapsed time from the submitted prompt", () => {
const submittedAt = new Date("2026-05-15T00:00:00.000Z");
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,
};
const timing = deriveStreamTurnTiming({
agentStatus: "running",
tail: [],
head: [user("submitted", submittedAt)],
head: [optimisticPrompt],
});
assert.equal(timing.runningStartedAt, submittedAt);
assert.equal(timing.runningStartedAt, null);
});
it("uses the last user message as the running turn start", () => {

View File

@@ -9,6 +9,7 @@ export interface TurnTiming {
export interface StreamTurnTiming {
byAssistantId: Map<string, TurnTiming>;
runningStartedAt: Date | null;
isActive: boolean;
}
export function deriveStreamTurnTiming(params: {
@@ -18,6 +19,8 @@ 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[] = [];
@@ -39,6 +42,8 @@ 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;
@@ -60,7 +65,7 @@ export function deriveStreamTurnTiming(params: {
}
const isRunning = params.agentStatus === "running";
const runningStartedAt = isRunning ? currentUserAt : null;
const runningStartedAt = isRunning ? currentAuthoritativeUserAt : null;
if (params.agentStatus !== "running") {
flushCompletedTurn();
}
@@ -68,5 +73,6 @@ export function deriveStreamTurnTiming(params: {
return {
byAssistantId,
runningStartedAt,
isActive: isRunning || currentUserIsOptimistic,
};
}

View File

@@ -4,7 +4,9 @@ import { describe, expect, it } from "vitest";
import {
applyStreamEvent,
createUserMessage,
appendOptimisticUserMessageToStream,
buildOptimisticUserMessage,
clearOptimisticUserMessages,
handoffCreatedAgentUserMessageToStream,
hydrateStreamState,
mergeToolCallDetail,
@@ -12,8 +14,6 @@ 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,109 +21,6 @@ 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",
@@ -998,15 +895,15 @@ describe("stream reducer canonical tool calls", () => {
assert.strictEqual(todos.items[0]?.text, "Task 1");
});
it("preserves submitted user message images when authoritative user message arrives", () => {
it("preserves optimistic user message images when authoritative user message arrives", () => {
const messageId = "msg-user-images";
const submittedTimestamp = new Date("2025-01-01T11:10:00Z");
const submittedImages = [
const optimisticTimestamp = new Date("2025-01-01T11:10:00Z");
const optimisticImages = [
{
id: "att-submitted",
id: "att-optimistic",
mimeType: "image/jpeg",
storageType: "native-file" as const,
storageKey: "/tmp/submitted.jpg",
storageKey: "/tmp/optimistic.jpg",
createdAt: Date.now(),
},
];
@@ -1014,10 +911,10 @@ describe("stream reducer canonical tool calls", () => {
{
kind: "user_message",
id: messageId,
clientMessageId: messageId,
text: "Analyze this image",
timestamp: submittedTimestamp,
images: submittedImages,
timestamp: optimisticTimestamp,
optimistic: true,
images: optimisticImages,
},
];
const event: AgentStreamEventPayload = {
@@ -1036,9 +933,9 @@ describe("stream reducer canonical tool calls", () => {
assert.ok(message);
assert.strictEqual(message.id, messageId);
assert.deepStrictEqual(message.images, submittedImages);
assert.deepStrictEqual(message.images, optimisticImages);
assert.strictEqual(message.text, "Analyze this image");
assert.strictEqual(message.timestamp.getTime(), submittedTimestamp.getTime());
assert.strictEqual(message.timestamp.getTime(), optimisticTimestamp.getTime());
});
it("keeps canonical assistant/user/assistant order during replay", () => {
@@ -1084,7 +981,7 @@ describe("stream reducer canonical tool calls", () => {
);
});
it("keeps live submitted assistant merge behavior", () => {
it("keeps live optimistic assistant merge behavior", () => {
const state: StreamItem[] = [
{
kind: "assistant_message",
@@ -1220,23 +1117,23 @@ describe("turn lifecycle events", () => {
});
it.each(["codex", "opencode", "pi"] satisfies AgentProvider[])(
"replaces a submitted user message when a live %s provider-owned id echo arrives without text matching",
"replaces an optimistic user message when a live %s provider-owned id echo arrives without text matching",
(provider) => {
const submittedTimestamp = new Date("2025-01-01T15:02:00Z");
const optimisticTimestamp = new Date("2025-01-01T15:02:00Z");
const serverTimestamp = new Date("2025-01-01T15:02:01Z");
const submitted: StreamItem = {
const optimistic: StreamItem = {
kind: "user_message",
id: "msg_submitted",
clientMessageId: "msg_submitted",
id: "msg_optimistic",
text: "same user text",
timestamp: submittedTimestamp,
timestamp: optimisticTimestamp,
optimistic: true,
images: [
{
id: "image-1",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "image-1",
createdAt: submittedTimestamp.getTime(),
createdAt: optimisticTimestamp.getTime(),
},
],
attachments: [
@@ -1250,7 +1147,7 @@ describe("turn lifecycle events", () => {
};
const state = reduceStreamUpdate(
[submitted],
[optimistic],
{
type: "timeline",
provider,
@@ -1258,7 +1155,6 @@ describe("turn lifecycle events", () => {
type: "user_message",
text: "server-owned rendered text",
messageId: "provider-owned-id",
clientMessageId: "msg_submitted",
},
},
serverTimestamp,
@@ -1269,28 +1165,28 @@ describe("turn lifecycle events", () => {
assert.strictEqual(userMessages.length, 1);
const userMessage = userMessages[0];
invariant(userMessage?.kind === "user_message");
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);
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);
},
);
it("replaces one submitted plain-text user message with the next live server user message", () => {
const submittedTimestamp = new Date("2025-01-01T15:03:00Z");
it("replaces one optimistic plain-text user message with the next live server user message", () => {
const optimisticTimestamp = new Date("2025-01-01T15:03:00Z");
const serverTimestamp = new Date("2025-01-01T15:03:01Z");
const submitted: StreamItem = {
const optimistic: StreamItem = {
kind: "user_message",
id: "msg_submitted",
clientMessageId: "msg_submitted",
id: "msg_optimistic",
text: "typed plain text",
timestamp: submittedTimestamp,
timestamp: optimisticTimestamp,
optimistic: true,
};
const state = reduceStreamUpdate(
[submitted],
[optimistic],
{
type: "timeline",
provider: "opencode",
@@ -1308,20 +1204,20 @@ describe("turn lifecycle events", () => {
assert.strictEqual(userMessages.length, 1);
const userMessage = userMessages[0];
invariant(userMessage?.kind === "user_message");
assert.strictEqual(userMessage.id, "msg_submitted");
assert.strictEqual(userMessage.messageId, "msg_opencode_provider_owned");
assert.strictEqual(userMessage.id, "msg_opencode_provider_owned");
assert.strictEqual(userMessage.text, "typed plain text");
assert.strictEqual(userMessage.timestamp.getTime(), submittedTimestamp.getTime());
assert.strictEqual(userMessage.timestamp.getTime(), optimisticTimestamp.getTime());
assert.strictEqual(userMessage.optimistic, undefined);
});
it("replaces a submitted image user message with the next canonical server user message", () => {
const submittedTimestamp = new Date("2025-01-01T15:03:10Z");
it("replaces an optimistic image user message with the next canonical server user message", () => {
const optimisticTimestamp = new Date("2025-01-01T15:03:10Z");
const image = {
id: "image-canonical",
mimeType: "image/png",
storageType: "web-indexeddb" as const,
storageKey: "image-canonical",
createdAt: submittedTimestamp.getTime(),
createdAt: optimisticTimestamp.getTime(),
};
const attachment = {
type: "text" as const,
@@ -1329,16 +1225,16 @@ describe("turn lifecycle events", () => {
text: "context",
title: "context.txt",
};
const submitted = createUserMessage({
clientMessageId: "msg_submitted_canonical",
const optimistic = buildOptimisticUserMessage({
id: "msg_optimistic_canonical",
text: "Analyze this",
timestamp: submittedTimestamp,
timestamp: optimisticTimestamp,
images: [image],
attachments: [attachment],
});
const state = reduceStreamUpdate(
[submitted],
[optimistic],
{
type: "timeline",
provider: "claude",
@@ -1346,7 +1242,7 @@ describe("turn lifecycle events", () => {
type: "user_message",
text: "server-rendered attachment text",
messageId: "provider-owned-canonical",
clientMessageId: submitted.id,
clientMessageId: optimistic.id,
},
},
new Date("2025-01-01T15:03:11Z"),
@@ -1357,17 +1253,17 @@ describe("turn lifecycle events", () => {
assert.strictEqual(userMessages.length, 1);
const userMessage = userMessages[0];
invariant(userMessage?.kind === "user_message");
assert.strictEqual(userMessage.id, "msg_submitted_canonical");
assert.strictEqual(userMessage.messageId, "provider-owned-canonical");
assert.strictEqual(userMessage.id, "provider-owned-canonical");
assert.strictEqual(userMessage.text, "Analyze this");
assert.strictEqual(userMessage.timestamp.getTime(), submittedTimestamp.getTime());
assert.strictEqual(userMessage.timestamp.getTime(), optimisticTimestamp.getTime());
assert.strictEqual(userMessage.optimistic, undefined);
assert.deepStrictEqual(userMessage.images, [image]);
assert.deepStrictEqual(userMessage.attachments, [attachment]);
});
it("places submitted user messages through the identity producer", () => {
const submitted = createUserMessage({
clientMessageId: "msg_append_once",
it("places optimistic user messages through one append helper", () => {
const optimistic = buildOptimisticUserMessage({
id: "msg_append_once",
text: "append once",
timestamp: new Date("2025-01-01T15:03:20Z"),
});
@@ -1378,30 +1274,28 @@ describe("turn lifecycle events", () => {
timestamp: new Date("2025-01-01T15:03:19Z"),
};
const first = upsertUserMessageAcrossStream({
const first = appendOptimisticUserMessageToStream({
tail: [],
head: [headItem],
message: submitted,
insert: "head",
presentation: "existing",
message: optimistic,
placement: "active-head",
});
const second = upsertUserMessageAcrossStream({
const second = appendOptimisticUserMessageToStream({
tail: first.tail,
head: first.head,
message: submitted,
insert: "head",
presentation: "existing",
message: optimistic,
placement: "active-head",
});
assert.deepStrictEqual(first.tail, []);
assert.deepStrictEqual(first.head, [headItem, submitted]);
assert.deepStrictEqual(first.head, [headItem, optimistic]);
assert.strictEqual(second.changedHead, false);
assert.strictEqual(second.head, first.head);
});
it("hands rich submitted content to its create message without overwriting an earlier user row", () => {
it("hands rich optimistic content to an authoritative create message without duplicating it", () => {
const timestamp = new Date("2025-01-01T15:03:20Z");
const submitted = createUserMessage({
clientMessageId: "client-user",
const optimistic = buildOptimisticUserMessage({
id: "client-user",
text: "",
timestamp,
images: [
@@ -1423,44 +1317,32 @@ 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: [precedingProviderRow, canonical],
tail: [canonical],
head: [],
message: submitted,
message: optimistic,
});
const repeated = handoffCreatedAgentUserMessageToStream({
tail: handedOff.tail,
head: handedOff.head,
message: submitted,
message: optimistic,
});
assert.deepStrictEqual(handedOff.tail, [
precedingProviderRow,
{
kind: "user_message",
id: "client-user",
clientMessageId: "client-user",
messageId: "provider-user",
text: submitted.text,
timestamp: submitted.timestamp,
images: submitted.images,
attachments: submitted.attachments,
id: "provider-user",
text: optimistic.text,
timestamp: optimistic.timestamp,
images: optimistic.images,
attachments: optimistic.attachments,
},
]);
assert.deepStrictEqual(handedOff.head, []);
@@ -1482,22 +1364,22 @@ describe("turn lifecycle events", () => {
);
assert.deepStrictEqual(
afterNextUser.filter((item) => item.kind === "user_message").map((item) => item.id),
["provider-system-user", "client-user", "provider-next-user"],
["provider-user", "provider-next-user"],
);
});
it("flushes an interrupted head when its submitted prompt becomes canonical", () => {
const submitted: StreamItem = {
it("reconciles an optimistic user message that was pending in the streaming head", () => {
const optimistic: StreamItem = {
kind: "user_message",
id: "msg_head_submitted",
clientMessageId: "msg_head_submitted",
id: "msg_head_optimistic",
text: "plain text in head",
timestamp: new Date("2025-01-01T15:03:02Z"),
optimistic: true,
};
const result = applyStreamEvent({
tail: [],
head: [submitted],
head: [optimistic],
event: {
type: "timeline",
provider: "opencode",
@@ -1511,80 +1393,33 @@ describe("turn lifecycle events", () => {
source: "live",
});
assert.deepStrictEqual(result.head, []);
assert.strictEqual(result.head.length, 0);
const userMessages = result.tail.filter((item) => item.kind === "user_message");
assert.strictEqual(userMessages.length, 1);
assert.strictEqual(userMessages[0]?.id, "msg_head_submitted");
assert.strictEqual(userMessages[0]?.messageId, "provider-owned-head");
assert.strictEqual(userMessages[0]?.id, "provider-owned-head");
assert.strictEqual(userMessages[0]?.optimistic, undefined);
});
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 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");
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 firstSubmitted: StreamItem = {
const firstOptimistic: StreamItem = {
kind: "user_message",
id: "msg_submitted_1",
clientMessageId: "msg_submitted_1",
id: "msg_optimistic_1",
text: "first typed text",
timestamp: submittedTimestamp,
timestamp: optimisticTimestamp,
optimistic: true,
};
const secondSubmitted: StreamItem = {
const secondOptimistic: StreamItem = {
kind: "user_message",
id: "msg_submitted_2",
clientMessageId: "msg_submitted_2",
id: "msg_optimistic_2",
text: "second typed text",
timestamp: new Date("2025-01-01T15:04:00.500Z"),
optimistic: true,
};
const afterFirstEcho = reduceStreamUpdate(
[firstSubmitted, secondSubmitted],
[firstOptimistic, secondOptimistic],
{
type: "timeline",
provider: "opencode",
@@ -1592,7 +1427,6 @@ describe("turn lifecycle events", () => {
type: "user_message",
text: "first server text",
messageId: "provider-owned-first",
clientMessageId: "msg_submitted_1",
},
},
serverTimestamp,
@@ -1607,7 +1441,6 @@ 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"),
@@ -1617,30 +1450,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.messageId]),
userMessages.map((item) => [item.id, item.text, item.optimistic]),
[
["msg_submitted_1", "first typed text", "provider-owned-first"],
["msg_submitted_2", "second typed text", "provider-owned-second"],
["provider-owned-first", "first typed text", undefined],
["provider-owned-second", "second typed text", undefined],
],
);
});
it("does not shift later prompts when an earlier submitted prompt has no canonical echo", () => {
it("does not shift later prompts when an earlier optimistic 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(
@@ -1663,16 +1496,15 @@ describe("turn lifecycle events", () => {
stalePrompt,
{
kind: "user_message",
id: "msg_submitted",
id: "provider-owned-submitted",
clientMessageId: submittedPrompt.id,
messageId: "provider-owned-submitted",
text: submittedPrompt.text,
timestamp: submittedPrompt.timestamp,
},
]);
});
it("appends a live server user message when no submitted user message is pending", () => {
it("appends a live server user message when no optimistic user message is pending", () => {
const state = reduceStreamUpdate(
[],
{
@@ -1691,11 +1523,21 @@ 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("appends a server user message after a rewound local row was removed", () => {
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]);
const state = reduceStreamUpdate(
[],
cleared,
{
type: "timeline",
provider: "opencode",
@@ -1713,6 +1555,7 @@ 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", () => {

View File

@@ -87,440 +87,22 @@ export interface UserMessageItem {
kind: "user_message";
id: string;
clientMessageId?: string;
messageId?: string;
text: string;
timestamp: Date;
optimistic?: true;
images?: UserMessageImageAttachment[];
attachments?: AgentAttachment[];
}
export interface OptimisticUserMessageInput {
id: string;
text: string;
timestamp: Date;
images?: UserMessageImageAttachment[];
attachments?: AgentAttachment[];
}
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 type OptimisticUserMessagePlacement = "tail" | "active-head";
export interface AssistantMessageItem {
kind: "assistant_message";
@@ -655,24 +237,124 @@ 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 {
return upsertUserMessageAcrossStream({
...params,
insert: "tail",
presentation: "incoming",
matchPolicy: "handoff",
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,
});
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[] {
@@ -682,14 +364,37 @@ function appendUserMessage(
}
const chunkSeed = chunk.trim() || chunk;
const nextItem = createUserMessage({
id: messageId ?? createUniqueTimelineId(state, "user", chunkSeed, timestamp),
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,
clientMessageId,
messageId,
text: chunk,
timestamp,
optimistic,
});
return upsertUserMessage(state, nextItem);
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;
}
function appendAssistantMessage(
@@ -721,8 +426,8 @@ function appendAssistantMessage(
return [...state.slice(0, -1), updated];
}
// A submitted user row can follow the streaming assistant during interrupt.
// In that case, look one row further back for the assistant to extend.
// If the last item is a user_message (optimistic append to head during
// interrupt), look one further back for the streaming assistant_message.
const secondLast = state[state.length - 2];
if (
source === "live" &&
@@ -1444,6 +1149,7 @@ export function flushHeadToTail(tail: StreamItem[], head: StreamItem[]): StreamI
if (newItems.length === 0) {
return tail;
}
return [...tail, ...newItems];
}
@@ -1471,7 +1177,8 @@ function shouldFlushHead(input: {
return true;
}
// Find the last streamable item in head (skip trailing non-streamable items).
// Find the last streamable item in head (skip trailing non-streamable
// items like an optimistic user_message appended during interrupt).
let lastStreamable: StreamItem | undefined;
for (let i = head.length - 1; i >= 0; i--) {
if (isStreamableKind(head[i].kind)) {
@@ -1502,41 +1209,6 @@ 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]
: [],
};
}
/**
@@ -1559,13 +1231,12 @@ 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;

View File

@@ -7,7 +7,6 @@ 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(
@@ -65,111 +64,6 @@ 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";

View File

@@ -324,11 +324,6 @@ 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";
@@ -2864,7 +2859,7 @@ export class DaemonClient {
agentId: string,
text: string,
options?: SendMessageOptions,
): Promise<SendMessageResult> {
): Promise<void> {
const requestId = this.createRequestId();
const messageId = options?.messageId ?? crypto.randomUUID();
const message = SessionInboundMessageSchema.parse({
@@ -2893,7 +2888,6 @@ 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> {

View File

@@ -478,9 +478,7 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor
latest = result?.agent ?? null;
return result;
},
send: async (text, options) => {
await daemonClient.sendAgentMessage(id, text, options);
},
send: (text, options) => daemonClient.sendAgentMessage(id, text, options),
archive: async () => {
const result = await daemonClient.archiveAgent(id);
if (latest) {

View File

@@ -3710,8 +3710,6 @@ 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(),
}),
});

View File

@@ -26,7 +26,7 @@ export type AgentLoaderManager = Pick<
| "hydrateTimelineFromProvider"
| "resumeAgentFromPersistence"
> &
Partial<Pick<AgentManager, "touchAgentActivity" | "waitForAgentClose">>;
Partial<Pick<AgentManager, "waitForAgentClose">>;
export interface EnsureAgentLoadedDeps {
agentManager: AgentLoaderManager;
@@ -71,8 +71,7 @@ export async function ensureAgentLoaded(
return inflight.promise;
}
const existing =
deps.agentManager.touchAgentActivity?.(agentId) ?? deps.agentManager.getAgent(agentId);
const existing = deps.agentManager.getAgent(agentId);
if (existing) {
return existing;
}

View File

@@ -35,7 +35,6 @@ import type {
AgentStreamEvent,
AgentTimelineItem,
ImportProviderSessionInput,
ImportProviderSessionContext,
ResolveAgentDefaultModeInput,
} from "./agent-sdk-types.js";
import type { PaseoToolCatalog } from "./tools/types.js";
@@ -1707,7 +1706,6 @@ test("createAgent passes daemon launch env through the provider launch context",
agentId: snapshot.id,
env: {
PASEO_AGENT_ID: snapshot.id,
PASEO_AGENT_CWD: workdir,
},
});
});
@@ -2513,7 +2511,6 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p
agentId: resumed.id,
env: {
PASEO_AGENT_ID: resumed.id,
PASEO_AGENT_CWD: workdir,
},
});
});
@@ -2528,16 +2525,14 @@ test("importProviderSession imports the selected session without listing and pub
class ImportClient extends TestAgentClient {
listCalls = 0;
importInput: unknown = null;
importLaunchContext: AgentLaunchContext | undefined;
async listImportableSessions() {
this.listCalls += 1;
return [];
}
async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) {
async importSession(input: ImportProviderSessionInput) {
this.importInput = input;
this.importLaunchContext = context.launchContext;
return {
session,
config: { provider: "codex" as const, cwd: workdir },
@@ -2617,13 +2612,6 @@ test("importProviderSession imports the selected session without listing and pub
expect(client.listCalls).toBe(0);
expect(client.importInput).toEqual({ providerHandleId: "thread-selected", cwd: workdir });
expect(client.importLaunchContext).toEqual({
agentId: imported.id,
env: {
PASEO_AGENT_ID: imported.id,
PASEO_AGENT_CWD: workdir,
},
});
expect(imported.lifecycle).toBe("idle");
expect(imported.historyPrimed).toBe(true);
expect(manager.getTimeline(imported.id)).toEqual([
@@ -2724,7 +2712,6 @@ test("reloadAgentSession passes daemon launch env through the provider launch co
agentId: snapshot.id,
env: {
PASEO_AGENT_ID: snapshot.id,
PASEO_AGENT_CWD: workdir,
},
});
@@ -2736,7 +2723,6 @@ test("reloadAgentSession passes daemon launch env through the provider launch co
agentId: snapshot.id,
env: {
PASEO_AGENT_ID: snapshot.id,
PASEO_AGENT_CWD: workdir,
},
});
});
@@ -7271,108 +7257,57 @@ test("closeAgent persists one final closed snapshot", async () => {
}
});
test("collectIdleAgents releases an idle runtime and resumes the same agent and timeline", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-collection-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
let activeSession: TestAgentSession | null = null;
const client = new (class extends NativeArchiveRecordingClient {
test("idle agents remain resident until an explicit lifecycle action closes them", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-residency-"));
let closeCount = 0;
let resumeCount = 0;
const client = new (class extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
activeSession = new TestAgentSession(config);
return activeSession;
const recordClose = () => {
closeCount += 1;
};
return new (class extends TestAgentSession {
override async close(): Promise<void> {
recordClose();
}
})(config);
}
override async resumeSession(
handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
resumeCount += 1;
return super.resumeSession(handle, config, launchContext);
}
})();
const manager = new AgentManager({
clients: { codex: client },
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000210",
});
const manager = new AgentManager({ clients: { codex: client }, logger });
try {
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: "workspace-idle-collection",
});
await manager.appendTimelineItem(created.id, {
type: "user_message",
text: "Keep this timeline",
});
activeSession?.pushEvent({
type: "provider_subagent",
provider: "codex",
event: {
type: "upsert",
id: "retained-provider-child",
title: "Retained provider child",
status: "completed",
},
});
await manager.flush();
const timelineBeforeCollection = manager.getTimeline(created.id);
const collection = await manager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
expect(collection).toEqual({
collected: [
{
agentId: created.id,
provider: "codex",
sessionId: created.persistence?.sessionId,
},
],
failures: [],
});
expect(manager.getAgent(created.id)).toBeNull();
expect(client.archivedHandles).toEqual([]);
const stored = await storage.get(created.id);
expect(stored).toMatchObject({
id: created.id,
lastStatus: "closed",
workspaceId: "workspace-idle-collection",
});
expect(stored?.archivedAt).toBeFalsy();
await new Promise((resolve) => setTimeout(resolve, 25));
const resumed = await ensureAgentLoaded(created.id, {
agentManager: manager,
agentStorage: storage,
logger,
});
expect(manager.getAgent(agent.id)?.lifecycle).toBe("idle");
expect(closeCount).toBe(0);
expect(resumed.id).toBe(created.id);
expect(resumed.persistence).toEqual(created.persistence);
expect(manager.getTimeline(created.id)).toEqual(timelineBeforeCollection);
expect(manager.listProviderSubagents(created.id)).toEqual([
expect.objectContaining({
id: "retained-provider-child",
title: "Retained provider child",
status: "completed",
}),
]);
const idleBeforeOpen = resumed.updatedAt;
await ensureAgentLoaded(created.id, {
agentManager: manager,
agentStorage: storage,
logger,
});
await expect(
manager.collectIdleAgents({ cutoff: idleBeforeOpen, protectedAgentIds: new Set() }),
).resolves.toMatchObject({ collected: [] });
await expect(manager.runAgent(created.id, "Continue the same agent")).resolves.toMatchObject({
finalText: "",
canceled: false,
});
expect(manager.getAgent(created.id)?.id).toBe(created.id);
await manager.runAgent(agent.id, "Continue on the resident runtime");
expect(manager.getAgent(agent.id)?.lifecycle).toBe("idle");
expect(resumeCount).toBe(0);
} finally {
await manager.flush().catch(() => undefined);
await storage.flush().catch(() => undefined);
await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))).catch(
() => undefined,
);
rmSync(workdir, { recursive: true, force: true });
}
});
test("archiving an idle-collected parent still cascades to its managed children", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-collected-parent-archive-"));
test("archiving a closed parent still cascades to its managed children", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-closed-parent-archive-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
@@ -7382,7 +7317,7 @@ test("archiving an idle-collected parent still cascades to its managed children"
try {
const parent = await manager.createAgent(
{ provider: "codex", cwd: workdir, title: "Collected parent" },
{ provider: "codex", cwd: workdir, title: "Closed parent" },
undefined,
{ workspaceId: undefined },
);
@@ -7395,10 +7330,7 @@ test("archiving an idle-collected parent still cascades to its managed children"
},
);
await manager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set([child.id]),
});
await manager.closeAgent(parent.id);
await manager.archiveSnapshot(parent.id, new Date().toISOString());
expect((await storage.get(parent.id))?.archivedAt).toEqual(expect.any(String));
@@ -7424,10 +7356,7 @@ test("ensureUnarchivedAgentLoaded does not resume an archived agent", async () =
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
await manager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
await manager.closeAgent(agent.id);
await manager.archiveSnapshot(agent.id, new Date().toISOString());
await expect(
@@ -7466,10 +7395,7 @@ test("ensureUnarchivedAgentLoaded closes a runtime archived while it resumes", a
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
await manager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
await manager.closeAgent(agent.id);
const load = ensureUnarchivedAgentLoaded(agent.id, {
agentManager: manager,
@@ -7512,10 +7438,7 @@ test("ensureUnarchivedAgentLoaded fences an archived agent after joining a share
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
await manager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
await manager.closeAgent(agent.id);
const sharedLoad = ensureAgentLoaded(agent.id, {
agentManager: manager,
@@ -7571,10 +7494,7 @@ test("a shared agent load upgrades provider history hydration to broadcast", asy
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
await manager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
await manager.closeAgent(agent.id);
await manager.deleteAgentState(agent.id);
const events: AgentManagerEvent[] = [];
manager.subscribe((event) => events.push(event), { agentId: agent.id, replayState: false });
@@ -7612,166 +7532,7 @@ test("a shared agent load upgrades provider history hydration to broadcast", asy
}
});
test("collectIdleAgents leaves recent, protected, internal, running, and error agents resident", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-eligibility-"));
const client = new SessionRecordingAgentClient();
const ids = [
"00000000-0000-4000-8000-000000000211",
"00000000-0000-4000-8000-000000000212",
"00000000-0000-4000-8000-000000000213",
"00000000-0000-4000-8000-000000000214",
"00000000-0000-4000-8000-000000000215",
];
const manager = new AgentManager({
clients: { codex: client },
logger,
idFactory: () => ids.shift()!,
});
try {
const recent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
const protectedAgent = await manager.createAgent(
{ provider: "codex", cwd: workdir },
undefined,
{ workspaceId: undefined },
);
const internal = await manager.createAgent(
{ provider: "codex", cwd: workdir, internal: true },
undefined,
{ workspaceId: undefined },
);
const running = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
const failed = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
client.sessions[3]!.pushEvent({
type: "turn_started",
provider: "codex",
turnId: "autonomous-running",
});
client.sessions[4]!.pushEvent({
type: "turn_failed",
provider: "codex",
turnId: "autonomous-failed",
error: "provider failed",
});
await manager.flush();
const recentSweep = await manager.collectIdleAgents({
cutoff: new Date(recent.updatedAt.getTime() - 1),
protectedAgentIds: new Set(),
});
const protectedSweep = await manager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set([protectedAgent.id, recent.id]),
});
expect(recentSweep.collected).toEqual([]);
expect(protectedSweep.collected).toEqual([]);
expect(manager.getAgent(recent.id)?.lifecycle).toBe("idle");
expect(manager.getAgent(protectedAgent.id)?.lifecycle).toBe("idle");
expect(manager.getAgent(internal.id)?.lifecycle).toBe("idle");
expect(manager.getAgent(running.id)?.lifecycle).toBe("running");
expect(manager.getAgent(failed.id)?.lifecycle).toBe("error");
} finally {
await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))).catch(
() => undefined,
);
rmSync(workdir, { recursive: true, force: true });
}
});
test("collectIdleAgents protects an idle parent with a running managed child", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-running-child-"));
const client = new SessionRecordingAgentClient();
const manager = new AgentManager({ clients: { codex: client }, logger });
try {
const parent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
const child = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
labels: { [PARENT_AGENT_ID_LABEL]: parent.id },
workspaceId: undefined,
});
const independent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
client.sessions[1]!.pushEvent({
type: "turn_started",
provider: "codex",
turnId: "managed-child-running",
});
await manager.flush();
const collection = await manager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
expect(collection).toEqual({
collected: [expect.objectContaining({ agentId: independent.id })],
failures: [],
});
expect(manager.getAgent(parent.id)?.lifecycle).toBe("idle");
expect(manager.getAgent(child.id)?.lifecycle).toBe("running");
expect(manager.getAgent(independent.id)).toBeNull();
} finally {
await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))).catch(
() => undefined,
);
rmSync(workdir, { recursive: true, force: true });
}
});
test("collectIdleAgents protects an idle parent with a running provider subagent", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-running-provider-child-"));
const client = new SessionRecordingAgentClient();
const manager = new AgentManager({ clients: { codex: client }, logger });
try {
const parent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
const independent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
client.sessions[0]!.pushEvent({
type: "provider_subagent",
provider: "codex",
event: {
type: "upsert",
id: "provider-child-running",
title: "Provider child",
status: "running",
},
});
await manager.flush();
const collection = await manager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
expect(collection).toEqual({
collected: [expect.objectContaining({ agentId: independent.id })],
failures: [],
});
expect(manager.getAgent(parent.id)?.lifecycle).toBe("idle");
expect(manager.getAgent(independent.id)).toBeNull();
} finally {
await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))).catch(
() => undefined,
);
rmSync(workdir, { recursive: true, force: true });
}
});
test("closed provider subagents do not block collection after resume", async () => {
test("explicit close cancels running provider subagents before resume", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-closed-provider-child-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new SessionRecordingAgentClient();
@@ -7825,11 +7586,6 @@ test("closed provider subagents do not block collection after resume", async ()
expect(manager.getProviderSubagent(parent.id, "provider-child-finishing")?.status).toBe(
"completed",
);
const collection = await manager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
expect(collection.collected).toEqual([expect.objectContaining({ agentId: parent.id })]);
} finally {
await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))).catch(
() => undefined,
@@ -7839,8 +7595,8 @@ test("closed provider subagents do not block collection after resume", async ()
}
});
test("load waits for an in-flight collection close and creates only one resumed runtime", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-close-race-"));
test("load waits for an in-flight explicit close and creates one resumed runtime", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-explicit-close-race-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const closeStarted = deferred<void>();
const closeAllowed = deferred<void>();
@@ -7872,10 +7628,7 @@ test("load waits for an in-flight collection close and creates only one resumed
"00000000-0000-4000-8000-000000000216",
{ workspaceId: undefined },
);
const collection = manager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
const close = manager.closeAgent(created.id);
await closeStarted.promise;
const loads = Promise.all([
ensureAgentLoaded(created.id, { agentManager: manager, agentStorage: storage, logger }),
@@ -7885,18 +7638,58 @@ test("load waits for an in-flight collection close and creates only one resumed
expect(client.resumeCount).toBe(0);
closeAllowed.resolve();
const [first, second] = await loads;
await collection;
await close;
expect(first.id).toBe(created.id);
expect(second.id).toBe(created.id);
expect(client.resumeCount).toBe(1);
} finally {
closeAllowed.resolve();
await manager.closeAgent("00000000-0000-4000-8000-000000000216").catch(() => undefined);
await storage.flush().catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("concurrent explicit closes tear down the runtime once", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-concurrent-close-"));
const closeStarted = deferred<void>();
const closeAllowed = deferred<void>();
let closeCount = 0;
const client = new (class extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
const recordClose = () => {
closeCount += 1;
};
return new (class extends TestAgentSession {
override async close(): Promise<void> {
recordClose();
closeStarted.resolve();
await closeAllowed.promise;
}
})(config);
}
})();
const manager = new AgentManager({ clients: { codex: client }, logger });
try {
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
const firstClose = manager.closeAgent(agent.id);
await closeStarted.promise;
const secondClose = manager.closeAgent(agent.id);
closeAllowed.resolve();
await Promise.all([firstClose, secondClose]);
expect(closeCount).toBe(1);
} finally {
closeAllowed.resolve();
rmSync(workdir, { recursive: true, force: true });
}
});
test("provider close failure still persists and emits a resumable closed agent", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-close-failure-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
@@ -7919,18 +7712,8 @@ test("provider close failure still persists and emits a resumable closed agent",
);
const closed = waitForAgentLifecycle(manager, created.id, "closed");
const collection = await manager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
await expect(manager.closeAgent(created.id)).rejects.toThrow("provider cleanup failed");
await closed;
expect(collection.collected).toEqual([]);
expect(collection.failures).toHaveLength(1);
expect(collection.failures[0]).toMatchObject({
agentId: created.id,
provider: "codex",
error: expect.objectContaining({ message: "provider cleanup failed" }),
});
const stored = await storage.get(created.id);
expect(stored).toMatchObject({ lastStatus: "closed" });
expect(stored?.archivedAt).toBeFalsy();

View File

@@ -394,21 +394,6 @@ export interface AgentMetricsSnapshot {
};
}
export interface IdleAgentCollectionEntry {
agentId: string;
provider: AgentProvider;
sessionId?: string;
}
export interface IdleAgentCollectionFailure extends IdleAgentCollectionEntry {
error: unknown;
}
export interface IdleAgentCollectionResult {
collected: IdleAgentCollectionEntry[];
failures: IdleAgentCollectionFailure[];
}
type ActiveManagedAgent =
| ManagedAgentInitializing
| ManagedAgentIdle
@@ -969,15 +954,6 @@ export class AgentManager {
return agent ? { ...agent } : null;
}
touchAgentActivity(id: string): ManagedAgent | null {
const agent = this.agents?.get(id);
if (!agent) {
return null;
}
this.touchUpdatedAt(agent);
return { ...agent };
}
async waitForAgentClose(agentId: string): Promise<void> {
await this.inFlightAgentCloses?.get(agentId)?.catch(() => undefined);
}
@@ -1047,12 +1023,7 @@ export class AgentManager {
const client = await this.requireAvailableClient({
provider: storedConfig.provider,
});
const launchContext = await this.buildLaunchContext(
resolvedAgentId,
client,
storedConfig.cwd,
options?.env,
);
const launchContext = await this.buildLaunchContext(resolvedAgentId, client, options?.env);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const createOptions = this.buildCreateSessionOptions(options);
const session = await client.createSession(providerLaunchConfig, launchContext, createOptions);
@@ -1130,7 +1101,7 @@ export class AgentManager {
`Provider '${handle.provider}' is not available. Please ensure the CLI is installed.`,
);
}
const launchContext = await this.buildLaunchContext(resolvedAgentId, client, storedConfig.cwd);
const launchContext = await this.buildLaunchContext(resolvedAgentId, client);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const session = await client.resumeSession(
handle,
@@ -1177,7 +1148,7 @@ export class AgentManager {
},
resolvedAgentId,
);
const launchContext = await this.buildLaunchContext(resolvedAgentId, client, storedConfig.cwd);
const launchContext = await this.buildLaunchContext(resolvedAgentId, client);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const imported = await client.importSession(
{
@@ -1258,7 +1229,7 @@ export class AgentManager {
provider,
} as AgentSessionConfig;
const { storedConfig, launchConfig } = await this.prepareSessionConfig(refreshConfig, agentId);
const launchContext = await this.buildLaunchContext(agentId, client, storedConfig.cwd);
const launchContext = await this.buildLaunchContext(agentId, client);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const session = handle
@@ -1441,66 +1412,6 @@ export class AgentManager {
}
}
async collectIdleAgents(options: {
cutoff: Date;
protectedAgentIds: ReadonlySet<string>;
}): Promise<IdleAgentCollectionResult> {
const result: IdleAgentCollectionResult = { collected: [], failures: [] };
for (const agent of Array.from(this.agents.values())) {
const current = this.agents.get(agent.id);
if (!current || !this.isIdleAgentCollectable(current, options)) {
continue;
}
const entry: IdleAgentCollectionEntry = {
agentId: current.id,
provider: current.provider,
...(current.persistence?.sessionId ? { sessionId: current.persistence.sessionId } : {}),
};
try {
await this.closeAgent(current.id);
result.collected.push(entry);
} catch (error) {
result.failures.push({ ...entry, error });
}
}
return result;
}
private isIdleAgentCollectable(
agent: LiveManagedAgent,
options: { cutoff: Date; protectedAgentIds: ReadonlySet<string> },
): agent is ManagedAgentIdle {
return (
agent.lifecycle === "idle" &&
agent.updatedAt.getTime() <= options.cutoff.getTime() &&
!agent.internal &&
!options.protectedAgentIds.has(agent.id) &&
agent.activeForegroundTurnId === null &&
!this.runs.hasRun(agent.id) &&
!agent.pendingReplacement &&
agent.pendingPermissions.size === 0 &&
agent.inFlightPermissionResponses.size === 0 &&
!this.hasRunningChild(agent.id)
);
}
private hasRunningChild(parentAgentId: string): boolean {
for (const agent of this.agents.values()) {
if (
agent.lifecycle === "running" &&
getParentAgentIdFromLabels(agent.labels) === parentAgentId
) {
return true;
}
}
return this.providerSubagents
.list(parentAgentId)
.some((subagent) => subagent.status === "running");
}
async archiveAgent(agentId: string): Promise<{ archivedAt: string }> {
const agent = this.requireAgent(agentId);
if (!this.registry) {
@@ -4258,7 +4169,6 @@ export class AgentManager {
private async buildLaunchContext(
agentId: string,
client: AgentClient,
cwd: string,
env?: Record<string, string>,
): Promise<AgentLaunchContext> {
const context: AgentLaunchContext = {
@@ -4266,7 +4176,6 @@ export class AgentManager {
env: {
...env,
PASEO_AGENT_ID: agentId,
PASEO_AGENT_CWD: cwd,
},
};
if (

View File

@@ -49,25 +49,6 @@ 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();

View File

@@ -583,7 +583,6 @@ 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;
@@ -594,13 +593,6 @@ 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> {
@@ -619,10 +611,6 @@ 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();

View File

@@ -37,19 +37,14 @@ describe("opencode agent commands E2E", () => {
}
}, 60_000);
test("listing commands resumes an idle-collected agent", async () => {
test("listing commands resumes an explicitly closed agent", async () => {
const agent = await ctx.client.createAgent({
...getFullAccessConfig("opencode"),
cwd: "/tmp",
title: "Collected OpenCode Commands Test Agent",
title: "Closed OpenCode Commands Test Agent",
});
const collection = await ctx.daemon.daemon.agentManager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
expect(collection.failures).toEqual([]);
expect(collection.collected.map((entry) => entry.agentId)).toContain(agent.id);
await ctx.daemon.daemon.agentManager.closeAgent(agent.id);
expect(ctx.daemon.daemon.agentManager.getAgent(agent.id)).toBeNull();
const result = await ctx.client.listCommands({ agentId: agent.id });

View File

@@ -707,6 +707,62 @@ describe("PiRpcAgentSession", () => {
);
});
test("treats Pi's aborted terminal response as cancellation after an interrupt", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
fakeSession.abort = async () => {
fakeSession.finishTurn({
role: "assistant",
provider: "openai-responses",
model: "gpt-5.6-terra",
responseId: "resp-aborted",
stopReason: "aborted",
errorMessage: "OpenAI Responses stream ended before a terminal response event",
content: [],
});
};
const { turnId } = await session.startTurn("stop this turn");
await session.interrupt();
await expect(events.nextTurnCancellation()).resolves.toEqual({
type: "turn_canceled",
provider: "pi",
reason: "interrupted",
turnId,
});
});
test("suppresses late aborted terminal response arriving after interrupt resolves", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
fakeSession.abort = async () => {};
const { turnId } = await session.startTurn("stop this turn");
await session.interrupt();
await expect(events.nextTurnCancellation()).resolves.toEqual({
type: "turn_canceled",
provider: "pi",
reason: "interrupted",
turnId,
});
fakeSession.finishTurn({
role: "assistant",
provider: "openai-responses",
model: "gpt-5.6-terra",
responseId: "resp-aborted",
stopReason: "aborted",
errorMessage: "OpenAI Responses stream ended before a terminal response event",
content: [],
});
expect(
(events as unknown as { events: AgentStreamEvent[] }).events.map((e) => e.type),
).not.toContain("turn_failed");
});
test("adds Pi assistant context to generic provider finish errors", async () => {
const { pi, session, events } = await createSession();

View File

@@ -849,6 +849,11 @@ function latestPiErrorMessage(messages: PiAgentMessage[]): string | null {
return formatPiErrorMessage(latestAssistant);
}
function isPiAbortedTerminalResponse(messages: PiAgentMessage[]): boolean {
const latestAssistant = messages.findLast((message) => message.role === "assistant");
return latestAssistant?.stopReason?.toLowerCase() === "aborted";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -1241,6 +1246,11 @@ export class PiRpcAgentSession implements AgentSession {
private state: PiSessionState;
private readonly currentModeId: string | null;
private closed = false;
// Pi reports an aborted OpenAI Responses stream before the abort RPC resolves.
// Keep the turn active until that RPC acknowledges the user-requested cancellation.
private interruptingTurnId: string | null = null;
private lastInterruptedTurnId: string | null = null;
private interruptedTerminalError: { turnId: string; error: string } | null = null;
constructor(options: PiRpcAgentSessionOptions) {
this.runtimeSession = options.runtimeSession;
@@ -1290,6 +1300,7 @@ export class PiRpcAgentSession implements AgentSession {
const payload = convertPromptInput(prompt, { model: this.state.model });
const turnId = randomUUID();
this.activeTurnId = turnId;
this.lastInterruptedTurnId = null;
this.activeClientMessageId = options?.clientMessageId ?? null;
this.activeAssistantMessageId = null;
this.activeTurnStarted = false;
@@ -1434,7 +1445,33 @@ export class PiRpcAgentSession implements AgentSession {
async interrupt(): Promise<void> {
const turnId = this.activeTurnId;
await this.runtimeSession.abort();
if (turnId) {
this.interruptingTurnId = turnId;
this.lastInterruptedTurnId = turnId;
}
try {
await this.runtimeSession.abort();
} catch (error) {
if (this.interruptingTurnId === turnId) {
this.interruptingTurnId = null;
}
if (this.interruptedTerminalError?.turnId === turnId) {
const terminalError = this.interruptedTerminalError;
this.interruptedTerminalError = null;
this.activeTurnId = null;
this.activeClientMessageId = null;
this.activeTurnStarted = false;
this.activeAssistantMessageId = null;
this.clearNoTurnBuffers();
this.emit({
type: "turn_failed",
provider: this.provider,
turnId,
error: terminalError.error,
});
}
throw error;
}
if (turnId && this.activeTurnId === turnId) {
this.activeTurnId = null;
this.activeClientMessageId = null;
@@ -1448,6 +1485,12 @@ export class PiRpcAgentSession implements AgentSession {
turnId,
});
}
if (this.interruptingTurnId === turnId) {
this.interruptingTurnId = null;
}
if (this.interruptedTerminalError?.turnId === turnId) {
this.interruptedTerminalError = null;
}
}
async revertConversation(input: { messageId: string }): Promise<void> {
@@ -2246,6 +2289,20 @@ export class PiRpcAgentSession implements AgentSession {
}
private completeTurn(turnId: string | undefined, messages: PiAgentMessage[]): void {
if (turnId && this.interruptingTurnId === turnId && isPiAbortedTerminalResponse(messages)) {
this.interruptedTerminalError = {
turnId,
error: latestPiErrorMessage(messages) ?? "Pi turn failed",
};
return;
}
if (
isPiAbortedTerminalResponse(messages) &&
(turnId === this.lastInterruptedTurnId || (!turnId && this.lastInterruptedTurnId !== null))
) {
this.lastInterruptedTurnId = null;
return;
}
this.activeTurnId = null;
this.activeClientMessageId = null;
this.activeAssistantMessageId = null;

View File

@@ -215,8 +215,6 @@ import { DaemonExecutions } from "./hub/daemon-executions.js";
const MAX_MCP_DEBUG_BATCH_ITEMS = 10;
const REDACTED_LOG_VALUE = "[redacted]";
const IDLE_AGENT_RUNTIME_TTL_MS = 30 * 60 * 1000;
const IDLE_AGENT_RUNTIME_SWEEP_INTERVAL_MS = 60 * 1000;
const DOWNLOAD_OPEN_FLAGS =
process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW;
@@ -1194,39 +1192,6 @@ export async function createPaseoDaemon(
archiveWorkspace: archiveScheduleWorkspaceExternal,
});
await scheduleService.start();
let inFlightIdleAgentCollection: Promise<void> | null = null;
const collectIdleAgentRuntimes = async () => {
const protectedAgentIds = await scheduleService.listActiveAgentTargetIds();
const cutoff = new Date(Date.now() - IDLE_AGENT_RUNTIME_TTL_MS);
const result = await agentManager.collectIdleAgents({ cutoff, protectedAgentIds });
for (const collected of result.collected) {
logger.info(collected, "Collected idle agent runtime");
}
for (const failure of result.failures) {
const { error, ...context } = failure;
logger.warn({ ...context, err: error }, "Failed to collect idle agent runtime");
}
};
const runIdleAgentCollection = () => {
if (inFlightIdleAgentCollection) {
return;
}
const collection = collectIdleAgentRuntimes()
.catch((error) => {
logger.warn({ err: error }, "Idle agent runtime sweep failed");
})
.finally(() => {
if (inFlightIdleAgentCollection === collection) {
inFlightIdleAgentCollection = null;
}
});
inFlightIdleAgentCollection = collection;
};
const idleAgentCollectionTimer = setInterval(
runIdleAgentCollection,
IDLE_AGENT_RUNTIME_SWEEP_INTERVAL_MS,
);
idleAgentCollectionTimer.unref();
agentManager.setAgentArchivedCallback(async (agentId) => {
try {
await scheduleService.completeForAgent(agentId);
@@ -1634,8 +1599,6 @@ export async function createPaseoDaemon(
await hubRelationships.stop();
workspaceReconciliation.dispose();
scriptHealthMonitor.stop();
clearInterval(idleAgentCollectionTimer);
await inFlightIdleAgentCollection;
// Freeze both ingress and registration before taking the agent closure snapshot.
wsServer?.prepareForShutdown();
agentManager.prepareForShutdown();

View File

@@ -646,7 +646,7 @@ test(
);
test(
"resumed Pi prompts retain their exact native entry ids after idle collection",
"resumed Pi prompts retain their exact native entry ids after explicit runtime close",
async () => {
const cwd = tmpCwd("pi-resumed-entry-id-");
const firstPrompt = "PASEO_PI_ENTRY_ID_FIRST. Reply exactly: first-ok";
@@ -665,12 +665,7 @@ test(
const firstFinish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
expect(firstFinish.status).toBe("idle");
const collection = await daemon.daemon.agentManager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
expect(collection.failures).toEqual([]);
expect(collection.collected.map((entry) => entry.agentId)).toContain(agent.id);
await daemon.daemon.agentManager.closeAgent(agent.id);
await client.sendMessage(agent.id, secondPrompt);
const secondFinish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);

View File

@@ -376,47 +376,6 @@ describe("ScheduleService", () => {
expect(resumed.nextRunAt).toBe("2026-01-01T00:04:00.000Z");
});
test("lists only active schedules that target existing agents", async () => {
const service = createScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
runner: async () => ({ agentId: null, output: "ok" }),
});
const activeAgentId = "00000000-0000-4000-8000-000000000201";
const pausedAgentId = "00000000-0000-4000-8000-000000000202";
const completedAgentId = "00000000-0000-4000-8000-000000000203";
const cadence = { type: "every" as const, everyMs: 60_000 };
await service.create({
prompt: "Keep active agent resident",
cadence,
target: { type: "agent", agentId: activeAgentId },
});
const paused = await service.create({
prompt: "Paused heartbeat",
cadence,
target: { type: "agent", agentId: pausedAgentId },
});
await service.pause(paused.id);
await service.create({
prompt: "Completed heartbeat",
cadence,
target: { type: "agent", agentId: completedAgentId },
});
await service.completeForAgent(completedAgentId);
await service.create({
prompt: "Fresh agent each run",
cadence,
target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } },
});
await expect(service.listActiveAgentTargetIds()).resolves.toEqual(new Set([activeAgentId]));
});
test("completes schedules when max runs is reached", async () => {
const service = createScheduleService({
paseoHome: tempDir,

View File

@@ -204,7 +204,6 @@ type ScheduleAgentManager = Pick<
| "hydrateTimelineFromProvider"
| "resumeAgentFromPersistence"
| "runAgent"
| "touchAgentActivity"
| "waitForAgentEvent"
| "waitForAgentClose"
>;
@@ -370,17 +369,6 @@ export class ScheduleService {
return this.store.list();
}
async listActiveAgentTargetIds(): Promise<Set<string>> {
const schedules = await this.store.list();
const agentIds = new Set<string>();
for (const schedule of schedules) {
if (schedule.status === "active" && schedule.target.type === "agent") {
agentIds.add(schedule.target.agentId);
}
}
return agentIds;
}
async inspect(id: string): Promise<StoredSchedule> {
const schedule = await this.store.get(id);
if (!schedule) {

View File

@@ -4906,7 +4906,7 @@ describe("agent config setters", () => {
} {
return {
waitForAgentClose: vi.fn().mockResolvedValue(undefined),
touchAgentActivity: vi.fn(() => ({ id: "agent-1" })),
getAgent: vi.fn(() => ({ id: "agent-1" })),
...overrides,
};
}

View File

@@ -6345,7 +6345,6 @@ export class Session {
agentId,
accepted: true,
error: null,
outOfBand: true,
},
});
return;
@@ -6373,7 +6372,6 @@ export class Session {
agentId,
accepted: true,
error: null,
outOfBand: false,
},
});
} catch (error) {

View File

@@ -117,7 +117,7 @@ describe("AgentConfigSession", () => {
});
});
test("set mode: a failed load rejects without mutating the collected agent", async () => {
test("set mode: a failed load rejects without mutating the closed agent", async () => {
const { subsystem, emitted, operations } = makeSubsystem();
operations.loadFailure = new Error("agent is archived");

View File

@@ -19,7 +19,7 @@ export interface AgentConfigSessionHost {
/**
* The per-agent config mutations this subsystem drives. The shell adapts these
* onto the AgentManager and loads a collected agent before mutation (mode still
* onto the AgentManager and loads a closed agent before mutation (mode still
* routes through setAgentModeCommand); tests wire an in-memory fake. Mode and
* thinking yield a provider notice; model and feature do not.
*/