Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 53fa9524cf fix(app): keep rewind invalidation for find results after Next and Previous
The rewind marker of the result set was cleared in cancel(), which also
stops navigation work. Next and Previous call cancel() while keeping the
results, so after one step a remote rewind no longer invalidated them and
the selection could stay on a retired message. The marker now shares the
result set's lifetime and is cleared only when a new query discards it.
2026-11-11 08:51:28 +01:00
Mohamed Boudra 5a8383d237 fix(app): end chat find results when a rewind retires messages
Find validated its results against the timeline epoch only. Capable hosts
rewind in place, so the epoch stays the same and a remote rewind left the
count and selection pointing at retired messages; Next could reveal one.

The replica now records the latest rewind seq per agent, from live rewind
events and from fetched pages carrying retirements. Find treats epoch plus
that seq as the history identity, so a rewind during or after a search
reports the history as changed.
2026-11-11 08:41:00 +01:00
Mohamed Boudra cf3caefa14 fix(server): retire the turns a steered Codex rewind actually removed
Codex rewinds whole native turns. A steered prompt shares its turn with the prompt it joined, so forking before that turn also removes the earlier prompt, and a rollback counted per prompt can remove earlier turns. The session reported at_target, so Paseo kept prompts the provider no longer had. It now reports the cut from the turn index taken before the fork, and an unreported outcome when the fork switched threads but did not finish.
2026-11-11 08:36:58 +01:00
Mohamed Boudra c747a58871 test(app): expect a rewound first prompt to leave the chat in the rewind sheet spec
On main the mock provider's rewind kept the first user message whatever the target, and the chat was rebuilt from that history, so the spec expected the rewound first prompt to stay. The mock now rewinds at the target and the chat retires the rewound turns in place, so rewinding the first prompt empties the chat and returns the prompt to the composer.
2026-11-11 08:26:19 +01:00
Mohamed Boudra c36d398829 perf(server): read the subscription freshness head without fetching the transcript
The freshness check read the head by fetching every row of the agent's history on each subscribe. The timeline head now carries its last committed sequence number, so the check reads only the head.
2026-11-11 04:00:27 +01:00
Mohamed Boudra 183bc736ec test(server): await provider child reads in the replay and pi child tests
These tests came in with the main merge. The child store reads are asynchronous now, so the tests compared a promise with the expected child.
2026-11-11 04:00:11 +01:00
Mohamed Boudra c4ce21a237 test(client): expect the rewind capability in the client hello
The client advertises timeline_rewind_operations since the rewind operations landed, but the hello expectation was never updated.
2026-11-11 03:59:55 +01:00
Mohamed Boudra 9a2236dac3 test(server): give the test session the interface's persistence signature
TestAgentSession.describePersistence had no return type, so TypeScript
inferred a codex-only handle with a UUID session id. The restarted-child test
from 80879eaf7 assigns the resumed AgentPersistenceHandle to it and failed to
typecheck (TS2322). The test double now declares the AgentSession signature,
which also clears the older plugin-provider sites with the same error.
2026-11-10 13:54:12 +01:00
Mohamed Boudra ecd0dd083e fix(server): let an aborted wait end while the provider is starting the turn
10c443a32 made waitForAgentEvent wait for a starting run before reading its
state, but that wait came before the abort listener. A provider start has no
deadline of its own, so a stalled startTurn kept every timed wait (MCP wait,
CLI wait, schedules, finish subscriptions) past its timeout. The start wait
now rejects as soon as the caller's signal aborts.
2026-11-10 13:53:50 +01:00
Mohamed Boudra 10c443a32b fix(server): wait for a starting run to settle before steering or waiting on it
A run's start now awaits the timeline before the provider is called, and a
failed start awaits its timeline writes before the run is cleared. Two
readers raced those windows:

- waitForForegroundStart returned as soon as a start failed, while the failed
  run was still tracked, so a steer send in that window replaced it and
  interrupted the session instead of starting its own turn.
- waitForAgentEvent replayed the idle state from before a starting run and
  finished immediately, so `wait` after a dispatch could return before the
  turn ran.

waitForForegroundStart now resolves only once the turn started or the run is
cleared, as documented, and waitForAgentEvent waits for it before reading the
agent's state. The OMP restored-history test previously finished only through
that early return; its fake now starts the turn it later ends.
2026-11-10 13:47:49 +01:00
Mohamed Boudra 457d8d6ec5 Merge origin/main into plan-provider-switch-sqlite-timelines
Adapts main's changes to the single asynchronous timeline owner:

- The subscribe freshness check (#9175) awaits the loaded timeline head and
  compares the client's cursor against the epoch that client sees, so legacy
  clients compare against their derived epoch after a rewind.
- History reads (#8737) pass purpose "history" when an agent with no stored
  timeline must be loaded; stored history is still read without the provider.
- Background work clears on reload, close and parent delete next to the
  async provider-child cancellation.
- Claude steered-message rewinds (#8372) report an at-target cut. Codex keeps
  main's saved thread id alongside the reported cut.
- Plugin rewinds (#8368) return an unreported outcome; a failed replay after
  the native revert leaves the rewind for the reload to settle rather than a
  retry. Manager-level rewinds target the timeline row's message id.
- Main's tests read the timeline asynchronously, and wait for the provider to
  receive a prompt instead of assuming it arrives in the dispatch tick.
2026-11-10 13:47:17 +01:00
Mohamed Boudra 80879eaf7c fix(timeline): retain child state and recover rewinds on resume
Provider children now live in the parent's timeline next to its history,
so their descriptors, rows and retirements survive close, archive and a
manager rebuilt over the same backend, and deleting the agent removes
them all. Resuming settles a rewind that was still open when the runtime
stopped instead of leaving its rows half-retired.

A stored child's status is only the last report, possibly from provider
history or an earlier runtime. Workspace activity and idle cleanup now
read the children the current runtime reported running, and closing a
runtime cancels only those, so a child stored as running from an earlier
runtime no longer keeps the agent busy or blocks cleanup.
2026-11-10 13:09:38 +01:00
Mohamed Boudra dc85c2bfbc feat(timeline): preserve history across reload and archive
Reload agent used to delete the timeline and replay the provider's
history under a new epoch. That brought back turns the user had rewound
and dropped anything the provider no longer had. Archive deleted the
timeline too.

Paseo's timeline is now the owner of the conversation. Reload agent
keeps it and reconciles the provider's history append-only: turns are
aligned by provider prompt id and compared exactly (prompt text,
assistant and reasoning text, tool calls). When the owned conversation
is a whole-segment prefix of the provider's, the rows after it are
appended as new live rows. Any other difference changes nothing and
agent_refreshed reports historyDiverged, which the app shows as a
warning on Reload agent and Unarchive.

Divergence is deliberately conservative:
- An answer or tool call Paseo saw only partly can never be completed by
  a reload. History rows carry no turn id to update a live row in place.
- A prompt sent with attachments diverges on every reload. Providers
  store the rendered attachment text, and the owned row keeps only the
  typed text. Recording the sent prompt is left to provider handoff work.

Archive now only closes the runtime. Stored history, including provider
children, is read without resuming or even requiring the provider.
Forced provider replacement is removed.

Tradeoff: an archived chat's history stays in memory until the daemon
exits, measured at about 1.1-1.2x its JSON size (about 0.5 MB for a
typical session, about 16 MB for a heavy tool session). Moving
retained history to disk belongs to the persisted backend, not eviction,
which would drop the rewinds this change keeps.
2026-11-10 11:43:10 +01:00
Mohamed Boudra 5ed1f8d108 feat(timeline): apply conversation rewinds without replacing history
Rewinding a prompt used to rotate the timeline epoch, so every open
client refetched and remounted the whole chat. A rewind is now a
retirement: the daemon keeps the rewound turn stored but outside the
conversation, and capable clients remove the rows that started in
[retireFromSeq, seq) in place. Catch-up pages carry the retirements in
their span. Clients without the timeline_rewind_operations capability
get the old replacement shape.

The daemon retires only what the provider actually removed. Providers
report the boundary they kept. A failure known to change nothing keeps
the conversation. Any other failure leaves the rewind open and refuses
prompts until reloading the agent proves the outcome from the
provider's replayed prompts. A recorded cut whose save failed is
committed by the next prompt or the reload, which resumes the rewound
session.

Plugin providers complete session.revert without saying what they
removed. Paseo then reloads the session itself and judges the outcome
from the replay, so a no-op completion is not taken as a cut.

The app anchors each row at the seq it started at, because a late
update moves a row's latest seq into the rewound range. The app also
refuses pages served before the newest retirement it has seen, then
refetches them.

Known limits:
- The automatic plugin settle shows the agent as restoring briefly.
- An empty or mismatched plugin replay leaves the rewind unresolved.
  Fork the chat to recover.
- A plugin's failed revert still needs a manual reload.
- An open rewind lives in memory, so a daemon restart drops it.
2026-11-10 10:16:13 +01:00
Mohamed Boudra 4178497d3d refactor(server): give agent timeline history a single awaitable owner
Agent timeline history had several writers that appended and published
independently, so a persistent backend could not be swapped in without
reordering or losing items. AgentTimeline now owns each agent's history:
one FIFO queue per agent (child agents share the parent's queue) commits
each item and then publishes it, so subscribers never see an item before it
is recorded. Backends may be async, which prepares the SQLite swap.

Terminal turn events wait for queued writes so a finished turn never races
its own last chunks. A subscriber that throws after commit leaves the item
in history. A failed commit surfaces as an agent error ("Conversation
history could not be saved") via the existing lastError/attention path; it
survives the turn's terminal transition and clears on the next run.
2026-11-10 08:11:00 +01:00
131 changed files with 8934 additions and 2617 deletions
+1
View File
@@ -54,6 +54,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority |
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/terminal-rendering.md](docs/terminal-rendering.md) | Native terminal view: xterm owns the grid, cluster placement, emoji widths, mixed-direction limits, render regression |
| [docs/timeline-sync.md](docs/timeline-sync.md) | Owned agent history, rewind retirements, live stream vs authoritative fetch, paged catch-up, client replica |
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
| [docs/agent-stream-disclosure.md](docs/agent-stream-disclosure.md) | Expand/collapse in the chat timeline — hold contract, native anchor sequence, frame spacing, render-window cost |
| [docs/markdown-renderer.md](docs/markdown-renderer.md) | Shared Markdown engine, native selection, links, images and library constraints |
+6 -3
View File
@@ -39,8 +39,8 @@ An unarchived agent may be `closed` without being deleted or archived. Closing r
processes and subscriptions while retaining its Paseo identity, persistence handle, timeline,
workspace, labels, title, usage, attention, timestamps, and parent relationship. Opening or prompting
the agent runs through `ensureAgentLoaded()`, which resumes the durable provider session under the
same Paseo agent ID. Provider history is not appended again when the canonical timeline is already
primed.
same Paseo agent ID. Resume does not read provider history when the agent already has a timeline;
see [owned history](timeline-sync.md#owned-history).
Reload releases the old runtime before resuming its durable session: an idle provider process can
still own an exclusive writer. A close failure retains that runtime for cleanup and blocks the
@@ -239,7 +239,8 @@ lifecycle is separate from public workspace visibility and is not a public creat
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 sets `archivedAt`, invokes the provider's native archive hook, and cascades to managed
children.
children. The timeline and the provider children stay, so an archived chat reads them without its
provider; deleting the agent removes them.
`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.
@@ -332,6 +333,8 @@ Workspace status is an aggregate activity signal computed **per `workspaceId`**.
Running provider-native subagents contribute `running` to the workspace owned by their parent agent. Their completed, failed, and canceled states stay in the parent's subagents track.
Only the parent's current runtime makes a provider child running. A child's stored status is the last report about it, and that report can come from provider history or from a runtime an earlier daemon ran, so a stored `running` child does not make the workspace running or keep the parent's runtime alive. It counts again once the live session reports it running. Closing a runtime cancels the children that runtime reported running; children it never reported keep their stored status.
A finished or failed workspace can be marked unread after it has been reviewed. The daemon restores
attention on its newest eligible workspace-root agent, `error` for an agent whose lifecycle is
`error` and `finished` otherwise, without sending a new notification. Opening a workspace restores
+1
View File
@@ -94,6 +94,7 @@ workspace for a directory clears it. The store lives in memory and resets on dae
| `server/directory-sync/` | Daemon-global latest-state sequences for projects, workspaces, and agents |
| `server/workspace-labels/` | Host-local label catalog, assignment mutations, and explicit subscriptions |
| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
| `server/agent/timeline/` | Owned agent history, provider children, rewind retirements, behind a backend |
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
| `server/agent/tools/` | Transport-neutral catalog for workspaces, agents, permissions, and automation |
| `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK |
+8
View File
@@ -180,6 +180,14 @@ Terminal activity contributes to the workspace status bucket **per `workspaceId`
---
## Runtime-only Agent Timelines
Agent timelines are not written to `$PASEO_HOME`. `AgentTimeline` owns each agent's history behind
a backend, and the only backend is in memory, so a daemon restart loses it and the next resume
imports provider history. See [owned history](timeline-sync.md#owned-history).
---
## 2. Daemon Configuration
**Path:** `$PASEO_HOME/config.json`
+62 -11
View File
@@ -5,15 +5,9 @@ Agent chat delivery has two paths:
1. **Live stream** — `agent_stream` WebSocket messages for immediacy. These may be delta-shaped lifecycle updates.
2. **Authoritative history** — `fetch_agent_timeline_request` for correctness. This always returns full projected timeline items, never lifecycle deltas.
The daemon retains projected items in memory. Each source event advances the stream sequence,
then replaces the previous tool state or merges into the current text item. Intermediate payloads
are never retained for history or catch-up. Provider history is the durable transcript authority
and rebuilds the projection when an agent resumes.
Pagination bounds each read, not total resident history. Active and idle agents retain their
projected transcript; archive releases it and later history reads rebuild it from the provider.
Streaming overhead must remain proportional to the retained transcript. Cumulative text snapshots
must be released after their deltas have been consumed.
Pagination bounds each read, not total resident history. Streaming overhead must remain
proportional to the retained transcript. Cumulative text snapshots must be released after their
deltas have been consumed.
The invariants are:
@@ -32,6 +26,44 @@ bounded item is used for runtime timeline rows and live stream events. Provider
applies the same rule so reopening an agent cannot restore an oversized payload. Fetches apply it
again to assistant and reasoning rows the projection merges from many chunks.
## Owned history
The daemon owns each agent's history in `AgentTimeline`
(`packages/server/src/server/agent/timeline/`): one ordered history per agent, with each provider
child's descriptor and history in a child scope of the same agent. Each source event advances the
stream sequence, then replaces the previous tool state or merges into the current text item.
Intermediate payloads are never retained for history or catch-up.
History is append-only: rewind, import, reload and archive never delete or rewrite a row. Only
deleting the agent, or a provider removing one of its own children, deletes history.
- **Rewind** retires a range of rows. They leave the conversation and stay stored, in the same
epoch. See [Rewind retires rows](#rewind-retires-rows).
- **Provider history** is imported only into an agent that has no history. After that, resume
does not read it. Reload agent and Unarchive append only the rows the provider has after the
owned conversation, as ordinary live rows. Any other difference, including an answer Paseo saw only part of or a prompt sent with
attachments, changes nothing and is reported as diverged. A provider child Paseo already holds,
listed or retired, is never imported again.
- **Archive and reload** keep the history and the provider children. An archived chat reads them
without its provider. Deleting the agent deletes its history and every child's, retired ones
included.
`epoch` is the stored history's identity, minted once when the history is created.
`AgentTimeline` serializes every operation of one agent, child scopes included, and keeps its
state in an `AgentTimelineBackend`: the sequence, the rows and retirements, prompt identities, the
open rewind, and the children. The backend contract suite is `agent-timeline.test.ts`, run over
`TIMELINE_CONTRACT_BACKENDS` in `timeline/testing.ts`. The only backend keeps everything in
memory, so a daemon restart loses the owned history: the next resume imports provider history into
a new epoch, and clients take the destructive reset below. Rewinds retired before the restart come
back if the provider history still holds them. Retained history costs memory for every loaded,
closed or archived agent until the daemon exits or the agent is deleted.
An open rewind is stored with the history. Resuming an agent whose history holds one settles it
before the agent takes prompts: a recorded provider cut is committed, and an unknown outcome is
judged from the resumed provider history. When that history proves nothing, the rewind stays open
and the agent refuses prompts with the unknown-outcome error.
## Presence is not delivery
Client heartbeat reports presence:
@@ -159,7 +191,7 @@ or retry backoff. This cannot keep a mobile socket alive after the operating sys
bookkeeping without replacing timeline arrays, preserving an upward-scrolled viewport.
- When the page overlaps or is adjacent to the local end cursor, only projected items newer than
that cursor are applied. Already-covered rows are not replayed.
- A true middle gap, epoch change, or rewind atomically replaces stale canonical history with the
- A true middle gap or epoch change atomically replaces stale canonical history with the
latest tail. The replacement reconciles positioned live rows beyond its coverage and unresolved
local submissions; it never retains two discontiguous canonical ranges.
@@ -172,6 +204,24 @@ history replay. The pending approval UI can hide that tool from presentation, bu
must retain its position. Creating a new history card on rejection places it after the prompt that
rejected it; changing steer-event ordering would also put new assistant output before that prompt.
## Rewind retires rows
A conversation rewind commits a retirement `{ seq, retireFromSeq }`: active rows anchored in
`[retireFromSeq, seq)` leave the conversation, and `seq` is the retirement's own position in the
sequence. The provider children spawned by retired tool calls, and their descendants, are retired
in the same commit. They leave the listing, keep their history, and drop late provider events.
Clients learn of a retirement live through `agent.timeline.rewind`, and on pages through
`retirements`, which lists the retirements whose `seq` lies inside the page's span. The client
drops the rows it holds in that range; rows without a timeline position are unechoed local sends
and stay. Retired ranges and the retirement's `seq` count as covered, so forward cursors advance
through them. Tail, before and prompt-jump pages are served from active rows, so one served before
a retirement the client already applied is stale and is refetched (`viewed-timeline-sync.ts`).
Clients without `timeline_rewind_operations` cannot drop retired rows. For them the session derives
a wire epoch that changes with each retirement, so they take the destructive replacement they
always took on rewind. The shim is tagged `COMPAT(timelineRewindOperations)`.
## Provider child history
Child transcripts use the same projected-page reconciliation as the main conversation. The client
@@ -336,7 +386,7 @@ the draft-create handoff is the one boundary that also permits the legacy canoni
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
rows because a provider may never echo them. A known epoch change 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. A cold
reset without an existing epoch is destructive because the client has no continuity anchor.
@@ -352,6 +402,7 @@ canonical assistant prefix, it stays in the head lane. No row may be returned in
## Relevant code
- Server owned history: `packages/server/src/server/agent/timeline/`
- Server live stream forwarding: `packages/server/src/server/session.ts`
- App sync planning: `packages/app/src/timeline/timeline-sync-plan.ts`
- App viewed-agent synchronization: `packages/app/src/timeline/viewed-timeline-sync.ts`
@@ -11,6 +11,13 @@ import {
import { expectNearBottom, scrollChatAwayFromBottom } from "../support/helpers/agent-bottom-anchor";
import { holdRewindCompletion } from "../support/helpers/agent-timeline-gate";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { userScrollsTimelineToHistoryStart } from "../support/helpers/timeline-pagination";
import {
closeWorkspaceAgentTab,
fetchAgentArchivedAt,
primeAdditionalPage,
resetSeededPageState,
} from "../support/helpers/archive-tab";
async function completeSubmittedTurn(
page: Page,
@@ -46,26 +53,102 @@ async function expectTurnCompletesNormally(
await expectAgentIdle(page);
}
async function openAgentOnAnotherClient(
page: Page,
agent: Awaited<ReturnType<typeof seedMockAgentWorkspace>>,
): Promise<void> {
await primeAdditionalPage(page);
await resetSeededPageState(page);
await openAgentRoute(page, agent);
await expectComposerVisible(page);
}
/** The prompts the chat shows, in order, each exactly once. */
async function expectPrompts(page: Page, prompts: string[]): Promise<void> {
const messages = page.getByTestId("user-message");
await expect(messages).toHaveCount(prompts.length);
await expect(messages).toContainText(prompts);
}
async function reloadAgentFromTabMenu(page: Page, agentId: string): Promise<void> {
const tabId = `workspace-tab-agent_${agentId}`;
await page.getByTestId(tabId).filter({ visible: true }).first().click({ button: "right" });
await page.getByTestId(`workspace-tab-context-agent_${agentId}-reload-agent`).click();
}
async function archiveAgentByClosingTab(
page: Page,
agent: Awaited<ReturnType<typeof seedMockAgentWorkspace>>,
): Promise<void> {
await closeWorkspaceAgentTab(page, agent.agentId);
await expect.poll(() => fetchAgentArchivedAt(agent.client, agent.agentId)).not.toBeNull();
}
async function unarchiveAgent(page: Page): Promise<void> {
await page.getByRole("button", { name: "Unarchive", exact: true }).click();
await expect(page.getByText("This agent is archived", { exact: true })).toHaveCount(0, {
timeout: 30_000,
});
}
async function expectToast(page: Page, message: string): Promise<void> {
await expect(page.getByTestId("app-toast-message")).toHaveText(message, { timeout: 30_000 });
}
// The mock provider's resumed session holds no history, so it never matches what Paseo shows.
const UNSYNCED_RELOAD =
"Reloaded agent, but the provider's history doesn't match this chat, so it wasn't synced";
/** The prompts of the whole conversation, after scrolling up through its older pages. */
async function expectConversationPrompts(page: Page, prompts: string[]): Promise<void> {
const messages = page.getByTestId("user-message");
await expect
.poll(async () => {
await userScrollsTimelineToHistoryStart(page);
return await messages.count();
})
.toBe(prompts.length);
await expect(messages).toContainText(prompts);
}
// A row React remounts loses this attribute; one that stays mounted keeps it.
async function markMounted(row: Locator): Promise<void> {
await row.evaluate((element) => element.setAttribute("data-e2e-mounted", "before-rewind"));
}
async function expectStillMounted(row: Locator): Promise<void> {
await expect(row).toHaveAttribute("data-e2e-mounted", "before-rewind");
}
test.describe("Agent message rewind", () => {
test("rewinds a submitted prompt without replaying history and preserves a human draft", async ({
test("rewinds a prompt in place on every open client and keeps a human draft", async ({
page,
}) => {
test.setTimeout(90_000);
test.setTimeout(150_000);
const retainedPrompt = "Keep this completed turn after rewind.";
const rewoundPrompt = "Remove this completed turn after rewind.";
const nextPrompt = "Continue after the rewind.";
const preservedDraft = "Keep this human draft after rewind.";
const agent = await seedMockAgentWorkspace({
repoPrefix: "message-rewind-roundtrip-",
title: "Message rewind roundtrip",
model: "ten-second-stream",
});
const otherClient = await page.context().newPage();
try {
const rewindCompletion = await holdRewindCompletion(page, agent.agentId);
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await completeSubmittedTurn(page, agent, retainedPrompt);
const retainedMessage = await completeSubmittedTurn(page, agent, retainedPrompt);
const userMessage = await completeSubmittedTurn(page, agent, rewoundPrompt);
await test.step("a second client opens the same chat", async () => {
await openAgentOnAnotherClient(otherClient, agent);
await expectPrompts(otherClient, [retainedPrompt, rewoundPrompt]);
});
await markMounted(retainedMessage);
await scrollChatAwayFromBottom(page, {
deltaY: -900,
minDistanceFromBottom: 300,
@@ -73,18 +156,91 @@ test.describe("Agent message rewind", () => {
await fillComposerDraft(page, preservedDraft);
rewindCompletion.clearTimelineStreamCount();
const rewind = rewindConversation(page, userMessage, rewoundPrompt);
await rewindCompletion.waitForDelayedResponse();
await expect(page.getByTestId("rewind-menu-conversation")).toBeDisabled();
expect(rewindCompletion.timelineStreamCount()).toBe(0);
rewindCompletion.release();
await rewind;
await test.step("the rewind stays pending until the daemon answers", async () => {
const rewind = rewindConversation(page, userMessage, rewoundPrompt);
await rewindCompletion.waitForDelayedResponse();
await expect(page.getByTestId("rewind-menu-conversation")).toBeDisabled();
expect(rewindCompletion.timelineStreamCount()).toBe(0);
rewindCompletion.release();
await rewind;
});
await expect(page.getByTestId("user-message").filter({ hasText: rewoundPrompt })).toHaveCount(
0,
);
await expectComposerDraft(page, preservedDraft);
await expectNearBottom(page);
await test.step("the rewound turn leaves without reloading the chat", async () => {
await expectPrompts(page, [retainedPrompt]);
await expectStillMounted(retainedMessage);
expect(rewindCompletion.timelineFetchCount()).toBe(0);
await expectComposerDraft(page, preservedDraft);
await expectNearBottom(page);
await expectPrompts(otherClient, [retainedPrompt]);
});
await test.step("the next prompt appears once on both clients", async () => {
await completeSubmittedTurn(otherClient, agent, nextPrompt);
await expectPrompts(otherClient, [retainedPrompt, nextPrompt]);
await expectPrompts(page, [retainedPrompt, nextPrompt]);
});
await test.step("a reconnecting client loads the rewound conversation", async () => {
await otherClient.reload();
await expectPrompts(otherClient, [retainedPrompt, nextPrompt]);
});
} finally {
await otherClient.close();
await agent.cleanup();
}
});
test("keeps the rewound conversation through Reload agent, archive and unarchive", async ({
page,
}) => {
test.setTimeout(150_000);
const keptPrompt = "Keep this turn through reload and archive.";
const rewoundPrompt = "Rewind this turn before reloading.";
const nextPrompt = "Continue after the rewind, then reload.";
const agent = await seedMockAgentWorkspace({
repoPrefix: "message-rewind-reload-archive-",
title: "Message rewind reload archive",
model: "ten-second-stream",
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await completeSubmittedTurn(page, agent, keptPrompt);
const rewound = await completeSubmittedTurn(page, agent, rewoundPrompt);
await rewindConversation(page, rewound, rewoundPrompt);
await expectPrompts(page, [keptPrompt]);
await completeSubmittedTurn(page, agent, nextPrompt);
await expectPrompts(page, [keptPrompt, nextPrompt]);
await test.step("Reload agent keeps the chat and says the provider's history was not synced", async () => {
await reloadAgentFromTabMenu(page, agent.agentId);
await expectToast(page, UNSYNCED_RELOAD);
await expectPrompts(page, [keptPrompt, nextPrompt]);
await expectComposerVisible(page);
});
await test.step("the archived chat shows the rewound conversation", async () => {
await archiveAgentByClosingTab(page, agent);
await openAgentRoute(page, agent);
await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expectConversationPrompts(page, [keptPrompt, nextPrompt]);
});
await test.step("unarchiving keeps the rewound conversation", async () => {
await unarchiveAgent(page);
await expectToast(page, UNSYNCED_RELOAD);
await expectComposerVisible(page);
await expectConversationPrompts(page, [keptPrompt, nextPrompt]);
});
await test.step("a reconnecting client loads the same conversation", async () => {
await page.reload();
await expectComposerVisible(page);
await expectConversationPrompts(page, [keptPrompt, nextPrompt]);
});
} finally {
await agent.cleanup();
}
@@ -215,14 +215,14 @@ test.describe("Rewind sheet", () => {
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expect(userMessage(page, secondPrompt)).toHaveCount(0);
await expect(page.getByText("Cycle 1", { exact: true })).toHaveCount(0);
await expectUserMessageCount(page, 1);
await expectUserMessageCount(page, 0);
await expectComposerDraft(page, firstPrompt);
await submitMessage(page, replacementPrompt);
await expectUserMessageVisible(page, replacementPrompt);
await expect(userMessage(page, secondPrompt)).toHaveCount(0);
await expect(page.getByText("Cycle 1", { exact: true })).toHaveCount(0);
await expectUserMessageCount(page, 2);
await expectUserMessageCount(page, 1);
await fillComposerDraft(page, "");
await composerLocator(page).evaluate((element) => element.blur());
@@ -232,7 +232,7 @@ test.describe("Rewind sheet", () => {
await selectRewindOption(page, "Rewind files");
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expectComposerDraft(page, "");
await expectUserMessageCount(page, 2);
await expectUserMessageCount(page, 1);
const preservedDraft = "Keep this human draft after rewind.";
await fillComposerDraft(page, preservedDraft);
@@ -243,7 +243,7 @@ test.describe("Rewind sheet", () => {
await selectRewindOption(page, "Rewind files");
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expectComposerDraft(page, preservedDraft);
await expectUserMessageCount(page, 2);
await expectUserMessageCount(page, 1);
await fillComposerDraft(page, "");
await composerLocator(page).evaluate((element) => element.blur());
@@ -253,7 +253,7 @@ test.describe("Rewind sheet", () => {
await selectRewindOption(page, "Rewind conversation and files");
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expectComposerDraft(page, replacementPrompt);
await expectUserMessageCount(page, 1);
await expectUserMessageCount(page, 0);
} finally {
await session.cleanup();
}
@@ -32,6 +32,8 @@ export interface RewindCompletionGate {
clearTimelineStreamCount(): void;
release(): void;
timelineStreamCount(): number;
/** Timeline reads this page sent since the last clear. */
timelineFetchCount(): number;
waitForDelayedResponse(): Promise<void>;
}
@@ -187,12 +189,20 @@ export async function holdDaemonHydration(page: Page): Promise<DaemonHydrationGa
};
}
// The rewind response and the timeline change it causes; the change removes the rewound row too.
const REWIND_COMPLETION_MESSAGES = new Set([
"agent.rewind.response",
"agent.timeline.rewind",
"agent.timeline.replacement",
]);
export async function holdRewindCompletion(
page: Page,
agentId: string,
): Promise<RewindCompletionGate> {
let released = false;
let timelineStreamCount = 0;
let timelineFetchCount = 0;
const delayedForwards: Array<() => void> = [];
let resolveDelayedResponse: (() => void) | null = null;
const delayedResponse = new Promise<void>((resolve) => {
@@ -201,7 +211,16 @@ export async function holdRewindCompletion(
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => server.send(message));
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (
sessionMessage?.type === "fetch_agent_timeline_request" &&
sessionMessage.agentId === agentId
) {
timelineFetchCount += 1;
}
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null;
@@ -217,11 +236,11 @@ export async function holdRewindCompletion(
}
if (
!released &&
(sessionMessage?.type === "agent.rewind.response" ||
sessionMessage?.type === "agent.timeline.replacement") &&
sessionMessage &&
REWIND_COMPLETION_MESSAGES.has(String(sessionMessage.type)) &&
payload?.agentId === agentId
) {
// The replacement also signals completion and removes the rewound row/menu.
// The rewind also signals completion and removes the rewound row/menu.
// Hold it with the response so the pending-state assertion observes a pending rewind.
delayedForwards.push(() => ws.send(message));
if (sessionMessage.type === "agent.rewind.response") resolveDelayedResponse?.();
@@ -234,6 +253,7 @@ export async function holdRewindCompletion(
return {
clearTimelineStreamCount() {
timelineStreamCount = 0;
timelineFetchCount = 0;
},
release() {
released = true;
@@ -242,6 +262,7 @@ export async function holdRewindCompletion(
}
},
timelineStreamCount: () => timelineStreamCount,
timelineFetchCount: () => timelineFetchCount,
waitForDelayedResponse: () => delayedResponse,
};
}
@@ -56,6 +56,7 @@ export function ChatFind({
agentId,
serverId,
epoch,
rewindSeq,
items,
viewportRef,
revealLoadedMessage,
@@ -95,8 +96,8 @@ export function ChatFind({
);
const state = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
useEffect(() => {
model.updateHistory(epoch, items);
}, [model, epoch, items]);
model.updateHistory(epoch, items, rewindSeq);
}, [model, epoch, items, rewindSeq]);
useEffect(() => () => model.close(), [model]);
useEffect(() => {
if (!active) model.close();
@@ -18,7 +18,7 @@ function message(seq: number, text = "target"): StreamItem {
kind: "assistant_message",
text,
timestamp: new Date(0),
timelineCursor: { epoch: "epoch", seq },
timelineCursor: { epoch: "epoch", seq, anchorSeq: seq },
};
}
@@ -39,7 +39,7 @@ it("loads an older location and searches a response merged across suppressed tas
source.map((item, index) => ({
event: { type: "timeline" as const, provider: "claude" as const, item },
timestamp: new Date(0),
timelineCursor: { epoch: "epoch", seq: index + 1 },
timelineCursor: { epoch: "epoch", seq: index + 1, anchorSeq: index + 1 },
})),
{ source: "canonical" },
);
@@ -55,7 +55,7 @@ it("loads an older location and searches a response merged across suppressed tas
},
async load(_epoch, seq) {
loaded.push(seq);
model.updateHistory("epoch", history);
model.updateHistory("epoch", history, null);
},
async reveal(_id, _query, occurrence) {
revealed.push(occurrence);
@@ -64,7 +64,7 @@ it("loads an older location and searches a response merged across suppressed tas
clear() {},
});
try {
model.updateHistory("epoch", []);
model.updateHistory("epoch", [], null);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
@@ -99,7 +99,7 @@ it("skips a source-only candidate and highlights a displayed match", async () =>
clear() {},
});
try {
model.updateHistory("epoch", [message(1), message(2)]);
model.updateHistory("epoch", [message(1), message(2)], null);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().selectedItemId).toBe("row-2");
@@ -129,7 +129,7 @@ it("ignores replies after closing and does not navigate", async () => {
},
clear() {},
});
model.updateHistory("epoch", [message(1)]);
model.updateHistory("epoch", [message(1)], null);
model.open();
model.setQuery("target");
await expect.poll(() => calls).toEqual(["search"]);
@@ -155,7 +155,7 @@ it("retains a failed search for retry and distinguishes failure from zero matche
};
const model = new ChatFindModel(operations);
try {
model.updateHistory("epoch", []);
model.updateHistory("epoch", [], null);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("error");
@@ -185,7 +185,7 @@ it("reports a failed timeline load as a connection failure", async () => {
clear() {},
});
try {
model.updateHistory("epoch", []);
model.updateHistory("epoch", [], null);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("error");
@@ -207,24 +207,160 @@ it("reports a search result that outlived its epoch as a changed history", async
clear() {},
});
try {
model.updateHistory("epoch", [message(1)]);
model.updateHistory("epoch", [message(1)], null);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
model.updateHistory("epoch-2", [message(1)]);
model.updateHistory("epoch-2", [message(1)], null);
expect(model.getSnapshot()).toMatchObject({ phase: "error", failure: "historyChanged" });
} finally {
model.close();
}
});
it("reports results that a rewind took messages from as a changed history", async () => {
const model = new ChatFindModel({
async search() {
return response([{ seq: 2, role: "assistant" }]);
},
async load() {},
async reveal() {
return { count: 1, occurrence: 0 };
},
clear() {},
});
try {
model.updateHistory("epoch", [message(1), message(2)], null);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
expect(model.getSnapshot()).toMatchObject({ selectedItemId: "row-2", count: 1 });
// Another client rewinds the turn holding the match; the epoch stays.
model.updateHistory("epoch", [message(1)], 3);
expect(model.getSnapshot()).toMatchObject({
phase: "error",
failure: "historyChanged",
selectedItemId: null,
});
} finally {
model.close();
}
});
it("stops a search still receiving pages when a rewind lands between them", async () => {
let pages = 0;
let releaseSecondPage!: () => void;
const secondPage = new Promise<void>((resolve) => {
releaseSecondPage = resolve;
});
const model = new ChatFindModel({
async search(_query, cursor) {
pages += 1;
if (cursor === undefined)
return { ...response([{ seq: 2, role: "assistant" }]), nextCursor: 2 };
await secondPage;
return response([{ seq: 1, role: "assistant" }]);
},
async load() {},
async reveal() {
return { count: 1, occurrence: 0 };
},
clear() {},
});
try {
model.updateHistory("epoch", [message(1), message(2)], null);
model.open();
model.setQuery("target");
await expect.poll(() => pages).toBe(2);
model.updateHistory("epoch", [message(1)], 3);
releaseSecondPage();
await expect.poll(() => model.getSnapshot().failure).toBe("historyChanged");
expect(model.getSnapshot()).toMatchObject({ phase: "error", selectedItemId: null });
} finally {
model.close();
}
});
it.each([
["Next", "next"],
["Previous", "previous"],
] as const)("reports a rewind after %s as a changed history", async (_label, step) => {
const model = new ChatFindModel({
async search() {
return response([
{ seq: 1, role: "assistant" },
{ seq: 2, role: "assistant" },
]);
},
async load() {},
async reveal() {
return { count: 1, occurrence: 0 };
},
clear() {},
});
try {
model.updateHistory("epoch", [message(1), message(2)], null);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
model[step]();
await expect.poll(() => model.getSnapshot().selectedItemId).toBe("row-2");
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
model.updateHistory("epoch", [message(1)], 3);
expect(model.getSnapshot()).toMatchObject({
phase: "error",
failure: "historyChanged",
selectedItemId: null,
});
} finally {
model.close();
}
});
it("stops a pending Next when a rewind lands before it reveals", async () => {
let reveals = 0;
let revealSecond!: () => void;
const secondReveal = new Promise<void>((resolve) => {
revealSecond = resolve;
});
const model = new ChatFindModel({
async search() {
return response([
{ seq: 1, role: "assistant" },
{ seq: 2, role: "assistant" },
]);
},
async load() {},
async reveal() {
reveals += 1;
if (reveals > 1) await secondReveal;
return { count: 1, occurrence: 0 };
},
clear() {},
});
try {
model.updateHistory("epoch", [message(1), message(2)], null);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
model.next();
await expect.poll(() => reveals).toBe(2);
model.updateHistory("epoch", [message(1)], 3);
revealSecond();
await expect.poll(() => model.getSnapshot().failure).toBe("historyChanged");
expect(model.getSnapshot()).toMatchObject({ phase: "error", selectedItemId: null });
} finally {
model.close();
}
});
it("can load a search location before the initial chat history has arrived", async () => {
const model = new ChatFindModel({
async search() {
return response([{ seq: 1, role: "assistant" }]);
},
async load() {
model.updateHistory("epoch", [message(1)]);
model.updateHistory("epoch", [message(1)], null);
},
async reveal() {
return { count: 1, occurrence: 0 };
@@ -232,7 +368,7 @@ it("can load a search location before the initial chat history has arrived", asy
clear() {},
});
try {
model.updateHistory(null, []);
model.updateHistory(null, [], null);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
@@ -260,7 +396,7 @@ it("reports the position across the whole chat and corrects it with rendered cou
clear() {},
});
try {
model.updateHistory("epoch", [message(1), message(2)]);
model.updateHistory("epoch", [message(1), message(2)], null);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
@@ -297,7 +433,7 @@ it("counts one match per message from a host that reports no counts", async () =
clear() {},
});
try {
model.updateHistory("epoch", [message(1), message(2)]);
model.updateHistory("epoch", [message(1), message(2)], null);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
@@ -76,6 +76,10 @@ export class ChatFindModel {
private items: StreamItem[] = [];
private epoch: string | null = null;
private resultEpoch: string | null = null;
/** The latest rewind applied to the history. A rewind keeps the epoch and retires messages. */
private rewindSeq: number | null = null;
/** The latest rewind when the current result set's search started; undefined until a search starts. */
private searchedRewindSeq: number | null | undefined = undefined;
private locations: Location[] = [];
private resolved = new Map<Location, Target>();
private current = -1;
@@ -113,6 +117,7 @@ export class ChatFindModel {
this.cancel();
this.locations = [];
this.resultEpoch = null;
this.searchedRewindSeq = undefined;
this.resolved.clear();
this.current = -1;
this.inMessage = { occurrence: 0, count: 0 };
@@ -144,6 +149,7 @@ export class ChatFindModel {
});
}
private async search(signal: AbortSignal) {
this.searchedRewindSeq = this.rewindSeq;
try {
let cursor: number | undefined;
do {
@@ -168,15 +174,14 @@ export class ChatFindModel {
this.fail(error, signal);
}
}
updateHistory(epoch: string | null, items: StreamItem[]) {
if (
this.resultEpoch !== null &&
epoch !== null &&
epoch !== this.resultEpoch &&
this.state.open
) {
/** `rewindSeq` is the sequence number of the latest rewind applied to this history. */
updateHistory(epoch: string | null, items: StreamItem[], rewindSeq: number | null) {
const replaced = this.resultEpoch !== null && epoch !== null && epoch !== this.resultEpoch;
const rewound = this.searchedRewindSeq !== undefined && rewindSeq !== this.searchedRewindSeq;
if ((replaced || rewound) && this.state.open) {
this.fail(new ChatFindFailureError("historyChanged"), this.abort.signal);
}
this.rewindSeq = rewindSeq;
this.epoch = epoch;
this.items = items;
this.historyListeners.forEach((listener) => listener());
@@ -9,6 +9,8 @@ export interface ChatFindProps {
agentId: string;
serverId: string;
epoch: string | null;
/** The sequence number of the latest rewind applied to this history, if any. */
rewindSeq: number | null;
items: StreamItem[];
viewportRef: RefObject<StreamViewportHandle | null>;
revealLoadedMessage(messageId: string): boolean;
@@ -139,7 +139,7 @@ describe("useChatOutline", () => {
kind: "user_message",
text: "live prompt",
timestamp: new Date(seq),
timelineCursor: { epoch: "epoch-1", seq },
timelineCursor: { epoch: "epoch-1", seq, anchorSeq: seq },
});
rerender({ tail: [loadedPrompt(2)] });
await waitFor(() => expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledTimes(2));
@@ -198,7 +198,7 @@ describe("useChatOutline", () => {
kind: "assistant_message",
text: "chunk",
timestamp: new Date(),
timelineCursor: { epoch: "epoch-1", seq: 2 },
timelineCursor: { epoch: "epoch-1", seq: 2, anchorSeq: 2 },
},
],
timelineEpoch: "epoch-1",
@@ -219,7 +219,7 @@ describe("useChatOutline", () => {
kind: "user_message",
text: "Replaced conversation",
timestamp: new Date(2),
timelineCursor: { epoch: "epoch-2", seq: 1 },
timelineCursor: { epoch: "epoch-2", seq: 1, anchorSeq: 1 },
},
],
timelineEpoch: "epoch-2",
@@ -275,7 +275,7 @@ describe("useChatOutline", () => {
kind: "user_message" as const,
text: "prompt",
timestamp: new Date(1),
timelineCursor: { epoch: "epoch-1", seq: 1 },
timelineCursor: { epoch: "epoch-1", seq: 1, anchorSeq: 1 },
},
];
const { result, rerender } = renderHook(
@@ -326,7 +326,7 @@ describe("useChatOutline", () => {
kind: "user_message" as const,
text: "prompt",
timestamp: new Date(1),
timelineCursor: { epoch: "epoch-1", seq: 1 },
timelineCursor: { epoch: "epoch-1", seq: 1, anchorSeq: 1 },
};
const { result, rerender } = renderHook(
({ tail, mountedHistoryMessageIds }) =>
@@ -381,7 +381,7 @@ describe("useChatOutline", () => {
kind: "user_message" as const,
text: "prompt",
timestamp: new Date(2),
timelineCursor: { epoch: "epoch-1", seq: 2 },
timelineCursor: { epoch: "epoch-1", seq: 2, anchorSeq: 2 },
};
const revealLoadedMessage = vi.fn();
revealLoadedMessage.mockReturnValue(false);
@@ -320,7 +320,7 @@ describe("stream presentation through installed plugins", () => {
const source = harness.source();
const live = source.head[0];
if (live?.kind !== "assistant_message") throw new Error("Expected a live assistant message");
const canonical = { ...live, timelineCursor: { epoch: "epoch-1", seq: 42 } };
const canonical = { ...live, timelineCursor: { epoch: "epoch-1", seq: 42, anchorSeq: 42 } };
const present = createStreamPresentation();
present({ ...presentationOptions, tail: [], head: [live], transform: undefined });
const repointed = rows(
@@ -330,7 +330,7 @@ describe("stream presentation through installed plugins", () => {
{
id: streamed[0]!.id,
text: "Streamed answer",
timelineCursor: { epoch: "epoch-1", seq: 42 },
timelineCursor: { epoch: "epoch-1", seq: 42, anchorSeq: 42 },
},
]);
});
@@ -517,7 +517,7 @@ describe("timeline presentation", () => {
messageId: "provider-message",
clientMessageId: "client-message",
turnId: "turn-1",
timelineCursor: { epoch: "epoch", seq: 12 },
timelineCursor: { epoch: "epoch", seq: 12, anchorSeq: 12 },
});
const presentMessage = () =>
present({
@@ -33,7 +33,7 @@ describe("resolveAssistantTurnForkBoundary", () => {
it("forks a failed assistant turn from its Paseo timeline cursor without a provider message id", () => {
const failedTurn = {
...assistantMessage("assistant-error", 2),
timelineCursor: { epoch: "timeline-1", seq: 42 },
timelineCursor: { epoch: "timeline-1", seq: 42, anchorSeq: 42 },
};
expect(
@@ -50,7 +50,7 @@ describe("resolveAssistantTurnForkBoundary", () => {
it("includes the provider message id with a supported timeline cursor", () => {
const selected = {
...assistantMessage("assistant-1", 2, "msg-assistant-1"),
timelineCursor: { epoch: "timeline-1", seq: 42 },
timelineCursor: { epoch: "timeline-1", seq: 42, anchorSeq: 42 },
};
expect(
@@ -68,7 +68,7 @@ describe("resolveAssistantTurnForkBoundary", () => {
it("falls back to the provider message id when timeline cursors are unsupported", () => {
const selected = {
...assistantMessage("assistant-1", 2, "msg-assistant-1"),
timelineCursor: { epoch: "timeline-1", seq: 42 },
timelineCursor: { epoch: "timeline-1", seq: 42, anchorSeq: 42 },
};
expect(
@@ -1,7 +1,13 @@
import type { StreamItem, TimelinePosition } from "@/types/stream";
import type { StreamItem } from "@/types/stream";
/** The daemon's timeline cursor; the row's anchor stays in the client. */
interface TimelineBoundaryCursor {
epoch: string;
seq: number;
}
export type AssistantTurnForkBoundary =
| { boundaryCursor: TimelinePosition; boundaryMessageId?: string }
| { boundaryCursor: TimelineBoundaryCursor; boundaryMessageId?: string }
| { boundaryCursor?: undefined; boundaryMessageId: string };
export function resolveAssistantTurnForkBoundary(input: {
@@ -15,7 +21,7 @@ export function resolveAssistantTurnForkBoundary(input: {
}
if (input.supportsTimelineCursor && item.timelineCursor) {
return {
boundaryCursor: item.timelineCursor,
boundaryCursor: { epoch: item.timelineCursor.epoch, seq: item.timelineCursor.seq },
...(item.messageId ? { boundaryMessageId: item.messageId } : {}),
};
}
@@ -140,7 +140,7 @@ describe("collapseTurns", () => {
it("stands at its host's timeline position, taking a new row when that position lands", () => {
const live = collapse([user("u1"), shell("c1"), reply("r1")]);
expect(live.summaryRowsByHostId.get("u1")).not.toHaveProperty("timelineCursor");
const cursor = { epoch: "epoch-1", seq: 7 };
const cursor = { epoch: "epoch-1", seq: 7, anchorSeq: 7 };
const placed = [{ ...user("u1"), timelineCursor: cursor }, shell("c1"), reply("r1")];
const canonical = collapse(placed, { previousSummaryRowsByHostId: live.summaryRowsByHostId });
expect(canonical.summaryRowsByHostId.get("u1")).not.toBe(live.summaryRowsByHostId.get("u1"));
+5
View File
@@ -406,6 +406,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const timelineEpoch = useSessionStore(
(state) => state.sessions[resolvedServerId]?.agentTimelineCursor.get(agentId)?.epoch ?? null,
);
const timelineRewindSeq = useSessionStore((state) => {
const rewind = state.sessions[resolvedServerId]?.agentTimelineLatestRewind.get(agentId);
return rewind && rewind.epoch === timelineEpoch ? rewind.seq : null;
});
const isTimelineDetached = useSessionStore(
(state) => state.sessions[resolvedServerId]?.agentTimelineHasNewer.get(agentId) === true,
);
@@ -1160,6 +1164,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
agentId={agentId}
serverId={resolvedServerId}
epoch={timelineEpoch}
rewindSeq={timelineRewindSeq}
items={findItems}
viewportRef={viewportRef}
revealLoadedMessage={revealLoadedMessage}
@@ -6,6 +6,7 @@ import { FOOTER_HEIGHT } from "@/constants/layout";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { KeyboardTranslateView } from "@/keyboard/shift";
import { Button } from "@/components/ui/button";
import { useToast } from "@/contexts/toast-context";
import type { Theme } from "@/styles/theme";
import { toErrorMessage } from "@/utils/error-messages";
@@ -18,6 +19,7 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
const { t } = useTranslation();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const toast = useToast();
const [isUnarchiving, setIsUnarchiving] = useState(false);
const [unarchiveError, setUnarchiveError] = useState<string | null>(null);
@@ -26,12 +28,15 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
setIsUnarchiving(true);
setUnarchiveError(null);
try {
await client.refreshAgent(agentId);
const refreshed = await client.refreshAgent(agentId);
if (refreshed.historyDiverged) {
toast.show(t("workspace.tabs.toasts.reloadedAgentHistoryDiverged"), { variant: "warning" });
}
} catch (error) {
setUnarchiveError(toErrorMessage(error));
setIsUnarchiving(false);
}
}, [client, isConnected, isUnarchiving, agentId]);
}, [client, isConnected, isUnarchiving, agentId, toast, t]);
return (
<KeyboardTranslateView style={styles.container}>
@@ -9,6 +9,7 @@ import { useRewindComposerRestore } from "./composer-restore";
import { useSessionStore } from "@/stores/session-store";
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { hostSupportsFeature } from "@/runtime/host-features";
interface UseRewindAgentMutationInput {
serverId?: string;
@@ -35,7 +36,16 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
throw new Error(t("common.errors.daemonClientUnavailable"));
}
await input.client.rewindAgent(input.agentId, input.messageId, mode);
if (mode !== "files") {
// COMPAT(timelineRewindOperations): added in v0.14.1, remove after 2027-05-10. A daemon with
// the feature delivers the rewind on the live timeline; an older one replaced the timeline
// and this client has to read it again.
const appliesLive =
input.serverId !== undefined &&
hostSupportsFeature(
useSessionStore.getState().sessions[input.serverId]?.serverInfo,
"timelineRewindOperations",
);
if (mode !== "files" && !appliesLive) {
const cursor = input.serverId
? useSessionStore
.getState()
@@ -385,6 +385,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
console.warn("[Session] timeline replacement refresh failed", { serverId, error });
});
};
const onRewind = (message: SessionOutboundMessage) => {
if (message.type !== "agent.timeline.rewind") return;
const { agentId, epoch, seq, retireFromSeq } = message.payload;
sync.applyStreamEvent(agentId, {
event: { type: "rewind", retireFromSeq },
seq,
epoch,
timestamp: new Date(),
});
};
const sync = getHostRuntimeStore().createViewedTimelineOwner(serverId, {
canCheckFreshness: () =>
client.getLastServerInfoMessage()?.features?.timelineFreshnessCheck === true,
@@ -395,6 +405,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
update: (message) => {
if (message.type === "agent_stream") onStream(message);
else if (message.type === "agent.timeline.replacement") onReplacement(message);
else if (message.type === "agent.timeline.rewind") onRewind(message);
},
});
return observation;
@@ -166,7 +166,7 @@ describe("ensureAgentIsInitialized", () => {
kind: "assistant_message",
id: "canonical-item",
text: "Canonical before restart",
timelineCursor: { epoch: "epoch-1", seq: 12 },
timelineCursor: { epoch: "epoch-1", seq: 12, anchorSeq: 12 },
timestamp: new Date("2026-07-27T10:00:00.000Z"),
},
],
+2
View File
@@ -722,6 +722,8 @@ export const ar: TranslationResources = {
resumeCommandUnavailable: "أمر الاستئناف غير متوفر",
reloadingAgent: "وكيل إعادة التحميل...",
reloadedAgent: "وكيل إعادة تحميل",
reloadedAgentHistoryDiverged:
"تمت إعادة تحميل الوكيل، لكن سجل المزوّد لا يطابق هذه المحادثة، لذا لم تتم مزامنته",
failedToReloadAgent: "فشل في إعادة تحميل الوكيل",
failedToCloseAgent: "فشل في إغلاق الوكيل",
},
+2
View File
@@ -719,6 +719,8 @@ export const en = {
resumeCommandUnavailable: "Resume command not available",
reloadingAgent: "Reloading agent...",
reloadedAgent: "Reloaded agent",
reloadedAgentHistoryDiverged:
"Reloaded agent, but the provider's history doesn't match this chat, so it wasn't synced",
failedToReloadAgent: "Failed to reload agent",
failedToCloseAgent: "Failed to close agent",
},
+2
View File
@@ -729,6 +729,8 @@ export const es: TranslationResources = {
resumeCommandUnavailable: "Comando de reanudación no disponible",
reloadingAgent: "Agente de recarga...",
reloadedAgent: "Agente recargado",
reloadedAgentHistoryDiverged:
"Agente recargado, pero el historial del proveedor no coincide con este chat, así que no se sincronizó",
failedToReloadAgent: "No se pudo recargar el agente",
failedToCloseAgent: "No se pudo cerrar el agente",
},
+2
View File
@@ -729,6 +729,8 @@ export const fr: TranslationResources = {
resumeCommandUnavailable: "Commande de reprise indisponible",
reloadingAgent: "Rechargement de l’agent…",
reloadedAgent: "Agent rechargé",
reloadedAgentHistoryDiverged:
"Agent rechargé, mais l’historique du fournisseur ne correspond pas à cette conversation, il n’a donc pas été synchronisé",
failedToReloadAgent: "Impossible de recharger l’agent",
failedToCloseAgent: "Impossible de fermer l’agent",
},
+2
View File
@@ -729,6 +729,8 @@ export const ja: TranslationResources = {
resumeCommandUnavailable: "再開コマンドが利用できません",
reloadingAgent: "エージェントを再読み込み中...",
reloadedAgent: "エージェントを再読み込みしました",
reloadedAgentHistoryDiverged:
"エージェントを再読み込みしましたが、プロバイダーの履歴がこのチャットと一致しないため同期しませんでした",
failedToReloadAgent: "エージェントの再読み込みに失敗しました",
failedToCloseAgent: "エージェントを閉じられませんでした",
},
+2
View File
@@ -724,6 +724,8 @@ export const ko: TranslationResources = {
resumeCommandUnavailable: "재개 명령을 사용할 수 없습니다",
reloadingAgent: "에이전트 다시 로드 중...",
reloadedAgent: "에이전트를 다시 로드했습니다",
reloadedAgentHistoryDiverged:
"에이전트를 다시 로드했지만 제공자 기록이 이 채팅과 일치하지 않아 동기화하지 않았습니다",
failedToReloadAgent: "에이전트를 다시 로드하지 못했습니다",
failedToCloseAgent: "에이전트를 닫지 못했습니다",
},
+2
View File
@@ -727,6 +727,8 @@ export const ptBR: TranslationResources = {
resumeCommandUnavailable: "Comando de retomada indisponível",
reloadingAgent: "Recarregando agente...",
reloadedAgent: "Agente recarregado",
reloadedAgentHistoryDiverged:
"Agente recarregado, mas o histórico do provedor não corresponde a este chat, então não foi sincronizado",
failedToReloadAgent: "Falha ao recarregar agente",
failedToCloseAgent: "Falha ao fechar agente",
},
+2
View File
@@ -729,6 +729,8 @@ export const ru: TranslationResources = {
resumeCommandUnavailable: "Команда возобновления недоступна",
reloadingAgent: "Перезагрузка агента...",
reloadedAgent: "Агент перезагружен",
reloadedAgentHistoryDiverged:
"Агент перезагружен, но история провайдера не совпадает с этим чатом, поэтому она не синхронизирована",
failedToReloadAgent: "Не удалось перезагрузить агента",
failedToCloseAgent: "Не удалось закрыть агента",
},
+2
View File
@@ -722,6 +722,8 @@ export const zhCN: TranslationResources = {
resumeCommandUnavailable: "恢复命令不可用",
reloadingAgent: "正在重新加载 Agent...",
reloadedAgent: "已重新加载 Agent",
reloadedAgentHistoryDiverged:
"已重新加载 Agent,但提供方的历史记录与此对话不一致,因此未同步",
failedToReloadAgent: "重新加载 Agent 失败",
failedToCloseAgent: "关闭 Agent 失败",
},
@@ -91,7 +91,7 @@ describe("AgentDirectoryReplica", () => {
id: "reply",
text: "server history",
timestamp: new Date(),
timelineCursor: { epoch: "e", seq: 1 },
timelineCursor: { epoch: "e", seq: 1, anchorSeq: 1 },
},
],
head: [],
+12 -4
View File
@@ -244,6 +244,7 @@ const PROBE_STEADY_MS = 10_000;
const PROBE_MAX_BACKOFF_MS = 30_000;
const PROBE_INACTIVE_WHILE_ONLINE_MS = 120_000;
const ADAPTIVE_SWITCH_THRESHOLD_MS = 40;
const MAX_STALE_TIMELINE_PAGE_ATTEMPTS = 3;
const ADAPTIVE_SWITCH_CONSECUTIVE_PROBES = 3;
const CONFIGURED_OVERRIDE_BOOTSTRAP_RETRY_MS = 1_000;
@@ -2857,12 +2858,19 @@ export class HostRuntimeStore {
const directory = this.directorySyncByServer.get(serverId);
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
const owner = useSessionStore.getState().sessions[serverId]?.viewedTimelineSync;
const page = await directory.fetchTimeline(agentId, request);
if (owner && useSessionStore.getState().sessions[serverId]?.viewedTimelineSync === owner) {
// A page served before a rewind this client applied is asked for again. Seqs only grow within
// an epoch, so the next page includes the rewind unless another one landed meanwhile.
for (let attempt = 1; ; attempt += 1) {
const page = await directory.fetchTimeline(agentId, request);
if (!owner || useSessionStore.getState().sessions[serverId]?.viewedTimelineSync !== owner) {
return page;
}
owner.flushStreamAgent(agentId);
owner.applyTimelineResponse(page);
if (owner.applyTimelineResponse(page) === "applied") return page;
if (attempt === MAX_STALE_TIMELINE_PAGE_ATTEMPTS) {
throw new Error(`Timeline page for ${agentId} stayed behind a rewind`);
}
}
return page;
}
createViewedTimelineOwner(
@@ -184,7 +184,7 @@ function timelineItem(text = "Cached"): StreamItem {
id: "message-1",
text,
timestamp: new Date("2026-07-18T08:02:00.000Z"),
timelineCursor: { epoch: "epoch-1", seq: 12 },
timelineCursor: { epoch: "epoch-1", seq: 12, anchorSeq: 12 },
};
}
@@ -789,7 +789,7 @@ describe("ReplicaCache", () => {
version: 1,
data: { passed: 4, failed: 0 },
timestamp: new Date("2026-07-18T08:02:00.000Z"),
timelineCursor: { epoch: "epoch-1", seq: 12 },
timelineCursor: { epoch: "epoch-1", seq: 12, anchorSeq: 12 },
};
writer.commitTimeline(SERVER_ID, "agent-1", {
agentId: "agent-1",
@@ -834,6 +834,32 @@ describe("ReplicaCache", () => {
expect(await createCache(storage).readTimeline(SERVER_ID, "agent-1")).toBeUndefined();
});
it("keeps where each cached row started, and refetches a timeline cached without it", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
const updatedAfterItStarted: StreamItem = {
...timelineItem(),
timelineCursor: { epoch: "epoch-1", seq: 12, anchorSeq: 9 },
};
writer.commitTimeline(SERVER_ID, "agent-1", { ...timeline(), items: [updatedAfterItStarted] });
await writer.flush();
expect((await createCache(storage).readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([
updatedAfterItStarted,
]);
const row = requireTimelineRow(storage);
const payload = JSON.parse(row.payload) as {
items: Array<{ timelineCursor?: Record<string, unknown> }>;
};
delete payload.items[0]?.timelineCursor?.anchorSeq;
storage.rows.set(`${row.serverId}:${row.kind}:${row.id}`, {
...row,
payload: JSON.stringify(payload),
});
expect(await createCache(storage).readTimeline(SERVER_ID, "agent-1")).toBeUndefined();
});
it("refetches old cached Markdown fragments instead of offering them as whole messages", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
@@ -1197,7 +1223,7 @@ it("retains semantic agent messages across disk-cache reconstruction", async ()
kind: "tool_call",
id: "delivery",
timestamp: new Date(1),
timelineCursor: { epoch: "epoch-1", seq: 12 },
timelineCursor: { epoch: "epoch-1", seq: 12, anchorSeq: 12 },
payload: {
source: "agent",
data: {
@@ -19,6 +19,7 @@ import {
isUnreconciledLocalUserMessage,
type AgentToolCallData,
type StreamItem,
type TimelinePosition,
} from "@/types/stream";
import type { UnsettledTurn } from "@/timeline/turn-liveness";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
@@ -79,7 +80,15 @@ const IsoDateSchema = z.iso.datetime();
const TimelinePositionSchema = z.strictObject({
epoch: z.string(),
seq: z.number().int().nonnegative(),
// COMPAT(timelineRewindOperations): absent on caches written before rows were anchored, added in
// v0.14.1, remove after 2027-05-10. Such a cache is not restored; the chat loads its tail instead.
anchorSeq: z.number().int().nonnegative().optional(),
});
type StoredTimelinePosition = z.infer<typeof TimelinePositionSchema>;
function isAnchored(position: StoredTimelinePosition | undefined): boolean {
return position === undefined || position.anchorSeq !== undefined;
}
const PluginTimelineDataSchema: z.ZodType<PluginTimelineData> = z.lazy(() =>
z.union([
z.null(),
@@ -413,6 +422,10 @@ function deserializeTimeline(stored: StoredTimeline | null): CachedTimeline | nu
if (!stored) {
return null;
}
// A rewind retires rows by anchor, so an unanchored row could not be retired.
if (!stored.items.every((item) => isAnchored(item.timelineCursor))) {
return null;
}
return {
agentId: stored.agentId,
items: stored.items.map(deserializeTimelineItem),
@@ -527,11 +540,20 @@ function serializeTimelineItem(item: StreamItem): StoredTimelineItem | null {
}
}
function anchoredPosition(position: StoredTimelinePosition | undefined): {
timelineCursor?: TimelinePosition;
} {
if (!position || position.anchorSeq === undefined) return {};
return {
timelineCursor: { epoch: position.epoch, seq: position.seq, anchorSeq: position.anchorSeq },
};
}
function deserializeTimelineItem(item: StoredTimelineItem): StreamItem {
if (item.kind === "plugin") {
return {
id: item.id,
...(item.timelineCursor ? { timelineCursor: item.timelineCursor } : {}),
...anchoredPosition(item.timelineCursor),
...(item.turnId ? { turnId: item.turnId } : {}),
timestamp: new Date(item.timestamp),
kind: item.kind,
@@ -550,7 +572,7 @@ function deserializeBuiltinTimelineItem(
): StreamItem {
const base = {
id: item.id,
...(item.timelineCursor ? { timelineCursor: item.timelineCursor } : {}),
...anchoredPosition(item.timelineCursor),
...(item.turnId ? { turnId: item.turnId } : {}),
timestamp: new Date(item.timestamp),
};
@@ -2215,11 +2215,10 @@ function WorkspaceScreenContent({
toast.show(t("workspace.tabs.toasts.reloadingAgent"), { durationMs: null });
try {
await client.refreshAgent(agentId);
// Send the existing cursor so the server detects the new epoch and
// returns reset:true. Without a cursor, the server returns reset:false
// and the client takes the incremental path, where new-epoch rows are
// dropped against the stale cursor.
const refreshed = await client.refreshAgent(agentId);
// Send the existing cursor: Reload agent keeps the history, so the server
// returns only the rows it added. When the history was imported again
// under a new epoch, the cursor reads as stale and the server resets.
const sessionState = useSessionStore.getState().sessions[normalizedServerId];
const currentCursor = sessionState?.agentTimelineCursor.get(agentId);
await getHostRuntimeStore().fetchAgentTimeline(normalizedServerId, agentId, {
@@ -2228,7 +2227,13 @@ function WorkspaceScreenContent({
? { cursor: { epoch: currentCursor.epoch, seq: currentCursor.endSeq } }
: {}),
});
toast.show(t("workspace.tabs.toasts.reloadedAgent"), { variant: "success" });
if (refreshed.historyDiverged) {
toast.show(t("workspace.tabs.toasts.reloadedAgentHistoryDiverged"), {
variant: "warning",
});
} else {
toast.show(t("workspace.tabs.toasts.reloadedAgent"), { variant: "success" });
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("workspace.tabs.toasts.failedToReloadAgent"),
+32
View File
@@ -316,6 +316,12 @@ export interface AgentTimelineCursorState {
}>;
}
/** The latest conversation rewind applied to an agent's history, by its sequence number. */
export interface AgentTimelineRewind {
epoch: string;
seq: number;
}
export type AgentTimelineState =
| { status: "cold" }
| { status: "painted"; items: StreamItem[] }
@@ -490,6 +496,7 @@ export interface SessionState {
agentTimelineUnsettledTurn: Map<string, UnsettledTurn>;
agentTimelineHasOlder: Map<string, boolean>;
agentTimelineHasNewer: Map<string, boolean>;
agentTimelineLatestRewind: Map<string, AgentTimelineRewind>;
agentTimelineOlderFetchInFlight: Map<string, boolean>;
historySyncGeneration: number;
agentHistorySyncGeneration: Map<string, number>;
@@ -609,6 +616,12 @@ interface SessionStoreActions {
serverId: string,
state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>),
) => void;
/** Records a rewind applied to the agent's history; an older one of the same history is ignored. */
recordAgentTimelineRewind: (
serverId: string,
agentId: string,
rewind: AgentTimelineRewind,
) => void;
setAgentTimelineOlderFetchInFlight: (
serverId: string,
state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>),
@@ -745,6 +758,7 @@ function createInitialSessionState(
agentTimelineUnsettledTurn: new Map(),
agentTimelineHasOlder: new Map(),
agentTimelineHasNewer: new Map(),
agentTimelineLatestRewind: new Map(),
agentTimelineOlderFetchInFlight: new Map(),
historySyncGeneration: 0,
agentHistorySyncGeneration: new Map(),
@@ -1386,6 +1400,24 @@ export const useSessionStore = create<SessionStore>()(
});
},
recordAgentTimelineRewind: (serverId, agentId, rewind) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) return prev;
const current = session.agentTimelineLatestRewind.get(agentId);
if (current && current.epoch === rewind.epoch && current.seq >= rewind.seq) return prev;
const next = new Map(session.agentTimelineLatestRewind);
next.set(agentId, rewind);
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, agentTimelineLatestRewind: next },
},
};
});
},
setAgentTimelineOlderFetchInFlight: (serverId, state) => {
set((prev) => {
const session = prev.sessions[serverId];
+230 -1
View File
@@ -36,7 +36,7 @@ function item(id: string, text: string, seq: number): StreamItem {
id,
text,
timestamp: new Date("2026-08-26T10:00:00.000Z"),
timelineCursor: { epoch: "epoch-1", seq },
timelineCursor: { epoch: "epoch-1", seq, anchorSeq: seq },
};
}
@@ -576,6 +576,235 @@ describe("viewed timeline persistence", () => {
});
});
describe("conversation rewind through the owner", () => {
const page = {
requestId: "page-1",
agentId: AGENT_ID,
agent: null,
direction: "tail",
projection: "projected",
reset: false,
epoch: "epoch-1",
error: null,
hasNewer: false,
hasOlder: false,
staleCursor: false,
gap: false,
} as const;
function entry(seq: number, type: "user_message" | "assistant_message", text: string) {
return {
seqStart: seq,
seqEnd: seq,
sourceSeqRanges: [{ startSeq: seq, endSeq: seq }],
collapsed: [],
provider: "codex",
item: { type, text },
timestamp: `2026-08-26T10:00:0${seq}.000Z`,
};
}
function texts(items: readonly StreamItem[]): string[] {
return items.flatMap((row) =>
row.kind === "user_message" || row.kind === "assistant_message" ? [row.text] : [],
);
}
function viewed(): string[] {
const session = useSessionStore.getState().sessions[SERVER_ID];
const timeline = selectAgentTimelineState(session, AGENT_ID);
const tail = timeline.status === "cold" ? [] : timeline.items;
return texts([...tail, ...(session?.agentStreamHead.get(AGENT_ID) ?? [])]);
}
function latestRewind() {
return useSessionStore.getState().sessions[SERVER_ID]?.agentTimelineLatestRewind.get(AGENT_ID);
}
it("retires B from the viewed and persisted timeline without refetching", () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
const commits: CachedTimeline[] = [];
const owner = createOwner({
readTimeline: async () => undefined,
commitTimeline: (_serverId, _agentId, timeline) => commits.push(timeline),
});
owner.applyTimelineResponse({
...page,
window: { minSeq: 1, maxSeq: 4, nextSeq: 5 },
startCursor: { epoch: "epoch-1", seq: 1 },
endCursor: { epoch: "epoch-1", seq: 4 },
entries: [
entry(1, "user_message", "A"),
entry(2, "assistant_message", "reply A"),
entry(3, "user_message", "B"),
entry(4, "assistant_message", "reply B"),
],
});
owner.applyStreamEvent(AGENT_ID, {
event: { type: "rewind", retireFromSeq: 3 },
seq: 5,
epoch: "epoch-1",
timestamp: new Date("2026-08-26T10:00:05.000Z"),
});
owner.applyStreamEvent(AGENT_ID, {
event: {
type: "timeline",
provider: "codex",
item: { type: "user_message", text: "C", messageId: "c" },
} as AgentStreamEventPayload,
seq: 6,
epoch: "epoch-1",
timestamp: new Date("2026-08-26T10:00:06.000Z"),
});
owner.flushStreamAgent(AGENT_ID);
expect(viewed()).toEqual(["A", "reply A", "C"]);
expect(texts(commits.at(-1)?.items ?? [])).toEqual(["A", "reply A", "C"]);
expect(commits.at(-1)?.range).toMatchObject({ epoch: "epoch-1", endSeq: 6 });
expect(latestRewind()).toEqual({ epoch: "epoch-1", seq: 5 });
owner.dispose();
});
it("brings a second client that loads after the rewind to the same conversation", () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
const owner = createOwner({ readTimeline: async () => undefined, commitTimeline: () => {} });
owner.applyTimelineResponse({
...page,
window: { minSeq: 1, maxSeq: 6, nextSeq: 7 },
startCursor: { epoch: "epoch-1", seq: 1 },
endCursor: { epoch: "epoch-1", seq: 6 },
entries: [
entry(1, "user_message", "A"),
entry(2, "assistant_message", "reply A"),
entry(6, "user_message", "C"),
],
retirements: [{ seq: 5, retireFromSeq: 3 }],
});
expect(viewed()).toEqual(["A", "reply A", "C"]);
expect(latestRewind()).toEqual({ epoch: "epoch-1", seq: 5 });
owner.dispose();
});
function liveRewindOfB(owner: ViewedTimelineOwner, retirement: { seq: number }): void {
owner.applyStreamEvent(AGENT_ID, {
event: { type: "rewind", retireFromSeq: 3 },
seq: retirement.seq,
epoch: "epoch-1",
timestamp: new Date("2026-08-26T10:00:06.000Z"),
});
owner.applyStreamEvent(AGENT_ID, {
event: {
type: "timeline",
provider: "codex",
item: { type: "user_message", text: "C", messageId: "c" },
} as AgentStreamEventPayload,
seq: retirement.seq + 1,
epoch: "epoch-1",
timestamp: new Date("2026-08-26T10:00:07.000Z"),
});
owner.flushStreamAgent(AGENT_ID);
}
/** A, reply A, B, reply B as a page served before B was rewound. */
const pageServedBeforeTheRewind: Parameters<ViewedTimelineOwner["applyTimelineResponse"]>[0] = {
...page,
direction: "before",
hasOlder: false,
window: { minSeq: 1, maxSeq: 5, nextSeq: 6 },
startCursor: { epoch: "epoch-1", seq: 1 },
endCursor: { epoch: "epoch-1", seq: 4 },
entries: [
entry(1, "user_message", "A"),
entry(2, "assistant_message", "reply A"),
entry(3, "user_message", "B"),
entry(4, "assistant_message", "reply B"),
],
};
function loadTheEndOfB(owner: ViewedTimelineOwner): void {
owner.applyTimelineResponse({
...page,
hasOlder: true,
window: { minSeq: 1, maxSeq: 5, nextSeq: 6 },
startCursor: { epoch: "epoch-1", seq: 5 },
endCursor: { epoch: "epoch-1", seq: 5 },
entries: [entry(5, "assistant_message", "more of B")],
});
}
it("refuses an older page served before the rewind and takes the one served after it", () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
const owner = createOwner({ readTimeline: async () => undefined, commitTimeline: () => {} });
loadTheEndOfB(owner);
liveRewindOfB(owner, { seq: 6 });
expect(viewed()).toEqual(["C"]);
const outcome = owner.applyTimelineResponse(pageServedBeforeTheRewind);
expect(viewed()).toEqual(["C"]);
expect(outcome).toBe("stale");
owner.applyTimelineResponse({
...pageServedBeforeTheRewind,
window: { minSeq: 1, maxSeq: 7, nextSeq: 8 },
// Retired rows count as covered, so the page reaches the loaded start.
endCursor: { epoch: "epoch-1", seq: 4 },
entries: [entry(1, "user_message", "A"), entry(2, "assistant_message", "reply A")],
});
expect(viewed()).toEqual(["A", "reply A", "C"]);
owner.dispose();
});
it("refuses a prompt jump window served before the rewind", () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
const owner = createOwner({ readTimeline: async () => undefined, commitTimeline: () => {} });
loadTheEndOfB(owner);
liveRewindOfB(owner, { seq: 6 });
const outcome = owner.applyTimelineResponse({
...pageServedBeforeTheRewind,
mergeWindow: true,
});
expect(viewed()).toEqual(["C"]);
expect(outcome).toBe("stale");
owner.dispose();
});
it("refuses a first page served before a rewind that reached the chat while it loaded", () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
const owner = createOwner({ readTimeline: async () => undefined, commitTimeline: () => {} });
liveRewindOfB(owner, { seq: 5 });
const outcome = owner.applyTimelineResponse({
...pageServedBeforeTheRewind,
direction: "tail",
window: { minSeq: 1, maxSeq: 4, nextSeq: 5 },
});
expect(viewed()).toEqual(["C"]);
expect(outcome).toBe("stale");
owner.applyTimelineResponse({
...page,
window: { minSeq: 1, maxSeq: 6, nextSeq: 7 },
startCursor: { epoch: "epoch-1", seq: 1 },
endCursor: { epoch: "epoch-1", seq: 6 },
entries: [
entry(1, "user_message", "A"),
entry(2, "assistant_message", "reply A"),
{
...entry(6, "user_message", "C"),
item: { type: "user_message", text: "C", messageId: "c" },
},
],
retirements: [{ seq: 5, retireFromSeq: 3 }],
});
expect(viewed()).toEqual(["A", "reply A", "C"]);
owner.dispose();
});
});
describe("turn boundaries", () => {
it("commits a turn's final reply before the turn stops running", () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
@@ -396,7 +396,7 @@ describe("processTimelineResponse", () => {
messageId: "provider-message",
text: "local prompt",
timestamp: new Date(1000),
timelineCursor: { epoch: "epoch-1", seq: 1 },
timelineCursor: { epoch: "epoch-1", seq: 1, anchorSeq: 1 },
turnId: "turn-1",
});
const hello = createUserMessage({
@@ -404,7 +404,7 @@ describe("processTimelineResponse", () => {
clientMessageId: "hello-client",
text: "hello",
timestamp: new Date(1500),
timelineCursor: { epoch: "epoch-1", seq: 2 },
timelineCursor: { epoch: "epoch-1", seq: 2, anchorSeq: 2 },
turnId: "turn-1",
});
const currentTail = [canonical, makeAssistantItem("existing tail", "existing-tail"), hello];
@@ -454,7 +454,7 @@ describe("processTimelineResponse", () => {
id: "stale-history",
text: "stale history",
timestamp: new Date(1040),
timelineCursor: { epoch: "epoch-1", seq: 40 },
timelineCursor: { epoch: "epoch-1", seq: 40, anchorSeq: 40 },
},
],
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 40 },
@@ -482,11 +482,11 @@ describe("processTimelineResponse", () => {
const unresolved = makeSubmittedUserMessage("unresolved prompt", "client-unresolved");
const coveredLive: StreamItem = {
...makeAssistantItem("covered live", "covered-live"),
timelineCursor: { epoch: "epoch-1", seq: 235 },
timelineCursor: { epoch: "epoch-1", seq: 235, anchorSeq: 235 },
};
const newerLive: StreamItem = {
...makeAssistantItem("newer live", "newer-live"),
timelineCursor: { epoch: "epoch-1", seq: 245 },
timelineCursor: { epoch: "epoch-1", seq: 245, anchorSeq: 245 },
};
const result = processTimelineResponse({
@@ -571,7 +571,7 @@ describe("processTimelineResponse", () => {
expect.objectContaining({
kind: "assistant_message",
text: "[System Error] failed",
timelineCursor: { epoch: "timeline-1", seq: 42 },
timelineCursor: { epoch: "timeline-1", seq: 42, anchorSeq: 40 },
}),
]);
});
@@ -743,7 +743,7 @@ describe("processTimelineResponse", () => {
}).item,
} as AgentStreamEventPayload,
timestamp: new Date(2000),
timelineCursor: { epoch: "epoch-1", seq: 2 },
timelineCursor: { epoch: "epoch-1", seq: 2, anchorSeq: 2 },
},
],
{ source: "canonical" },
@@ -773,7 +773,7 @@ describe("processTimelineResponse", () => {
expect(getAgentToolCalls([...result.tail, ...result.head])).toEqual([
expect.objectContaining({
timelineCursor: { epoch: "epoch-1", seq: 2 },
timelineCursor: { epoch: "epoch-1", seq: 2, anchorSeq: 1 },
payload: expect.objectContaining({
data: expect.objectContaining({ callId, status: "completed" }),
}),
@@ -794,7 +794,7 @@ describe("processTimelineResponse", () => {
},
} as AgentStreamEventPayload,
timestamp: new Date(2000),
timelineCursor: { epoch: "epoch-1", seq: 2 },
timelineCursor: { epoch: "epoch-1", seq: 2, anchorSeq: 2 },
},
],
{ source: "canonical" },
@@ -829,7 +829,7 @@ describe("processTimelineResponse", () => {
expect([...result.tail, ...result.head].filter((item) => item.kind === "todo_list")).toEqual([
expect.objectContaining({
timelineCursor: { epoch: "epoch-1", seq: 2 },
timelineCursor: { epoch: "epoch-1", seq: 2, anchorSeq: 1 },
items: [{ text: "Verify hydration", completed: true }],
}),
]);
@@ -845,7 +845,7 @@ describe("processTimelineResponse", () => {
item: { type: "compaction", status: "completed", trigger: "auto", preTokens: 1200 },
} as AgentStreamEventPayload,
timestamp: new Date(2000),
timelineCursor: { epoch: "epoch-1", seq: 2 },
timelineCursor: { epoch: "epoch-1", seq: 2, anchorSeq: 2 },
},
],
{ source: "canonical" },
@@ -877,7 +877,7 @@ describe("processTimelineResponse", () => {
expect([...result.tail, ...result.head].filter((item) => item.kind === "compaction")).toEqual([
expect.objectContaining({
timelineCursor: { epoch: "epoch-1", seq: 2 },
timelineCursor: { epoch: "epoch-1", seq: 2, anchorSeq: 1 },
status: "completed",
trigger: "auto",
preTokens: 1200,
@@ -916,7 +916,7 @@ describe("processTimelineResponse", () => {
messageId: "assistant-live",
text: " continuation",
timestamp: new Date(2001),
timelineCursor: { epoch: "epoch-1", seq: 101 },
timelineCursor: { epoch: "epoch-1", seq: 101, anchorSeq: 101 },
},
],
isInitializing: true,
@@ -944,7 +944,7 @@ describe("processTimelineResponse", () => {
);
expect(assistants).toHaveLength(1);
expect(assistants[0]?.text).toBe("canonical prefix continuation");
expect(assistants[0]?.timelineCursor).toEqual({ epoch: "epoch-1", seq: 101 });
expect(assistants[0]?.timelineCursor).toEqual({ epoch: "epoch-1", seq: 101, anchorSeq: 100 });
});
it("keeps assistant segments separate when a live tool sits between them", () => {
@@ -961,12 +961,12 @@ describe("processTimelineResponse", () => {
}).item,
} as AgentStreamEventPayload,
timestamp: new Date(2101),
timelineCursor: { epoch: "epoch-1", seq: 101 },
timelineCursor: { epoch: "epoch-1", seq: 101, anchorSeq: 101 },
},
{
event: makeAssistantTimelineEvent("after tool", messageId),
timestamp: new Date(2102),
timelineCursor: { epoch: "epoch-1", seq: 102 },
timelineCursor: { epoch: "epoch-1", seq: 102, anchorSeq: 102 },
},
],
{ source: "canonical" },
@@ -1017,7 +1017,7 @@ describe("processTimelineResponse", () => {
id: "assistant-live",
text: "canonical prefix continuation",
timestamp: new Date(2001),
timelineCursor: { epoch: "epoch-1", seq: 101 },
timelineCursor: { epoch: "epoch-1", seq: 101, anchorSeq: 101 },
},
],
isInitializing: true,
@@ -1036,7 +1036,7 @@ describe("processTimelineResponse", () => {
);
expect(assistants).toHaveLength(1);
expect(assistants[0]?.text).toBe("canonical prefix continuation");
expect(assistants[0]?.timelineCursor).toEqual({ epoch: "epoch-1", seq: 101 });
expect(assistants[0]?.timelineCursor).toEqual({ epoch: "epoch-1", seq: 101, anchorSeq: 100 });
});
it("keeps a canonical live user row newer than the bootstrap page", () => {
@@ -1058,7 +1058,7 @@ describe("processTimelineResponse", () => {
});
expect(live.head[0]).toMatchObject({
kind: "user_message",
timelineCursor: { epoch: "epoch-1", seq: 101 },
timelineCursor: { epoch: "epoch-1", seq: 101, anchorSeq: 101 },
});
const result = processTimelineResponse({
@@ -1121,7 +1121,7 @@ describe("processTimelineResponse", () => {
kind: "user_message",
clientMessageId: "client-message-1",
messageId: "provider-message-1",
timelineCursor: { epoch: "epoch-1", seq: 51 },
timelineCursor: { epoch: "epoch-1", seq: 51, anchorSeq: 51 },
});
});
@@ -2806,7 +2806,7 @@ describe("processTimelineResponse", () => {
id: "newest",
text: "newest",
timestamp: new Date(1000),
timelineCursor: { epoch: "epoch-1", seq: 100 },
timelineCursor: { epoch: "epoch-1", seq: 100, anchorSeq: 100 },
},
makeSubmittedUserMessage("pending submission", "pending-submission"),
],
@@ -2884,14 +2884,14 @@ describe("processTimelineResponse", () => {
id: "oldest",
text: "oldest",
timestamp: new Date(1000),
timelineCursor: { epoch: "epoch-1", seq: 1 },
timelineCursor: { epoch: "epoch-1", seq: 1, anchorSeq: 1 },
},
{
kind: "user_message",
id: "newest",
text: "newest",
timestamp: new Date(100_000),
timelineCursor: { epoch: "epoch-1", seq: 100 },
timelineCursor: { epoch: "epoch-1", seq: 100, anchorSeq: 100 },
},
];
@@ -2933,7 +2933,7 @@ describe("processTimelineResponse", () => {
id: "newest",
text: "newest",
timestamp: new Date(100_000),
timelineCursor: { epoch: "epoch-1", seq: 100 },
timelineCursor: { epoch: "epoch-1", seq: 100, anchorSeq: 100 },
},
...(lane === "tail" ? [pending] : []),
],
@@ -2974,9 +2974,13 @@ describe("processTimelineResponse", () => {
{
text: "local pending prompt",
messageId: "canonical-message",
timelineCursor: { epoch: "epoch-1", seq: 1 },
timelineCursor: { epoch: "epoch-1", seq: 1, anchorSeq: 1 },
},
{
text: "newest",
messageId: undefined,
timelineCursor: { epoch: "epoch-1", seq: 100, anchorSeq: 100 },
},
{ text: "newest", messageId: undefined, timelineCursor: { epoch: "epoch-1", seq: 100 } },
],
head: [],
acknowledgedClientMessageIds: [clientMessageId],
@@ -2990,7 +2994,7 @@ describe("processTimelineResponse", () => {
{
event: makeToolCallTimelineEvent("call-1"),
timestamp: new Date(500),
timelineCursor: { epoch: "epoch-1", seq: 500 },
timelineCursor: { epoch: "epoch-1", seq: 500, anchorSeq: 500 },
},
],
{ source: "canonical" },
@@ -3036,7 +3040,7 @@ describe("processTimelineResponse", () => {
{
callId: "call-1",
status: "completed",
timelineCursor: { epoch: "epoch-1", seq: 500 },
timelineCursor: { epoch: "epoch-1", seq: 500, anchorSeq: 1 },
},
]);
});
@@ -3048,7 +3052,7 @@ describe("processTimelineResponse", () => {
...makeAssistantItem("projected response", "loaded-assistant"),
messageId: "assistant-message",
timestamp,
timelineCursor: { epoch: "epoch-1", seq: 500 },
timelineCursor: { epoch: "epoch-1", seq: 500, anchorSeq: 500 },
},
];
const result = processTimelineResponse({
@@ -3098,7 +3102,7 @@ describe("processTimelineResponse", () => {
id: "loaded-assistant",
text: "projected response",
messageId: "assistant-message",
timelineCursor: { epoch: "epoch-1", seq: 500 },
timelineCursor: { epoch: "epoch-1", seq: 500, anchorSeq: 1 },
},
]);
});
@@ -3110,7 +3114,7 @@ describe("processTimelineResponse", () => {
id: "current-200",
text: "current-200",
timestamp: new Date(3200),
timelineCursor: { epoch: "epoch-1", seq: 200 },
timelineCursor: { epoch: "epoch-1", seq: 200, anchorSeq: 200 },
},
];
const existingCursor: TimelineCursor = {
@@ -3217,7 +3221,7 @@ describe("processTimelineResponse", () => {
const currentTail = [
{
...makeAssistantItem("newer chunk", "assistant-newer"),
timelineCursor: { epoch: "epoch-1", seq: 3 },
timelineCursor: { epoch: "epoch-1", seq: 3, anchorSeq: 3 },
},
];
const existingCursor: TimelineCursor = {
@@ -3244,7 +3248,7 @@ describe("processTimelineResponse", () => {
expect(result.tail[0]).toEqual(
expect.objectContaining({
id: "assistant-newer",
timelineCursor: { epoch: "epoch-1", seq: 3 },
timelineCursor: { epoch: "epoch-1", seq: 3, anchorSeq: 3 },
}),
);
expect(result.cursor).toEqual({
@@ -3683,7 +3687,7 @@ describe("processAgentStreamEvent", () => {
expect.objectContaining({
kind: "assistant_message",
text: "[System Error] failed",
timelineCursor: { epoch: "timeline-1", seq: 42 },
timelineCursor: { epoch: "timeline-1", seq: 42, anchorSeq: 42 },
}),
]);
});
@@ -3912,7 +3916,7 @@ describe("processAgentStreamEvent", () => {
expect(getUserTexts(result.head)).toEqual(["live before bootstrap"]);
expect(result.head[0]).toMatchObject({
kind: "user_message",
timelineCursor: { epoch: "epoch-1", seq: 51 },
timelineCursor: { epoch: "epoch-1", seq: 51, anchorSeq: 51 },
});
expect(result.cursorChanged).toBe(false);
expect(result.cursor).toBeNull();
@@ -3947,7 +3951,7 @@ describe("processAgentStreamEvent", () => {
clientMessageId: "client-message-1",
messageId: "provider-message-1",
text: "submitted before bootstrap",
timelineCursor: { epoch: "epoch-1", seq: 51 },
timelineCursor: { epoch: "epoch-1", seq: 51, anchorSeq: 51 },
}),
]);
});
@@ -4536,3 +4540,276 @@ describe("createAgentStreamReducerQueue", () => {
expect(scheduler.size).toBe(0);
});
});
// ---------------------------------------------------------------------------
// Conversation rewind: A, B, rewind B, C
// ---------------------------------------------------------------------------
describe("conversation rewind", () => {
const rewindB = { seq: 5, retireFromSeq: 3 };
function loadAB(): { tail: StreamItem[]; head: StreamItem[]; cursor: TimelineCursor } {
const result = processTimelineResponse({
...baseTimelineInput,
isInitializing: true,
hasActiveInitDeferred: true,
payload: {
...baseTimelineInput.payload,
direction: "tail",
window: { minSeq: 1, maxSeq: 4, nextSeq: 5 },
startCursor: { seq: 1 },
endCursor: { seq: 4 },
entries: [
makeTimelineEntry(1, "A", "user_message"),
makeTimelineEntry(2, "reply A"),
makeTimelineEntry(3, "B", "user_message"),
makeTimelineEntry(4, "reply B"),
],
},
});
if (!result.cursor) throw new Error("expected a loaded range");
return { tail: result.tail, head: result.head, cursor: result.cursor };
}
function live(
state: { tail: StreamItem[]; head: StreamItem[]; cursor: TimelineCursor },
events: AgentStreamReducerEvent[],
) {
const result = processAgentStreamEvents({
events,
currentTail: state.tail,
currentHead: state.head,
currentCursor: state.cursor,
hasAuthoritativeBaseline: true,
});
return {
tail: result.tail,
head: result.head,
cursor: result.cursor ?? state.cursor,
sideEffects: result.sideEffects,
};
}
function rewindEvent(retirement: { seq: number; retireFromSeq: number }) {
return {
event: { type: "rewind" as const, retireFromSeq: retirement.retireFromSeq },
seq: retirement.seq,
epoch: "epoch-1",
timestamp: new Date(1000 + retirement.seq),
};
}
const promptC = [
makeStreamReducerEvent(makeTimelineEvent("C", "user_message"), 6),
makeStreamReducerEvent(makeTimelineEvent("reply C"), 7),
];
function conversation(state: { tail: StreamItem[]; head: StreamItem[] }) {
return [...state.tail, ...state.head].map((item) =>
item.kind === "user_message" || item.kind === "assistant_message" ? item.text : item.kind,
);
}
it("retires B in place, keeps A mounted, and shows C once", () => {
const loaded = loadAB();
const [promptA, replyA] = loaded.tail;
const rewound = live(loaded, [rewindEvent(rewindB), ...promptC]);
expect(conversation(rewound)).toEqual(["A", "reply A", "C", "reply C"]);
expect(rewound.tail[0]).toBe(promptA);
expect(rewound.tail[1]).toBe(replyA);
expect(rewound.cursor).toMatchObject({ epoch: "epoch-1", startSeq: 1, endSeq: 7 });
expect(rewound.sideEffects).toEqual([]);
});
it("ignores a duplicate rewind so C is not retired", () => {
const rewound = live(loadAB(), [rewindEvent(rewindB), ...promptC]);
const replayed = live(rewound, [rewindEvent(rewindB)]);
expect(conversation(replayed)).toEqual(["A", "reply A", "C", "reply C"]);
expect(replayed.cursor.endSeq).toBe(7);
});
it("does not let an old page resurrect B after the rewind applied", () => {
const rewound = live(loadAB(), [rewindEvent(rewindB), ...promptC]);
const stale = processTimelineResponse({
...baseTimelineInput,
currentTail: rewound.tail,
currentHead: rewound.head,
currentCursor: rewound.cursor,
payload: {
...baseTimelineInput.payload,
direction: "after",
window: { minSeq: 1, maxSeq: 4, nextSeq: 5 },
startCursor: { seq: 3 },
endCursor: { seq: 4 },
entries: [makeTimelineEntry(3, "B", "user_message"), makeTimelineEntry(4, "reply B")],
},
});
expect(conversation(stale)).toEqual(["A", "reply A", "C", "reply C"]);
expect(stale.cursorChanged).toBe(false);
});
it("drops a late live delivery for the retired range", () => {
const rewound = live(loadAB(), [rewindEvent(rewindB)]);
const late = live(rewound, [makeStreamReducerEvent(makeTimelineEvent("late B"), 4)]);
expect(conversation(late)).toEqual(["A", "reply A"]);
});
it("catches up through a page that only carries the retirement", () => {
const loaded = loadAB();
const caughtUp = processTimelineResponse({
...baseTimelineInput,
currentTail: loaded.tail,
currentHead: loaded.head,
currentCursor: loaded.cursor,
payload: {
...baseTimelineInput.payload,
direction: "after",
window: { minSeq: 1, maxSeq: 5, nextSeq: 6 },
startCursor: { seq: 5 },
endCursor: { seq: 5 },
entries: [],
retirements: [rewindB],
},
});
if (!caughtUp.cursor) throw new Error("expected a loaded range");
expect(conversation(caughtUp)).toEqual(["A", "reply A"]);
expect(caughtUp.cursor.endSeq).toBe(5);
expect(caughtUp.sideEffects).not.toContainEqual(expect.objectContaining({ type: "catch_up" }));
const continued = live({ ...caughtUp, cursor: caughtUp.cursor }, promptC);
expect(conversation(continued)).toEqual(["A", "reply A", "C", "reply C"]);
});
it("converges after reconnect from a resume page that crosses the rewind", () => {
const loaded = loadAB();
const resumed = processTimelineResponse({
...baseTimelineInput,
currentTail: loaded.tail,
currentHead: loaded.head,
currentCursor: loaded.cursor,
payload: {
...baseTimelineInput.payload,
direction: "tail",
window: { minSeq: 1, maxSeq: 7, nextSeq: 8 },
startCursor: { seq: 1 },
endCursor: { seq: 7 },
entries: [
makeTimelineEntry(1, "A", "user_message"),
makeTimelineEntry(2, "reply A"),
makeTimelineEntry(6, "C", "user_message"),
makeTimelineEntry(7, "reply C"),
],
retirements: [rewindB],
},
});
expect(conversation(resumed)).toEqual(["A", "reply A", "C", "reply C"]);
expect(resumed.tail[0]).toBe(loaded.tail[0]);
expect(resumed.cursor).toMatchObject({ endSeq: 7 });
});
it("recovers a rewind that skips past the loaded end", () => {
const loaded = loadAB();
const gapped = live(loaded, [rewindEvent({ seq: 9, retireFromSeq: 3 })]);
expect(conversation(gapped)).toEqual(["A", "reply A", "B", "reply B"]);
expect(gapped.sideEffects).toEqual([
{ type: "catch_up", cursor: { epoch: "epoch-1", endSeq: 4 } },
]);
});
it("keeps an A row whose late update carries a seq inside B's range", () => {
const loaded = loadAB();
const [promptA, replyA, ...rest] = loaded.tail;
const lateTool = hydrateStreamState(
[
{
event: makeToolCallTimelineEvent("call-a"),
timestamp: new Date(1002),
timelineCursor: { epoch: "epoch-1", seq: 4, anchorSeq: 2 },
},
],
{ source: "canonical" },
);
const state = { ...loaded, tail: [promptA, replyA, ...lateTool, ...rest] };
const rewound = live(state, [rewindEvent(rewindB)]);
expect(conversation(rewound)).toEqual(["A", "reply A", "tool_call"]);
});
it("keeps a tool that started before B when B's prompt is not loaded and the tool updated after B", () => {
const toolA = {
...makeToolCallTimelineEntry(2, "call-a", "completed", {
type: "read",
filePath: "/tmp/a.ts",
}),
seqEnd: 6,
sourceSeqRanges: [
{ startSeq: 2, endSeq: 2 },
{ startSeq: 6, endSeq: 6 },
],
};
const loaded = processTimelineResponse({
...baseTimelineInput,
isInitializing: true,
hasActiveInitDeferred: true,
payload: {
...baseTimelineInput.payload,
direction: "tail",
window: { minSeq: 1, maxSeq: 6, nextSeq: 7 },
startCursor: { seq: 1 },
endCursor: { seq: 6 },
entries: [makeTimelineEntry(1, "A", "user_message"), toolA],
},
});
if (!loaded.cursor) throw new Error("expected a loaded range");
const rewound = live({ tail: loaded.tail, head: loaded.head, cursor: loaded.cursor }, [
rewindEvent({ seq: 7, retireFromSeq: 3 }),
]);
expect(conversation(rewound)).toEqual(["A", "tool_call"]);
});
it("keeps C from the live overlay when the first page brings the retirement C followed", () => {
const overlay = processAgentStreamEvents({
events: [rewindEvent(rewindB), ...promptC],
currentTail: [],
currentHead: [],
currentCursor: undefined,
hasAuthoritativeBaseline: false,
});
const initialized = processTimelineResponse({
...baseTimelineInput,
isInitializing: true,
hasActiveInitDeferred: true,
currentTail: overlay.tail,
currentHead: overlay.head,
payload: {
...baseTimelineInput.payload,
direction: "tail",
window: { minSeq: 1, maxSeq: 5, nextSeq: 6 },
startCursor: { seq: 1 },
endCursor: { seq: 5 },
entries: [makeTimelineEntry(1, "A", "user_message"), makeTimelineEntry(2, "reply A")],
retirements: [rewindB],
},
});
expect(conversation(initialized)).toEqual(["A", "reply A", "C", "reply C"]);
});
});
@@ -1,7 +1,13 @@
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
import type { AssistantMessageItem, StreamItem, TodoEntry } from "@/types/stream";
import {
advanceTimelinePosition,
type AssistantMessageItem,
type StreamItem,
type TimelinePosition,
type TodoEntry,
} from "@/types/stream";
import type { TurnLivenessTransition } from "@/timeline/turn-liveness";
import {
applyStreamEvent,
@@ -71,6 +77,64 @@ interface TimelineSeqRange {
endSeq: number;
}
/** A conversation rewind took the rows anchored in `[retireFromSeq, seq)` out of the conversation. */
export interface TimelineRetirement {
seq: number;
retireFromSeq: number;
}
/** The live form of a retirement; it is sequenced with the stream events around it. */
export interface TimelineRewindEvent {
type: "rewind";
retireFromSeq: number;
}
/**
* Drops the rows a retirement took out of the conversation: those anchored in
* `[retireFromSeq, seq)`. Anchors do not move on update, so an earlier row updated after the
* rewound prompt stays, and a row that started after the retirement stays. Items without a
* timeline position are local sends the daemon has not echoed and stay.
*/
function retireRewoundStreamItems(input: {
tail: StreamItem[];
head: StreamItem[];
epoch: string;
retirement: TimelineRetirement;
}): { tail: StreamItem[]; head: StreamItem[] } {
const { tail, head, epoch, retirement } = input;
const isRetired = (item: StreamItem) => {
const position = item.timelineCursor;
return (
position?.epoch === epoch &&
position.anchorSeq >= retirement.retireFromSeq &&
position.anchorSeq < retirement.seq
);
};
const nextTail = tail.filter((item) => !isRetired(item));
const nextHead = head.filter((item) => !isRetired(item));
return {
tail: nextTail.length === tail.length ? tail : nextTail,
head: nextHead.length === head.length ? head : nextHead,
};
}
/**
* Applies a page's retirements to what this client holds. The page's own entries are already
* active. Applying a retirement twice changes nothing, since its range is bounded.
*/
function retireForPage(input: {
payload: ProcessTimelineResponseInput["payload"];
tail: StreamItem[];
head: StreamItem[];
}): { tail: StreamItem[]; head: StreamItem[] } {
const { payload } = input;
let held = { tail: input.tail, head: input.head };
for (const retirement of payload.retirements ?? []) {
held = retireRewoundStreamItems({ ...held, epoch: payload.epoch, retirement });
}
return held;
}
function mergeTimelineCoverage(
current: TimelineCursor,
added: TimelineLoadedRange,
@@ -150,6 +214,7 @@ export interface ProcessTimelineResponseInput {
hasNewer: boolean;
hasOlder: boolean;
mergeWindow?: boolean;
retirements?: TimelineRetirement[];
};
currentTail: StreamItem[];
currentHead: StreamItem[];
@@ -391,7 +456,7 @@ function mergeTimelineWindow(args: {
toHydratedEvents: (units: TimelineUnit[]) => Array<{
event: AgentStreamEventPayload;
timestamp: Date;
timelineCursor: { epoch: string; seq: number };
timelineCursor: TimelinePosition;
}>;
}): TimelinePathResult {
const { timelineUnits, payload, currentTail, currentHead, currentCursor, toHydratedEvents } =
@@ -755,7 +820,7 @@ function replaceLiveAssistantWithProjectedText(params: {
head: StreamItem[];
event: AgentStreamEventPayload;
timestamp: Date;
timelineCursor: { epoch: string; seq: number };
timelineCursor: TimelinePosition;
}): StreamItem[] | null {
const { head, event, timestamp, timelineCursor } = params;
if (event.type !== "timeline" || event.item.type !== "assistant_message") {
@@ -774,7 +839,7 @@ function replaceLiveAssistantWithProjectedText(params: {
...current,
text: event.item.text,
timestamp,
timelineCursor,
...advanceTimelinePosition(current.timelineCursor, timelineCursor),
};
return next;
}
@@ -824,7 +889,7 @@ function reconcileOverlappingProjectedAssistant(params: {
...(messageId !== undefined ? { messageId } : {}),
text: projectedText,
timestamp: unit.timestamp,
timelineCursor: { epoch: params.epoch, seq: unit.seqEnd },
timelineCursor: { epoch: params.epoch, seq: unit.seqEnd, anchorSeq: unit.seq },
};
const replaceMatch = (items: StreamItem[], index: number) => {
const next = [...items];
@@ -941,8 +1006,8 @@ function applyCanonicalForwardUnit(params: {
unit: TimelineUnit;
epoch: string;
}): { tail: StreamItem[]; head: StreamItem[]; acknowledgedClientMessageIds: string[] } {
const { event, timestamp, seqEnd } = params.unit;
const timelineCursor = { epoch: params.epoch, seq: seqEnd };
const { event, timestamp, seq, seqEnd } = params.unit;
const timelineCursor: TimelinePosition = { epoch: params.epoch, seq: seqEnd, anchorSeq: seq };
if (event.type === "timeline" && event.item.type === "user_message") {
const applied = applyStreamEvent({
tail: params.tail,
@@ -1141,10 +1206,10 @@ function applyAcceptedTimelinePage(input: {
});
}
const olderTail = hydrateStreamState(
acceptedUnits.map(({ event, timestamp, seqEnd }) => ({
acceptedUnits.map(({ event, timestamp, seq, seqEnd }) => ({
event,
timestamp,
timelineCursor: { epoch: payload.epoch, seq: seqEnd },
timelineCursor: { epoch: payload.epoch, seq: seqEnd, anchorSeq: seq },
})),
{
source: "canonical",
@@ -1176,7 +1241,12 @@ function applyTimelineIncrementalPath(args: {
let cursorChanged = false;
const sideEffects: TimelineReducerSideEffect[] = [];
if (timelineUnits.length === 0 && payload.direction !== "before") {
// A forward page of retirements alone still advances the loaded end.
if (
timelineUnits.length === 0 &&
payload.direction !== "before" &&
(payload.retirements?.length ?? 0) === 0
) {
return {
tail: currentTail,
head: currentHead,
@@ -1234,8 +1304,6 @@ export function processTimelineResponse(
): ProcessTimelineResponseOutput {
const {
payload,
currentTail,
currentHead,
currentCursor,
isInitializing,
hasActiveInitDeferred,
@@ -1249,8 +1317,8 @@ export function processTimelineResponse(
if (payload.error) {
return {
commit: "apply",
tail: currentTail,
head: currentHead,
tail: input.currentTail,
head: input.currentHead,
cursor: currentCursor,
cursorChanged: false,
older: "unchanged",
@@ -1262,6 +1330,13 @@ export function processTimelineResponse(
};
}
// Entries are already free of retired rows; the retirements apply to what this client holds.
const { tail: currentTail, head: currentHead } = retireForPage({
payload,
tail: input.currentTail,
head: input.currentHead,
});
// ------------------------------------------------------------------
// Convert entries to timeline units
// ------------------------------------------------------------------
@@ -1286,12 +1361,12 @@ export function processTimelineResponse(
): Array<{
event: AgentStreamEventPayload;
timestamp: Date;
timelineCursor: { epoch: string; seq: number };
timelineCursor: TimelinePosition;
}> =>
units.map(({ event, timestamp, seqEnd }) => ({
units.map(({ event, timestamp, seq, seqEnd }) => ({
event,
timestamp,
timelineCursor: { epoch: payload.epoch, seq: seqEnd },
timelineCursor: { epoch: payload.epoch, seq: seqEnd, anchorSeq: seq },
}));
// ------------------------------------------------------------------
@@ -1426,7 +1501,7 @@ export function processTimelineResponse(
// ---------------------------------------------------------------------------
export interface ProcessAgentStreamEventInput {
event: AgentStreamEventPayload;
event: AgentStreamEventPayload | TimelineRewindEvent;
seq: number | undefined;
epoch: string | undefined;
currentTail: StreamItem[];
@@ -1449,7 +1524,7 @@ export interface ProcessAgentStreamEventOutput {
}
export interface AgentStreamReducerEvent {
event: AgentStreamEventPayload;
event: AgentStreamEventPayload | TimelineRewindEvent;
seq: number | undefined;
epoch: string | undefined;
timestamp: Date;
@@ -1567,11 +1642,70 @@ function processTimelineSequencingGate(input: {
};
}
/**
* Applies a live rewind in sequence with the stream. One that does not follow the loaded end is
* stale (already applied, or older than a page this client took) or leaves a gap to catch up.
*/
function processTimelineRewind(
input: ProcessAgentStreamEventInput & { event: TimelineRewindEvent },
): ProcessAgentStreamEventOutput {
const { event, seq, epoch, currentTail, currentHead, currentCursor } = input;
const unchanged: ProcessAgentStreamEventOutput = {
tail: currentTail,
head: currentHead,
changedTail: false,
changedHead: false,
cursor: null,
cursorChanged: false,
acknowledgedClientMessageIds: [],
sideEffects: [],
};
if (seq === undefined || epoch === undefined) return unchanged;
const decision =
input.hasAuthoritativeBaseline === false
? "overlay"
: classifySessionTimelineSeq({
cursor: currentCursor
? { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq }
: null,
epoch,
seq,
});
if (decision === "gap" && currentCursor) {
return {
...unchanged,
sideEffects: [
{ type: "catch_up", cursor: { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq } },
],
};
}
if (decision !== "accept" && decision !== "init" && decision !== "overlay") return unchanged;
const retired = retireRewoundStreamItems({
tail: currentTail,
head: currentHead,
epoch,
retirement: { seq, retireFromSeq: event.retireFromSeq },
});
let cursor: TimelineCursor | null = null;
if (decision === "accept" && currentCursor) cursor = { ...currentCursor, endSeq: seq };
if (decision === "init") cursor = { epoch, startSeq: seq, endSeq: seq };
return {
...unchanged,
tail: retired.tail,
head: retired.head,
changedTail: retired.tail !== currentTail,
changedHead: retired.head !== currentHead,
cursor,
cursorChanged: cursor !== null,
};
}
export function processAgentStreamEvent(
input: ProcessAgentStreamEventInput,
): ProcessAgentStreamEventOutput {
const event = input.event;
if (event.type === "rewind") return processTimelineRewind({ ...input, event });
const {
event,
seq,
epoch,
currentTail,
@@ -1590,7 +1724,7 @@ export function processAgentStreamEvent(
});
const timelineCursor =
event.type === "timeline" && seq !== undefined && epoch !== undefined
? { epoch, seq }
? { epoch, seq, anchorSeq: seq }
: undefined;
// ------------------------------------------------------------------
// Apply stream event to tail/head
@@ -1904,6 +2038,10 @@ export function createSessionAgentStreamReducerQueue(
};
},
commit: (agentId, result, events) => {
for (const { event, seq, epoch } of events) {
if (event.type !== "rewind" || seq === undefined || epoch === undefined) continue;
useSessionStore.getState().recordAgentTimelineRewind(serverId, agentId, { epoch, seq });
}
if (
result.changedTail ||
result.changedHead ||
@@ -236,6 +236,13 @@ function commitProcessedTimeline(input: {
const agentId = payload.agentId;
const store = useSessionStore.getState();
if (result.commit !== "discard") {
const latestRewindSeq = Math.max(...(payload.retirements ?? []).map(({ seq }) => seq));
if (Number.isFinite(latestRewindSeq)) {
store.recordAgentTimelineRewind(serverId, agentId, {
epoch: payload.epoch,
seq: latestRewindSeq,
});
}
store.applyAgentTimelineResponseState(serverId, agentId, {
items: result.tail,
head: result.head,
@@ -456,8 +463,14 @@ export type ViewedTimelineOwnerPorts = Omit<
| "applyCurrentTimeline"
>;
/**
* `stale`: the page was served before a rewind this client already applied, so it can hold rows
* that rewind retired. Nothing was applied; request the page again.
*/
export type TimelineResponseOutcome = "applied" | "stale";
export interface ViewedTimelineOwner extends ViewedTimelineSync {
applyTimelineResponse(payload: TimelineResponsePayload): void;
applyTimelineResponse(payload: TimelineResponsePayload): TimelineResponseOutcome;
/**
* Accepts one live stream event. Content is coalesced to the next frame; a turn boundary
* commits the turn's queued content before its liveness changes, so the view never shows
@@ -467,6 +480,37 @@ export interface ViewedTimelineOwner extends ViewedTimelineSync {
flushStreamAgent(agentId: string): void;
}
/**
* The latest retirement each chat has seen, live or on a page. Tail, older and prompt-jump pages
* are served from active rows at one moment; one served before that retirement can still hold the
* rows it retired, and has no retirement to drop them with. Forward pages are sequenced against
* the loaded end instead, so they are never stale here.
*/
function createRetirementHighWater() {
const latest = new Map<string, { epoch: string; seq: number }>();
function note(agentId: string, retirement: { epoch: string; seq: number }): void {
const current = latest.get(agentId);
if (current?.epoch === retirement.epoch && current.seq >= retirement.seq) return;
latest.set(agentId, retirement);
}
return {
note,
notePage(payload: TimelineResponsePayload): void {
for (const retirement of payload.retirements ?? []) {
note(payload.agentId, { epoch: payload.epoch, seq: retirement.seq });
}
},
servedBefore(payload: TimelineResponsePayload): boolean {
const known = latest.get(payload.agentId);
return (
payload.direction !== "after" &&
known?.epoch === payload.epoch &&
payload.window.maxSeq < known.seq
);
},
};
}
export function createViewedTimelineOwner(input: {
serverId: string;
replica: TimelineReplica;
@@ -476,6 +520,7 @@ export function createViewedTimelineOwner(input: {
ports: ViewedTimelineOwnerPorts;
}): ViewedTimelineOwner {
const forcedTailReplacements = new Set<string>();
const retirements = createRetirementHighWater();
const sync = createViewedTimelineSync({
...input.ports,
fetchLatestTail: async (agentId) => {
@@ -516,6 +561,8 @@ export function createViewedTimelineOwner(input: {
return {
...sync,
applyTimelineResponse(receivedPayload) {
if (retirements.servedBefore(receivedPayload)) return "stale";
retirements.notePage(receivedPayload);
const payload = consumeForcedTimelineTailReplacement(receivedPayload, forcedTailReplacements);
const accepted = applyAuthoritativeTimelineResponse({
serverId: input.serverId,
@@ -525,8 +572,12 @@ export function createViewedTimelineOwner(input: {
drainQueuedAgentMessage: input.drainQueuedAgentMessage,
});
if (accepted) input.replica.timelineUpdated(payload.agentId);
return "applied";
},
applyStreamEvent(agentId, event) {
if (event.event.type === "rewind" && event.epoch !== undefined && event.seq !== undefined) {
retirements.note(agentId, { epoch: event.epoch, seq: event.seq });
}
streamQueue.enqueue(agentId, event);
const turnLiveness = deriveAgentStreamTurnLiveness([event]);
if (turnLiveness.length === 0) return;
+7 -3
View File
@@ -1009,7 +1009,7 @@ describe("stream reducer canonical tool calls", () => {
});
it("keeps the timeline position on the whole assistant message", () => {
const timelineCursor = { epoch: "epoch-1", seq: 42 };
const timelineCursor = { epoch: "epoch-1", seq: 42, anchorSeq: 42 };
const result = applyStreamEvent({
tail: [],
head: [],
@@ -1890,7 +1890,7 @@ describe("turn lifecycle events", () => {
new Date("2025-01-01T15:03:11Z"),
{
source: "canonical",
timelineCursor: { epoch: "epoch-1", seq: 42 },
timelineCursor: { epoch: "epoch-1", seq: 42, anchorSeq: 42 },
},
);
@@ -1902,7 +1902,11 @@ describe("turn lifecycle events", () => {
assert.strictEqual(userMessage.messageId, "provider-owned-canonical");
assert.strictEqual(userMessage.text, "Analyze this");
assert.strictEqual(userMessage.timestamp.getTime(), submittedTimestamp.getTime());
assert.deepStrictEqual(userMessage.timelineCursor, { epoch: "epoch-1", seq: 42 });
assert.deepStrictEqual(userMessage.timelineCursor, {
epoch: "epoch-1",
seq: 42,
anchorSeq: 42,
});
assert.deepStrictEqual(userMessage.images, [image]);
assert.deepStrictEqual(userMessage.attachments, [attachment]);
});
+51 -14
View File
@@ -264,7 +264,7 @@ function produceUserMessage(
...presentation,
clientMessageId: incoming.clientMessageId ?? existing.clientMessageId,
messageId: incoming.messageId ?? existing.messageId,
timelineCursor: incoming.timelineCursor ?? existing.timelineCursor,
...advanceTimelinePosition(existing.timelineCursor, incoming.timelineCursor),
});
if (
existing.id === merged.id &&
@@ -480,7 +480,7 @@ function mergeRetainedLifecycleItem(tail: StreamItem[], retained: StreamItem): S
const next = [...tail];
next[tailIndex] = {
...existing,
timelineCursor: retained.timelineCursor,
...advanceTimelinePosition(existing.timelineCursor, retained.timelineCursor),
timestamp: retained.timestamp,
items: retained.items,
};
@@ -497,7 +497,7 @@ function mergeRetainedLifecycleItem(tail: StreamItem[], retained: StreamItem): S
const next = [...tail];
next[tailIndex] = {
...existing,
timelineCursor: retained.timelineCursor,
...advanceTimelinePosition(existing.timelineCursor, retained.timelineCursor),
status: "completed",
trigger: retained.trigger ?? existing.trigger,
preTokens: retained.preTokens ?? existing.preTokens,
@@ -517,7 +517,10 @@ function replaceTimelineIdentityItem(
const existing = items[index];
if (index < 0 || !existing || existing.kind !== "plugin") return null;
const next = [...items];
next[index] = retained;
next[index] = {
...retained,
...advanceTimelinePosition(existing.timelineCursor, retained.timelineCursor),
};
return next;
}
@@ -608,7 +611,11 @@ function preserveReplacementHead(
: `${tailAssistant.text}${liveAssistant.text}`;
const head = [
...unreconciledHead.slice(0, liveAssistantIndex),
{ ...liveAssistant, text },
{
...liveAssistant,
text,
...advanceTimelinePosition(tailAssistant.timelineCursor, liveAssistant.timelineCursor),
},
...unreconciledHead.slice(liveAssistantIndex + 1),
];
return {
@@ -623,7 +630,13 @@ function preserveReplacementHead(
const head = [
...unreconciledHead.slice(0, liveAssistantIndex),
{ ...liveAssistant, text: tailAssistant.text },
{
...liveAssistant,
text: tailAssistant.text,
...(liveAssistant.timelineCursor
? advanceTimelinePosition(tailAssistant.timelineCursor, liveAssistant.timelineCursor)
: {}),
},
...unreconciledHead.slice(liveAssistantIndex + 1),
];
return {
@@ -720,7 +733,27 @@ export interface AssistantMessageItem {
export interface TimelinePosition {
epoch: string;
/** The latest seq that changed this row. */
seq: number;
/** The seq the row started at. Updates never move it; rewinds retire rows by it. */
anchorSeq: number;
}
/**
* The position of a row after an update: it moves to the update's seq and stays anchored where it
* started. Without an incoming position the row keeps the one it has.
*/
export function advanceTimelinePosition(
existing: TimelinePosition | undefined,
incoming: TimelinePosition | undefined,
): { timelineCursor?: TimelinePosition } {
if (!incoming) return existing ? { timelineCursor: existing } : {};
if (!existing || existing.epoch !== incoming.epoch) return { timelineCursor: incoming };
const anchorSeq = Math.min(existing.anchorSeq, incoming.anchorSeq);
if (existing.seq === incoming.seq && existing.anchorSeq === anchorSeq) {
return { timelineCursor: existing };
}
return { timelineCursor: { ...incoming, anchorSeq } };
}
export type ThoughtStatus = "loading" | "ready";
@@ -966,7 +999,7 @@ function appendAssistantMessage(
...last,
text: `${last.text}${chunk}`,
timestamp,
...(timelineCursor ? { timelineCursor } : {}),
...advanceTimelinePosition(last.timelineCursor, timelineCursor),
};
return [...state.slice(0, -1), updated];
}
@@ -984,7 +1017,7 @@ function appendAssistantMessage(
...secondLast,
text: `${secondLast.text}${chunk}`,
timestamp,
...(timelineCursor ? { timelineCursor } : {}),
...advanceTimelinePosition(secondLast.timelineCursor, timelineCursor),
};
return [...state.slice(0, -2), updated, last];
}
@@ -1022,7 +1055,7 @@ function appendThought(
if (last && last.kind === "thought" && last.turnId === turnId) {
const updated: ThoughtItem = {
...last,
...(timelineCursor ? { timelineCursor } : {}),
...advanceTimelinePosition(last.timelineCursor, timelineCursor),
text: `${last.text}${chunk}`,
timestamp,
status: "loading",
@@ -1202,7 +1235,7 @@ export function mergeAgentToolCallItem(
return {
...existing,
...(timelineCursor ? { timelineCursor } : {}),
...advanceTimelinePosition(existing.timelineCursor, timelineCursor),
timestamp,
payload: {
source: "agent",
@@ -1308,7 +1341,11 @@ function appendPluginTimelineItem(
const existing = state[existingIndex];
if (!existing || existing.kind !== "plugin") return state;
const next = [...state];
next[existingIndex] = { ...nextItem, id: existing.id };
next[existingIndex] = {
...nextItem,
id: existing.id,
...advanceTimelinePosition(existing.timelineCursor, timelineCursor),
};
return next;
}
@@ -1339,7 +1376,7 @@ function appendTodoList(
const next = [...state];
next[previousIndex] = {
...previous,
...(timelineCursor ? { timelineCursor } : {}),
...advanceTimelinePosition(previous.timelineCursor, timelineCursor),
items: normalizedItems,
timestamp,
};
@@ -1358,7 +1395,7 @@ function appendTodoList(
const next = [...state];
next[next.length - 1] = {
...lastItem,
...(timelineCursor ? { timelineCursor } : {}),
...advanceTimelinePosition(lastItem.timelineCursor, timelineCursor),
items: normalizedItems,
activity: { type: "created", count: normalizedItems.length },
timestamp,
@@ -1503,7 +1540,7 @@ function reduceTimelineCompaction(
if (loadingIdx >= 0 && existing && existing.kind === "compaction") {
const updated: CompactionItem = {
...existing,
...(timelineCursor ? { timelineCursor } : {}),
...advanceTimelinePosition(existing.timelineCursor, timelineCursor),
status: "completed",
trigger: item.trigger ?? existing.trigger,
preTokens: item.preTokens ?? existing.preTokens,
@@ -146,6 +146,10 @@ export async function runAttachCommand(
const unsubscribe = client.subscribeAgentTimeline(resolvedId, (message) => {
if (message.type === "agent.timeline.replacement") {
console.log("\n[Timeline replaced; earlier output is no longer current]");
} else if (message.type === "agent.timeline.rewind") {
console.log(
"\n[Conversation rewound; output since the rewound message is no longer current]",
);
} else if (message.type === "agent.timeline.subscription_restored") {
console.log("\n[Reconnected; live output resumed. Events may have been missed.]");
} else if (message.type === "agent.timeline.error") {
+5 -1
View File
@@ -408,7 +408,6 @@ async function runFollowMode({
} catch (error) {
console.warn("Warning: failed to fetch existing timeline", error);
}
// Apply filter to existing items
if (options.filter) {
existingItems = existingItems.filter((item) => matchesFilter(item, options.filter));
@@ -439,6 +438,11 @@ async function runFollowMode({
notice("\n[Timeline replaced; earlier output is no longer current]");
return;
}
if (message.type === "agent.timeline.rewind") {
writer.end();
notice("\n[Conversation rewound; output since the rewound message is no longer current]");
return;
}
if (message.type === "agent.timeline.error") {
writer.end();
+31
View File
@@ -211,6 +211,37 @@ test("SDK subscribers receive timeline replacement instead of silently losing hi
}
});
test("SDK subscribers receive a conversation rewind as a retired range", async () => {
const { createPaseoApi } = await import("./index");
const h = connection();
const received: unknown[] = [];
try {
const ready = h.client.connect();
h.open();
await ready;
const off = createPaseoApi(h.client)
.agents.ref("agent")
.timeline.subscribe((event) => received.push(event));
await off.ready;
h.receive({
type: "agent.timeline.rewind",
payload: {
agentId: "agent",
epoch: "epoch-1",
seq: 12,
retireFromSeq: 7,
subscriptionId: off.subscriptionId,
},
});
expect(received).toEqual([
{ agentId: "agent", event: { type: "rewind", epoch: "epoch-1", seq: 12, retireFromSeq: 7 } },
]);
off();
} finally {
await h.client.close();
}
});
test("local listeners create no demand before or after reconnect", async () => {
const h = connection();
try {
+1
View File
@@ -140,6 +140,7 @@ export const DEFAULT_CLIENT_CAPABILITIES = {
[CLIENT_CAPS.compactProviderSnapshots]: true,
[CLIENT_CAPS.providerSnapshotReferences]: true,
[CLIENT_CAPS.timelineReplacementInvalidation]: true,
[CLIENT_CAPS.timelineRewindOperations]: true,
[CLIENT_CAPS.timelineNotifications]: true,
[CLIENT_CAPS.pluginTimelineItems]: true,
[CLIENT_CAPS.workspaceSetupBlocked]: true,
+3 -1
View File
@@ -246,7 +246,9 @@ export class LegacySubscriptions {
);
case "agent.timeline.set_subscription.request":
return (
(message.type === "agent_stream" || message.type === "agent.timeline.replacement") &&
(message.type === "agent_stream" ||
message.type === "agent.timeline.replacement" ||
message.type === "agent.timeline.rewind") &&
(query.agentIds as string[]).includes(message.payload.agentId)
);
case "session.events.set_subscription.request":
@@ -1129,6 +1129,7 @@ test("advertises client capabilities in hello", async () => {
all_providers: true,
selective_agent_timeline: true,
timeline_replacement_invalidation: true,
timeline_rewind_operations: true,
provider_snapshot_references: true,
explicit_event_subscriptions: true,
owned_subscriptions: true,
+21 -1
View File
@@ -341,6 +341,14 @@ export type PaseoAgentTimelineEvent =
agentId: string;
event: { type: "replacement"; epoch: string };
}
| {
/**
* A conversation rewind removed the rows anchored in `[retireFromSeq, seq)` from the active
* history. Rows after `seq` belong to the rewound conversation.
*/
agentId: string;
event: { type: "rewind"; epoch: string; seq: number; retireFromSeq: number };
}
| {
agentId: string;
subscriptionId: string;
@@ -362,7 +370,9 @@ export interface PaseoAgentTimelineHandle {
* Delivers live events only. After reconnect, subscription_restored precedes
* subsequent updates. History may have been missed; use refetch() to request
* the range you need. No history is fetched automatically. A replacement event
* invalidates the previous epoch. Subscription errors release this observation.
* invalidates the previous epoch. A rewind event removes `[retireFromSeq, seq)`
* from the active history without changing the epoch. Subscription errors
* release this observation.
* Await the returned unsubscribe function's `ready` promise before starting
* work that must be observed. It rejects if establishment fails.
*/
@@ -1042,6 +1052,16 @@ function createAgentHandleFactory(
agentId: id,
event: { type: "replacement", epoch: message.payload.epoch },
});
case "agent.timeline.rewind":
return handler({
agentId: id,
event: {
type: "rewind",
epoch: message.payload.epoch,
seq: message.payload.seq,
retireFromSeq: message.payload.retireFromSeq,
},
});
}
}),
},
@@ -3,7 +3,7 @@ import type { OwnedSubscription, TimelineSubscription } from "../connection/inde
type TimelineUpdate = Extract<
SessionOutboundMessage,
{ type: "agent_stream" | "agent.timeline.replacement" }
{ type: "agent_stream" | "agent.timeline.replacement" | "agent.timeline.rewind" }
>;
/** Local SDK lifecycle messages; these are not additional wire RPCs. */
@@ -59,7 +59,11 @@ export function subscribeTimeline(
});
},
update: (message) => {
if (message.type === "agent_stream" || message.type === "agent.timeline.replacement")
if (
message.type === "agent_stream" ||
message.type === "agent.timeline.replacement" ||
message.type === "agent.timeline.rewind"
)
notify(message);
},
error: fail,
@@ -39,6 +39,9 @@ export const CLIENT_CAPS = {
// COMPAT(timelineReplacementInvalidation): added in v0.5.0, remove legacy
// reconstructed timeline replay after 2027-02-21 once the client floor supports invalidation.
timelineReplacementInvalidation: "timeline_replacement_invalidation",
// COMPAT(timelineRewindOperations): added in v0.14.1, remove after 2027-05-10 once the client
// floor applies rewinds. Clients without it see a rewound history under a derived epoch.
timelineRewindOperations: "timeline_rewind_operations",
// COMPAT(timelineNotifications): added in v0.7.2. The daemon omits notification
// timeline items for older clients whose strict timeline union rejects them.
// Remove after 2027-03-03 once the supported client floor is >= v0.7.2.
+28
View File
@@ -3997,6 +3997,9 @@ export const ServerInfoStatusPayloadSchema = z
"terminal-grapheme-widths": z.boolean().optional(),
// COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X.
rewind: z.boolean().optional(),
// COMPAT(timelineRewindOperations): added in v0.14.1, remove gate after 2027-05-10.
// A conversation rewind arrives as `agent.timeline.rewind`, not a replacement.
timelineRewindOperations: z.boolean().optional(),
// COMPAT(agentTimelinePromptIndex): added in v0.2.X, drop the gate when floor >= v0.2.X.
agentTimelinePromptIndex: z.boolean().optional(),
// COMPAT(agentHistorySearch): added in v0.3.0, remove gate after 2027-02-07.
@@ -4194,6 +4197,8 @@ export const AgentResumedStatusPayloadSchema = z
export const AgentRefreshedStatusPayloadSchema = z
.object({
status: z.literal("agent_refreshed"),
/** The provider's history differs from the kept history, which Reload agent did not change. */
historyDiverged: z.boolean().optional(),
})
.extend(AgentStatusWithTimelineSchema.shape);
@@ -5034,6 +5039,12 @@ export const AgentTimelineEntryPayloadSchema = z.object({
collapsed: z.array(z.enum(["assistant_merge", "reasoning_merge", "tool_lifecycle", "identity"])),
});
/** Active rows anchored in `[retireFromSeq, seq)` left the conversation. `seq` is the retirement's own. */
export const AgentTimelineRetirementSchema = z.object({
seq: z.number().int().nonnegative(),
retireFromSeq: z.number().int().nonnegative(),
});
export const FetchAgentTimelineResponseMessageSchema = z.object({
type: z.literal("fetch_agent_timeline_response"),
payload: z.object({
@@ -5057,6 +5068,9 @@ export const FetchAgentTimelineResponseMessageSchema = z.object({
hasNewer: z.boolean(),
mergeWindow: z.boolean().optional(),
entries: z.array(AgentTimelineEntryPayloadSchema),
// COMPAT(timelineRewindOperations): added in v0.14.1. Retirements whose seq lies in the page's
// span; apply them before the entries. Sent only to clients with the capability.
retirements: z.array(AgentTimelineRetirementSchema).optional(),
error: z.string().nullable(),
}),
});
@@ -5070,6 +5084,18 @@ export const AgentTimelineReplacementMessageSchema = z.object({
}),
});
/** A conversation rewind retired active rows. Apply it when `seq` follows the loaded end. */
export const AgentTimelineRewindMessageSchema = z.object({
type: z.literal("agent.timeline.rewind"),
payload: z.object({
subscriptionId: z.string().optional(),
agentId: z.string(),
epoch: z.string(),
seq: z.number().int().nonnegative(),
retireFromSeq: z.number().int().nonnegative(),
}),
});
export const AgentTimelineSearchResponseMessageSchema = z.object({
type: z.literal("agent.timeline.search.response"),
payload: z.object({
@@ -7459,6 +7485,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
FetchAgentResponseMessageSchema,
FetchAgentTimelineResponseMessageSchema,
AgentTimelineReplacementMessageSchema,
AgentTimelineRewindMessageSchema,
AgentTimelineSearchResponseMessageSchema,
AgentTimelineListPromptsResponseMessageSchema,
ProviderSubagentListResponseMessageSchema,
@@ -8129,6 +8156,7 @@ export const WSHelloMessageSchema = z.object({
[CLIENT_CAPS.compactProviderSnapshots]: z.boolean().optional(),
[CLIENT_CAPS.providerSnapshotReferences]: z.boolean().optional(),
[CLIENT_CAPS.timelineReplacementInvalidation]: z.boolean().optional(),
[CLIENT_CAPS.timelineRewindOperations]: z.boolean().optional(),
[CLIENT_CAPS.timelineNotifications]: z.boolean().optional(),
[CLIENT_CAPS.backgroundAgents]: z.boolean().optional(),
[CLIENT_CAPS.browserHost]: BrowserAutomationHostCapabilitySchema.optional(),
@@ -181,6 +181,64 @@ describe("wire schema compatibility", () => {
});
});
test("rewind operations are opt-in and older fetch responses still parse", () => {
expect(
WSHelloMessageSchema.parse({
type: "hello",
clientId: "capable-client",
clientType: "mobile",
protocolVersion: 1,
capabilities: { timeline_rewind_operations: true },
}).capabilities,
).toEqual({ timeline_rewind_operations: true });
const rewind = {
type: "agent.timeline.rewind",
payload: {
subscriptionId: "sub-1",
agentId: "agent-1",
epoch: "epoch-1",
seq: 12,
retireFromSeq: 7,
},
};
expect(SessionOutboundMessageSchema.parse(rewind)).toEqual(rewind);
const page = {
requestId: "fetch-1",
agentId: "agent-1",
agent: null,
direction: "after",
projection: "projected",
epoch: "epoch-1",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 1, maxSeq: 14, nextSeq: 15 },
startCursor: { epoch: "epoch-1", seq: 13 },
endCursor: { epoch: "epoch-1", seq: 14 },
hasOlder: true,
hasNewer: false,
entries: [],
error: null,
};
const withRetirements = { ...page, retirements: [{ seq: 12, retireFromSeq: 7 }] };
expect(
SessionOutboundMessageSchema.parse({
type: "fetch_agent_timeline_response",
payload: withRetirements,
}),
).toMatchObject({ payload: { retirements: [{ seq: 12, retireFromSeq: 7 }] } });
// A daemon before rewind operations sends no retirements.
const older = SessionOutboundMessageSchema.parse({
type: "fetch_agent_timeline_response",
payload: page,
});
expect(older.type === "fetch_agent_timeline_response" && older.payload.retirements).toBe(
undefined,
);
});
test("server info strips unknown legacy features while accepting former turn identity", () => {
const parsed = ServerInfoStatusPayloadSchema.parse({
status: "server_info",
@@ -1,11 +1,11 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "vitest";
import { expect, test, vi } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { AgentManager } from "./agent-manager.js";
import { ensureAgentLoaded } from "./agent-loading.js";
import { AgentNotFoundError, ensureAgentLoaded, loadAgentHistory } from "./agent-loading.js";
import { startAgentRun } from "./agent-prompt.js";
import { AgentStorage } from "./agent-storage.js";
import type {
@@ -227,6 +227,109 @@ test("reopening an agent with no resumable session keeps a metadata write that o
}
});
test("reads stored history without starting or requiring the provider, and imports history that is not stored", async () => {
const root = await mkdtemp(path.join(tmpdir(), "agent-loading-history-"));
const logger = createTestLogger();
const storage = new AgentStorage(path.join(root, "agents"), logger);
const baseClient = createTestAgentClients().codex;
if (!baseClient) {
throw new Error("expected Codex test client");
}
let resumes = 0;
const client: AgentClient = {
provider: baseClient.provider,
capabilities: baseClient.capabilities,
createSession: async (config: AgentSessionConfig, launchContext?: AgentLaunchContext) =>
await baseClient.createSession(config, launchContext),
resumeSession: async (
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
) => {
resumes += 1;
return await baseClient.resumeSession(handle, overrides, launchContext);
},
fetchCatalog: async (options) => await baseClient.fetchCatalog(options),
isAvailable: async () => await baseClient.isAvailable(),
};
const agentId = "00000000-0000-4000-8000-000000000601";
const managers: AgentManager[] = [];
function startDaemon(): AgentManager {
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
managers.push(manager);
return manager;
}
try {
const manager = startDaemon();
const agent = await manager.createAgent({ provider: "codex", cwd: root }, agentId, {
workspaceId: "workspace-a",
});
await manager.runAgent(agent.id, "hello");
const transcript = await manager.getTimeline(agent.id);
expect(transcript.map((item) => item.type)).toContain("assistant_message");
await manager.closeAgent(agent.id);
await manager.flush();
await storage.flush();
const stored = await loadAgentHistory(agentId, {
agentManager: manager,
agentStorage: storage,
logger,
});
expect(stored).toMatchObject({ kind: "stored", record: { id: agentId } });
expect(await manager.getTimeline(agentId)).toEqual(transcript);
expect(manager.getAgent(agentId)).toBeNull();
// Stored history needs no provider, so it still reads once the provider is removed.
await expect(
loadAgentHistory(agentId, {
agentManager: manager,
agentStorage: storage,
logger,
validProviders: [],
}),
).resolves.toMatchObject({ kind: "stored", record: { id: agentId } });
expect(resumes).toBe(0);
// A restarted daemon has no history in memory; the provider's own history is imported,
// which needs the provider.
const restarted = startDaemon();
await expect(
loadAgentHistory(agentId, {
agentManager: restarted,
agentStorage: storage,
logger,
validProviders: [],
}),
).rejects.toThrow("references unavailable provider 'codex'");
const loaded = await loadAgentHistory(agentId, {
agentManager: restarted,
agentStorage: storage,
logger,
});
expect(loaded).toMatchObject({ kind: "live", agent: { id: agentId } });
expect(resumes).toBe(1);
expect((await restarted.getTimeline(agentId)).map((item) => item.type)).toEqual(
transcript.map((item) => item.type),
);
await expect(
loadAgentHistory("00000000-0000-4000-8000-000000000602", {
agentManager: restarted,
agentStorage: storage,
logger,
}),
).rejects.toBeInstanceOf(AgentNotFoundError);
} finally {
for (const manager of managers) {
await manager.closeAgent(agentId).catch(() => undefined);
await manager.flush().catch(() => undefined);
}
await storage.flush().catch(() => undefined);
await rm(root, { recursive: true, force: true });
}
});
test.each([false, true])(
"loads history after its working directory is removed (archived: %s)",
async (archived) => {
@@ -271,11 +374,11 @@ test.each([false, true])(
});
expect(loaded.id).toBe(agentId);
// The transcript is replayed from the provider's persisted history, so the reply the
// agent gave before the worktree went away is still readable.
const replies = manager
.getTimeline(agentId)
.filter((item) => item.type === "assistant_message");
// The manager kept the transcript when the agent closed, so the reply the agent gave
// before the worktree went away is still readable.
const replies = (await manager.getTimeline(agentId)).filter(
(item) => item.type === "assistant_message",
);
expect(replies.length).toBeGreaterThan(0);
expect(
replies.every((item) => item.type === "assistant_message" && item.text.length > 0),
@@ -318,7 +421,7 @@ test("timeline requests read unarchived stored history with a deleted cwd", asyn
}
}, 30000);
test("timeline reads keep runnable unarchived agents interactive for the next prompt", async () => {
test("timeline reads of stored history leave the provider closed until a prompt resumes it interactively", async () => {
const clients = createTestAgentClients();
const base = clients.codex!;
const purposes: Array<AgentResumeSessionOptions | undefined> = [];
@@ -339,7 +442,8 @@ test("timeline reads keep runnable unarchived agents interactive for the next pr
const agent = await ctx.client.createAgent({ provider: "codex", cwd });
await ctx.daemon.daemon.agentManager.closeAgent(agent.id);
await ctx.client.fetchAgentTimeline(agent.id);
expect(purposes).toEqual([{ purpose: "interactive" }]);
// The manager serves the stored timeline itself, so the read resumes no provider session.
expect(purposes).toEqual([]);
await ctx.client.sendMessage(agent.id, "continue after opening history");
expect((await ctx.client.waitForFinish(agent.id, 15000)).status).toBe("idle");
const timeline = await ctx.client.fetchAgentTimeline(agent.id);
@@ -353,11 +457,9 @@ test("timeline reads keep runnable unarchived agents interactive for the next pr
manager.tryRunOutOfBand(agent.id, "first prompt"),
manager.tryRunOutOfBand(agent.id, "second prompt"),
]);
expect(purposes).toEqual([
{ purpose: "interactive" },
{ purpose: "history" },
undefined, // Reload uses the provider's interactive default.
]);
// The prompt resumed once, and the concurrent prompts after the removed-cwd read share one
// resume. Every resume is interactive, so no history session needs replacing.
expect(purposes).toEqual([{ purpose: "interactive" }, { purpose: "interactive" }]);
} finally {
await ctx.cleanup();
await rm(cwd, { recursive: true, force: true });
@@ -451,9 +553,13 @@ test.each(["mode", "thinking", "rewind"])(
runtime.recordedLaunches.filter((launch) => launch.session === sessionFile),
).toHaveLength(1);
expect(manager.getAgent(agentId)?.currentModeId).toBe("full");
expect(runtime.latestSession().prompts).toEqual([
expect.objectContaining({ message: "continue" }),
]);
// The run reads its timeline before the provider starts the turn.
await vi.waitFor(() =>
expect(runtime.latestSession().prompts).toEqual([
expect.objectContaining({ message: "continue" }),
]),
);
runtime.latestSession().beginTurn();
runtime.latestSession().finishTurn();
await manager.waitForAgentEvent(agentId);
await manager.archiveAgent(agentId);
@@ -2,7 +2,7 @@ import type { Logger } from "pino";
import type { AgentProvider } from "./agent-sdk-types.js";
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage } from "./agent-storage.js";
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
import {
buildConfigOverrides,
extractAttention,
@@ -18,20 +18,11 @@ export class AgentNotFoundError extends Error {
}
}
interface PendingAgentInitialization {
promise: Promise<ManagedAgent>;
options: { broadcastTimeline: boolean };
}
const pendingAgentInitializations = new Map<string, PendingAgentInitialization>();
const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>();
export type AgentLoaderManager = Pick<
AgentManager,
| "createAgentFromRecord"
| "getAgent"
| "getRegisteredProviderIds"
| "hydrateTimelineFromProvider"
| "resumeAgentFromPersistence"
"createAgentFromRecord" | "getAgent" | "getRegisteredProviderIds" | "resumeAgentFromPersistence"
> &
Partial<Pick<AgentManager, "waitForAgentClose">>;
@@ -39,11 +30,54 @@ export interface EnsureAgentLoadedDeps {
agentManager: AgentLoaderManager;
agentStorage: AgentStorage;
validProviders?: Iterable<AgentProvider>;
broadcastTimeline?: boolean;
purpose?: "history" | "interactive";
logger: Logger;
}
/** The agent's history to read: from its runtime, or stored, with no provider session opened. */
export type AgentHistorySource =
| { kind: "live"; agent: ManagedAgent }
| { kind: "stored"; record: StoredAgentRecord };
export interface LoadAgentHistoryDeps extends EnsureAgentLoadedDeps {
agentManager: AgentLoaderManager & Pick<AgentManager, "hasStoredHistory">;
}
/**
* Resolves an agent for reading its history. A loaded agent is read live. A stored agent whose
* history the timeline holds is read without resuming its provider, so it reads even when its
* provider is no longer available. Otherwise the provider is resumed, which imports its history
* and requires the provider.
*/
export async function loadAgentHistory(
agentId: string,
deps: LoadAgentHistoryDeps,
): Promise<AgentHistorySource> {
const live = deps.agentManager.getAgent(agentId);
if (live) return { kind: "live", agent: live };
const record = await deps.agentStorage.get(agentId);
if (!record) throw new AgentNotFoundError(agentId);
if (await deps.agentManager.hasStoredHistory(agentId)) {
return { kind: "stored", record };
}
return { kind: "live", agent: await ensureAgentLoaded(agentId, deps) };
}
async function requireLoadableRecord(
agentId: string,
deps: EnsureAgentLoadedDeps,
): Promise<StoredAgentRecord> {
const record = await deps.agentStorage.get(agentId);
if (!record) {
throw new AgentNotFoundError(agentId);
}
const validProviders = deps.validProviders ?? deps.agentManager.getRegisteredProviderIds();
if (!isStoredAgentProviderAvailable(record, validProviders)) {
throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`);
}
return record;
}
export async function ensureUnarchivedAgentLoaded(
agentId: string,
deps: EnsureAgentLoadedDeps & {
@@ -75,8 +109,7 @@ export async function ensureAgentLoaded(
const inflight = pendingAgentInitializations.get(agentId);
if (inflight) {
inflight.options.broadcastTimeline ||= deps.broadcastTimeline === true;
return inflight.promise;
return inflight;
}
const existing = deps.agentManager.getAgent(agentId);
@@ -91,24 +124,12 @@ export async function ensureAgentLoaded(
const laterInflight = pendingAgentInitializations.get(agentId);
if (laterInflight) {
laterInflight.options.broadcastTimeline ||= deps.broadcastTimeline === true;
return laterInflight.promise;
return laterInflight;
}
const pendingOptions = {
broadcastTimeline: deps.broadcastTimeline === true,
};
const initPromise = (async () => {
const record = await deps.agentStorage.get(agentId);
if (!record) {
throw new AgentNotFoundError(agentId);
}
const record = await requireLoadableRecord(agentId, deps);
const validProviders = deps.validProviders ?? deps.agentManager.getRegisteredProviderIds();
if (!isStoredAgentProviderAvailable(record, validProviders)) {
throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`);
}
const handle = toAgentPersistenceHandle(validProviders, record.persistence);
let snapshot: ManagedAgent;
@@ -130,20 +151,16 @@ export async function ensureAgentLoaded(
deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config");
}
await deps.agentManager.hydrateTimelineFromProvider(agentId, {
broadcast: () => pendingOptions.broadcastTimeline,
});
return deps.agentManager.getAgent(agentId) ?? snapshot;
})();
const pending: PendingAgentInitialization = { promise: initPromise, options: pendingOptions };
pendingAgentInitializations.set(agentId, pending);
pendingAgentInitializations.set(agentId, initPromise);
try {
return await initPromise;
} finally {
const current = pendingAgentInitializations.get(agentId);
if (current === pending) {
if (current === initPromise) {
pendingAgentInitializations.delete(agentId);
}
}
@@ -3,12 +3,16 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import pino, { type Logger } from "pino";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { AgentManager, type AgentManagerEvent } from "./agent-manager.js";
import { AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS } from "./agent-stream-coalescer.js";
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
import type { AgentTimelineRow } from "./agent-timeline-store-types.js";
import { projectTimelineRows } from "./timeline-projection.js";
import { AgentTimeline } from "./timeline/index.js";
import { DeferredTimelineBackend } from "./timeline/testing.js";
import type {
AgentCapabilityFlags,
AgentClient,
@@ -178,6 +182,7 @@ class TestAgentSession implements AgentSession {
class TestAgentClient implements AgentClient {
readonly capabilities = TEST_CAPABILITIES;
private sessionCounter = 0;
private history: AgentStreamEvent[] = [];
readonly sessions = new Map<string, TestAgentSession>();
constructor(readonly provider: AgentProvider = "codex") {}
@@ -191,6 +196,7 @@ class TestAgentClient implements AgentClient {
config,
`${config.provider}-session-${++this.sessionCounter}`,
);
session.setHistory(this.history);
this.sessions.set(config.cwd, session);
return session;
}
@@ -226,6 +232,11 @@ class TestAgentClient implements AgentClient {
return true;
}
/** The provider history of the sessions created from now on. */
setHistory(events: AgentStreamEvent[]): void {
this.history = [...events];
}
getSession(cwd: string): TestAgentSession {
const session = this.sessions.get(cwd);
if (!session) {
@@ -243,13 +254,18 @@ interface Harness {
cleanup: () => void;
}
function createHarness(options?: { provider?: AgentProvider }): Harness {
function createHarness(options?: {
provider?: AgentProvider;
timeline?: AgentTimeline;
logger?: Logger;
}): Harness {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stream-coalescing-"));
const client = new TestAgentClient(options?.provider ?? "codex");
const manager = new AgentManager({
clients: { [client.provider]: client },
idFactory: createIdFactory(),
logger: createTestLogger(),
logger: options?.logger ?? createTestLogger(),
timeline: options?.timeline,
});
const events: AgentManagerEvent[] = [];
manager.subscribe((event) => events.push(event), { replayState: false });
@@ -268,6 +284,20 @@ function createIdFactory(): () => string {
return () => AGENT_IDS[index++] ?? `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`;
}
/** Resumes an agent whose history Paseo does not hold, so it imports the provider's. */
async function resumeWithProviderHistory(
harness: Harness,
history: AgentStreamEvent[],
): Promise<{ agentId: string }> {
harness.client.setHistory(history);
const agent = await harness.manager.resumeAgentFromPersistence(
{ provider: harness.client.provider, sessionId: "provider-history" },
{ cwd: harness.workdir },
AGENT_IDS[0],
);
return { agentId: agent.id };
}
async function createManagedSession(
harness: Harness,
options?: { agentId?: string; provider?: AgentProvider; workdir?: string },
@@ -438,15 +468,15 @@ describe("target coalesced behavior", () => {
}
});
test("bounds tool output while hydrating provider history", async () => {
test("bounds tool output while importing provider history", async () => {
const harness = createHarness();
try {
const { agentId, session } = await createManagedSession(harness);
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = limitAgentTimelineItemContent(toolCall({ status: "completed", output }));
session.setHistory([timelineEvent(toolCall({ status: "completed", output }))]);
await harness.manager.hydrateTimelineFromProvider(agentId, { force: true });
const { agentId } = await resumeWithProviderHistory(harness, [
timelineEvent(toolCall({ status: "completed", output })),
]);
expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([
expectedItem,
@@ -1037,6 +1067,81 @@ describe("target coalesced behavior", () => {
}
});
test.each(["turn_completed", "turn_failed", "turn_canceled"] as const)(
"%s waits for the final chunk an asynchronous timeline is still committing",
async (terminalType) => {
const harness = createHarness({ timeline: new AgentTimeline(new DeferredTimelineBackend()) });
try {
const { agentId, session } = await createManagedSession(harness);
session.pushEvent(assistant("final chunk", "codex", "turn"));
session.pushEvent(terminalEvent(terminalType, "turn"));
const streamTypes = () =>
getStreamEvents(harness.events, agentId).map((event) =>
event.type === "agent_stream" ? event.event.type : event.type,
);
await expect.poll(streamTypes).toContain(terminalType);
const streamEvents = getStreamEvents(harness.events, agentId);
expect(streamEvents[0]).toMatchObject({
event: { type: "timeline", item: { type: "assistant_message", text: "final chunk" } },
seq: 1,
});
expect(streamTypes().indexOf(terminalType)).toBeGreaterThan(0);
} finally {
await harness.manager.flush();
harness.cleanup();
}
},
);
test("a final chunk the timeline fails to commit surfaces a history error after the turn", async () => {
const backend = new DeferredTimelineBackend();
const logs: Array<Record<string, unknown>> = [];
const harness = createHarness({
timeline: new AgentTimeline(backend),
logger: pino({ level: "error" }, { write: (line: string) => logs.push(JSON.parse(line)) }),
});
try {
const { agentId, session } = await createManagedSession(harness);
session.pushEvent(assistant("earlier chunk", "codex", "earlier"));
session.pushEvent(terminalEvent("turn_completed", "earlier"));
await expect.poll(() => getTimelineStreamEvents(harness.events, agentId)).toHaveLength(1);
await harness.manager.flush();
session.pushEvent({ type: "turn_started", provider: "codex", turnId: "turn" });
await expect.poll(() => harness.manager.getAgent(agentId)?.lifecycle).toBe("running");
backend.failNextAppend = true;
session.pushEvent(assistant("final chunk", "codex", "turn"));
session.pushEvent(terminalEvent("turn_completed", "turn"));
const completions = () =>
getStreamEvents(harness.events, agentId).filter(
(event) => event.type === "agent_stream" && event.event.type === "turn_completed",
);
await expect.poll(completions).toHaveLength(2);
await harness.manager.flush();
expect(harness.manager.getAgent(agentId)).toMatchObject({
lifecycle: "error",
lastError: "Conversation history could not be saved: disk full",
attention: { requiresAttention: true, attentionReason: "error" },
});
const earlier = { type: "assistant_message", text: "earlier chunk" };
expect(getTimelineStreamEvents(harness.events, agentId)).toEqual([
expect.objectContaining({ event: expect.objectContaining({ item: earlier }) }),
]);
expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([earlier]);
expect(logs).toContainEqual(
expect.objectContaining({
msg: "Failed to record timeline item",
agentId,
err: expect.objectContaining({ message: "disk full" }),
}),
);
} finally {
harness.cleanup();
}
});
test("lifecycle paths are timer-safe", async () => {
vi.useFakeTimers();
@@ -1257,14 +1362,16 @@ describe("target coalesced behavior", () => {
}
});
test("history hydration projects immediately without waiting for a streaming window", async () => {
test("history import projects immediately without waiting for a streaming window", async () => {
vi.useFakeTimers();
const harness = createHarness();
try {
const { agentId, session } = await createManagedSession(harness);
session.setHistory([assistant("a1"), assistant("a2"), reasoning("r1"), reasoning("r2")]);
await harness.manager.hydrateTimelineFromProvider(agentId, { force: true });
const { agentId } = await resumeWithProviderHistory(harness, [
assistant("a1"),
assistant("a2"),
reasoning("r1"),
reasoning("r2"),
]);
const rows = await harness.manager.getTimelineRows(agentId);
expect(getTimelineItems(rows)).toEqual([
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -96,7 +96,6 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
persistence: { ...basePersistence },
lastUsage: undefined,
lastError: lastErrorValue,
historyPrimed: true,
lastUserMessageAt: now,
attention: { requiresAttention: false },
};
@@ -74,7 +74,7 @@ export class AgentRunState {
*/
async waitForForegroundStart(agentId: string): Promise<void> {
const run = this.getPendingRun(agentId);
if (run?.start.status !== "pending") {
if (!run || run.start.status === "started") {
return;
}
await Promise.race([run.turnStartedPromise, run.settledPromise]);
@@ -608,6 +608,38 @@ export interface ImportedProviderSession {
providerSubagentEvents?: Extract<AgentStreamEvent, { type: "provider_subagent" }>[];
}
/**
* What a conversation rewind removed from the provider session. `at_target`: the target prompt and
* everything after it. `after`: the provider kept history through the prompt `retainedMessageId`
* only (null: it kept nothing), which can cut earlier than the target.
*/
export type ConversationRewindCut =
| { kind: "at_target" }
| { kind: "after"; retainedMessageId: string | null };
/**
* How a conversation rewind the provider finished ended: a cut, or `unreported` when the provider
* finished without saying what it removed. Paseo then judges it from the prompts a resumed
* session's `streamHistory` replays.
*/
export type ConversationRewindResult = ConversationRewindCut | { kind: "unreported" };
/** The provider conversation is known to be unchanged; the rewind can be abandoned. */
export class RewindNotAppliedError extends Error {
constructor(message: string, options?: { cause?: unknown }) {
super(message, options);
this.name = "RewindNotAppliedError";
}
/** Marks a failure that happened before the provider conversation could change. */
static from(error: unknown): RewindNotAppliedError {
if (error instanceof RewindNotAppliedError) return error;
return new RewindNotAppliedError(error instanceof Error ? error.message : String(error), {
cause: error,
});
}
}
export interface AgentSessionConfig {
provider: AgentProvider;
cwd: string;
@@ -753,9 +785,17 @@ export interface AgentSession {
* ended, so the stored options still match what runs.
*/
applyOptions?(options: ProviderOptions): Promise<Record<string, unknown> | void>;
revertConversation?(input: { messageId: string }): Promise<void>;
/**
* Rewinds the provider conversation to before the prompt `messageId`. Resolves with what the
* provider actually removed; `void` means exactly the target and everything after it. Rejects
* with `RewindNotAppliedError` only when the conversation is known to be unchanged; any other
* rejection leaves the outcome unknown, and Paseo judges it later from the prompts `streamHistory`
* replays once the session is resumed.
*/
revertConversation?(input: { messageId: string }): Promise<ConversationRewindResult | void>;
revertFiles?(input: { messageId: string }): Promise<void>;
revertBoth?(input: { messageId: string }): Promise<void>;
/** `revertConversation` plus file changes, with the same outcome contract. */
revertBoth?(input: { messageId: string }): Promise<ConversationRewindResult | void>;
/**
* Out-of-band prompt handler. When non-null, the manager runs the returned
* handler instead of allocating a turn. The handler emits stream events
@@ -124,7 +124,6 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
sessionId: overrides.sessionId ?? "session-123",
}),
persistence: overrides.persistence ?? null,
historyPrimed: overrides.historyPrimed ?? true,
lastUserMessageAt: overrides.lastUserMessageAt ?? core.now,
lastUsage: overrides.lastUsage,
lastError: overrides.lastError,
@@ -1,4 +1,4 @@
import type { ProjectedTimelineRow } from "./timeline-projection.js";
import type { ProjectedTimelineRow, TimelineRetirement } from "./timeline-projection.js";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
export interface AgentTimelineRow {
@@ -12,6 +12,11 @@ export interface AgentTimelineRow {
export interface AgentTimelineCursor {
epoch: string;
seq: number;
/**
* For a reader that cannot apply retirements: the retirement count its view reflects. The
* cursor is stale once another retirement has happened.
*/
retirementCount?: number;
}
export type AgentTimelineFetchDirection = "tail" | "before" | "after";
@@ -46,23 +51,8 @@ export interface AgentTimelineFetchResult {
startSeq: number | null;
endSeq: number | null;
rows: ProjectedTimelineRow[];
}
export interface AgentTimelineStore {
appendCommitted(
agentId: string,
item: AgentTimelineItem,
options?: { timestamp?: string; turnId?: string },
): Promise<AgentTimelineRow>;
fetchCommitted(
agentId: string,
options?: AgentTimelineFetchOptions,
): Promise<AgentTimelineFetchResult>;
getLatestCommittedSeq(agentId: string): Promise<number>;
getCommittedRows(agentId: string): Promise<AgentTimelineRow[]>;
getLastItem(agentId: string): Promise<AgentTimelineItem | null>;
getLastAssistantMessage(agentId: string): Promise<string | null>;
deleteAgent(agentId: string): Promise<void>;
bulkInsert(agentId: string, rows: readonly AgentTimelineRow[]): Promise<void>;
updateCommittedRow(agentId: string, row: AgentTimelineRow): Promise<void>;
/** Retirements whose seq lies in the page's span. A client applies them before the rows. */
retirements: TimelineRetirement[];
/** Retirements of this history so far. */
retirementCount: number;
}
@@ -1,443 +0,0 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { selectReferenceProjectedTimelinePage } from "./test-utils/timeline-page-reference.js";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
describe("InMemoryAgentTimelineStore", () => {
it("resets stale forward cursors to complete available turns", () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("agent", { epoch: "current" });
for (let index = 0; index < 60; index++)
store.append(
"agent",
{
type: "tool_call",
callId: `call-${index}`,
name: "shell",
status: "completed",
error: null,
detail: { type: "unknown", input: null, output: null },
},
{ turnId: "run" },
);
const page = store.fetch("agent", {
direction: "after",
cursor: { epoch: "old", seq: 10 },
limit: 40,
wholeTurns: true,
});
expect(page.rows).toHaveLength(60);
expect(page).toMatchObject({
reset: true,
staleCursor: true,
startSeq: 1,
endSeq: 60,
hasOlder: false,
hasNewer: false,
});
});
it("clamps an overshooting before cursor into the bounded tail window", () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("agent-1", {
epoch: "epoch-1",
nextSeq: 8,
rows: [
{
seq: 5,
timestamp: "2026-01-01T00:00:00.000Z",
item: { type: "assistant_message", text: "five", messageId: "five" },
},
{
seq: 6,
timestamp: "2026-01-01T00:00:01.000Z",
item: { type: "assistant_message", text: "six", messageId: "six" },
},
{
seq: 7,
timestamp: "2026-01-01T00:00:02.000Z",
item: { type: "assistant_message", text: "seven", messageId: "seven" },
},
],
});
const result = store.fetch("agent-1", {
direction: "before",
cursor: { epoch: "epoch-1", seq: 100 },
limit: 2,
});
expect(result).toMatchObject({
epoch: "epoch-1",
direction: "before",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 5, maxSeq: 7, nextSeq: 8 },
hasOlder: true,
hasNewer: false,
rows: [
{
seq: 6,
timestamp: "2026-01-01T00:00:01.000Z",
item: { type: "assistant_message", text: "six", messageId: "six" },
},
{
seq: 7,
timestamp: "2026-01-01T00:00:02.000Z",
item: { type: "assistant_message", text: "seven", messageId: "seven" },
},
],
});
});
it("returns a bounded reset window when an after cursor is behind retained history", () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("agent-1", {
epoch: "epoch-1",
nextSeq: 8,
rows: [
{
seq: 5,
timestamp: "2026-01-01T00:00:00.000Z",
item: { type: "assistant_message", text: "five", messageId: "five" },
},
{
seq: 6,
timestamp: "2026-01-01T00:00:01.000Z",
item: { type: "assistant_message", text: "six", messageId: "six" },
},
{
seq: 7,
timestamp: "2026-01-01T00:00:02.000Z",
item: { type: "assistant_message", text: "seven", messageId: "seven" },
},
],
});
const result = store.fetch("agent-1", {
direction: "after",
cursor: { epoch: "epoch-1", seq: 1 },
limit: 1,
});
expect(result).toMatchObject({
epoch: "epoch-1",
direction: "after",
reset: true,
staleCursor: false,
gap: true,
window: { minSeq: 5, maxSeq: 7, nextSeq: 8 },
hasOlder: true,
hasNewer: false,
rows: [
{
seq: 7,
timestamp: "2026-01-01T00:00:02.000Z",
item: { type: "assistant_message", text: "seven", messageId: "seven" },
},
],
});
});
});
describe("projected timeline retention", () => {
it("retains one full tool state while streaming every source update", () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("agent");
for (let seq = 1; seq <= 2000; seq++) {
const item = {
type: "tool_call" as const,
callId: "child",
name: "task",
status: "running" as const,
error: null,
detail: { type: "plain_text" as const, label: "Child", text: "x".repeat(seq * 128) },
};
expect(store.append("agent", item)).toMatchObject({ seq, item });
}
const result = store.fetch("agent", { limit: 0 });
expect(result.rows).toHaveLength(1);
expect(JSON.stringify(result.rows).length).toBeLessThan(260_000);
expect(result.window.maxSeq).toBe(2000);
});
it("catches up a mid-message cursor with the complete projected message", () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("agent", { epoch: "e" });
store.append("agent", { type: "assistant_message", messageId: "m", text: "A" });
store.append("agent", { type: "assistant_message", messageId: "m", text: "B" });
expect(
store.fetch("agent", { direction: "after", cursor: { epoch: "e", seq: 1 } }).rows,
).toMatchObject([
{
item: { text: "AB" },
seqStart: 1,
seqEnd: 2,
sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }],
},
]);
});
it("serves a merged assistant message within the item budget", () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("agent", { epoch: "e" });
const chunk = "a".repeat(200 * 1024);
for (let index = 0; index < 10; index++) {
store.append("agent", { type: "assistant_message", messageId: "m", text: chunk });
}
const [row] = store.fetch("agent", { limit: 1 }).rows;
expect(row).toMatchObject({ seqStart: 1, seqEnd: 10 });
expect(row.item).toMatchObject({ type: "assistant_message", messageId: "m" });
expect(Buffer.byteLength(JSON.stringify(row.item))).toBeLessThanOrEqual(256 * 1024);
expect(store.getLastAssistantMessage("agent")).toHaveLength(10 * chunk.length);
});
});
describe("projected sequence ownership", () => {
it("preserves fetched coverage when another source chunk arrives", () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("a");
store.append("a", { type: "assistant_message", text: "A" });
const before = store.fetch("a");
store.append("a", { type: "assistant_message", text: "B" });
expect(before.rows[0]).toMatchObject({
item: { text: "A" },
seqEnd: 1,
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
});
expect(store.fetch("a").rows[0]).toMatchObject({
item: { text: "AB" },
seqEnd: 2,
sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }],
});
});
it("preserves source positions when seeding a projected history whose last update is an earlier tool", () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("a");
const tool = {
type: "tool_call" as const,
callId: "t",
name: "shell",
error: null,
detail: { type: "plain_text" as const, label: "work" },
};
store.append("a", { ...tool, status: "running" });
store.append("a", { type: "assistant_message", text: "Answer" });
store.append("a", { ...tool, status: "completed" });
store.initialize("b", { rows: store.getRows("a") });
expect(store.getLastItem("b")).toMatchObject({ type: "tool_call", status: "completed" });
expect(store.getLastAssistantMessage("b")).toBe("Answer");
expect(store.append("b", { type: "user_message", text: "next" }).seq).toBe(4);
expect(store.getLastItem("b")).toEqual({ type: "user_message", text: "next" });
expect(store.fetch("b").rows.map((row) => row.seqStart)).toEqual([1, 2, 4]);
});
it("returns the last projected assistant message without joining different messages", () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("a");
store.append("a", { type: "assistant_message", messageId: "first", text: "First" });
store.append("a", { type: "assistant_message", messageId: "second", text: "Sec" });
store.append("a", { type: "assistant_message", messageId: "second", text: "ond" });
expect(store.getLastAssistantMessage("a")).toBe("Second");
});
});
it("includes transitive tool updates in a contiguous projected tail", () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("a");
const tool = (callId: string, status: "running" | "completed") => ({
type: "tool_call" as const,
callId,
name: "shell",
status,
error: null,
detail: { type: "plain_text" as const, label: "work" },
});
store.append("a", tool("first", "running"));
store.append("a", tool("second", "running"));
store.append("a", { type: "user_message", text: "Continue" });
store.append("a", tool("first", "completed"));
store.append("a", { type: "assistant_message", text: "Answer" });
store.append("a", tool("second", "completed"));
const tail = store.fetch("a", { limit: 1 });
expect(tail.rows.map((row) => row.seqStart)).toEqual([1, 2, 3, 5]);
expect(tail).toMatchObject({ startSeq: 1, endSeq: 6, hasOlder: false });
});
async function measureTimelineTail(wholeTurns: boolean) {
const fixture = fileURLToPath(
new URL("./test-utils/timeline-responsiveness.ts", import.meta.url),
);
const { stdout, stderr } = await promisify(execFile)(
process.execPath,
["--expose-gc", "--import", "tsx", fixture, String(wholeTurns)],
{ timeout: 15_000 },
);
process.stderr.write(stderr);
return JSON.parse(stdout) as {
result: ReturnType<InMemoryAgentTimelineStore["fetch"]>;
blockingMs: number;
};
}
it("excludes unrelated test-runner CPU from the timeline fetch budget", async () => {
let unrelatedWork: ReturnType<typeof setImmediate>;
const burnCpu = () => {
const threadCpuUsage: (previous?: NodeJS.CpuUsage) => NodeJS.CpuUsage = Reflect.get(
process,
"threadCpuUsage",
);
const start = threadCpuUsage();
let elapsed: NodeJS.CpuUsage;
do {
elapsed = threadCpuUsage(start);
} while (elapsed.user + elapsed.system < 80_000);
unrelatedWork = setImmediate(burnCpu);
};
unrelatedWork = setImmediate(burnCpu);
try {
const { result: tail, blockingMs } = await measureTimelineTail(false);
expect(tail.rows).toHaveLength(200);
expect(tail.startSeq).toBe(99_801);
expect(tail.endSeq).toBe(100_000);
expect(blockingMs).toBeLessThan(50);
} finally {
clearImmediate(unrelatedWork);
}
});
it.each([false, true])(
"serves tail 200 from 100,000 retained rows without stalling the loop (wholeTurns=%s)",
async (wholeTurns) => {
const { result: tail, blockingMs } = await measureTimelineTail(wholeTurns);
expect(tail.rows).toHaveLength(200);
expect(tail.startSeq).toBe(99_801);
expect(tail.endSeq).toBe(100_000);
expect(blockingMs).toBeLessThan(50);
},
);
it("matches the previous reprojecting selector on seeded randomized timelines", () => {
let seed = 0x76aabbcc;
const random = (bound: number) => {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
return Math.floor((seed / 0x1_0000_0000) * bound);
};
for (let scenario = 0; scenario < 100; scenario++) {
const store = new InMemoryAgentTimelineStore();
store.initialize("a", { epoch: "e" });
for (let seq = 1; seq <= 100; seq++) {
const identity = String(random(8));
const text = String(seq);
const items: AgentTimelineItem[] = [
{ type: "user_message", text },
{ type: "assistant_message", text },
{ type: "assistant_message", text, messageId: identity },
{ type: "reasoning", text },
{
type: "tool_call",
callId: identity,
name: "shell",
status: "running",
error: null,
detail: { type: "plain_text", label: text },
},
{
type: "plugin",
id: identity,
pluginId: "test",
kind: "state",
version: 1,
data: { text },
},
];
store.append("a", items[random(items.length)], { turnId: String(random(3)) });
}
// Include rehydration of already-projected rows, with non-monotonic seqEnd.
store.initialize("b", { rows: store.getRows("a"), epoch: "e" });
for (const agentId of ["a", "b"]) {
assertSelectionMatchesReference(store, agentId);
store.append(agentId, { type: "assistant_message", text: "later" });
assertSelectionMatchesReference(store, agentId);
}
}
});
function assertSelectionMatchesReference(store: InMemoryAgentTimelineStore, agentId: string): void {
for (const direction of ["tail", "before", "after"] as const) {
for (const limit of [0, 1, 7, 200]) {
for (const cursorSeq of [0, 1, 25, 50, 99, 100, 150]) {
const actual = store.fetch(agentId, {
direction,
limit,
cursor: { epoch: "e", seq: cursorSeq },
});
const expected = selectReferenceProjectedTimelinePage({
rows: store.getRows(agentId),
bounds: actual.window,
direction,
limit,
cursorSeq,
});
expect({
...expected,
entries: undefined,
rows: expected.entries.map((entry) => Object.assign({ seq: entry.seqEnd }, entry)),
}).toEqual({
entries: undefined,
rows: actual.rows,
startSeq: actual.startSeq,
endSeq: actual.endSeq,
hasOlder: actual.hasOlder,
hasNewer: actual.hasNewer,
});
}
}
}
}
it("continues a scroll across appends and lifecycle collapse without losing source coverage", () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("a", { epoch: "e" });
const tool: AgentTimelineItem = {
type: "tool_call",
callId: "t",
name: "shell",
status: "running",
error: null,
detail: { type: "plain_text", label: "work" },
};
store.append("a", tool);
for (let seq = 2; seq <= 5; seq++) store.append("a", { type: "user_message", text: String(seq) });
const first = store.fetch("a", { direction: "after", cursor: { epoch: "e", seq: 0 }, limit: 2 });
expect(first.endSeq).toBe(2);
store.append("a", { ...tool, status: "completed" });
store.append("a", { type: "assistant_message", text: "Done" });
expect(first.rows[0].item).toMatchObject({ status: "running" });
const second = store.fetch("a", {
direction: "after",
cursor: { epoch: "e", seq: first.endSeq! },
limit: 2,
});
expect(second.rows.map((row) => row.seqStart)).toEqual([3, 4]);
expect(second.endSeq).toBe(4);
const third = store.fetch("a", {
direction: "after",
cursor: { epoch: "e", seq: second.endSeq! },
limit: 2,
});
expect(third.rows.map((row) => row.seqStart)).toEqual([1, 5]);
expect(third.rows[0].item).toMatchObject({ status: "completed" });
expect(third.endSeq).toBe(6);
const last = store.fetch("a", {
direction: "after",
cursor: { epoch: "e", seq: third.endSeq! },
limit: 2,
});
expect(last.rows.map((row) => row.item)).toEqual([{ type: "assistant_message", text: "Done" }]);
expect(last.endSeq).toBe(7);
expect(last.hasNewer).toBe(false);
});
@@ -1,190 +0,0 @@
import { randomUUID } from "node:crypto";
import { TimelineProjection, type ProjectedTimelineRow } from "./timeline-projection.js";
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type {
AgentTimelineFetchOptions,
AgentTimelineFetchResult,
AgentTimelineRow,
} from "./agent-timeline-store-types.js";
export interface SeedAgentTimelineOptions {
items?: readonly AgentTimelineItem[];
rows?: readonly AgentTimelineRow[];
epoch?: string;
nextSeq?: number;
timestamp?: string;
}
interface AgentTimelineState {
epoch: string;
projection: TimelineProjection;
minSeq: number;
nextSeq: number;
}
const DEFAULT_TIMELINE_FETCH_LIMIT = 200;
function cloneRow<T extends AgentTimelineRow>(row: T): T {
return { ...row };
}
export class InMemoryAgentTimelineStore {
private readonly states = new Map<string, AgentTimelineState>();
has(agentId: string): boolean {
return this.states.has(agentId);
}
initialize(agentId: string, options?: SeedAgentTimelineOptions): void {
const timestamp = options?.timestamp ?? new Date().toISOString();
const rows = options?.rows?.length
? options.rows.map(cloneRow)
: this.buildRowsFromItems(options?.items ?? [], options?.nextSeq ?? 1, timestamp);
const nextSeq = rows.reduce((next, row) => Math.max(next, row.seq + 1), options?.nextSeq ?? 1);
const projection = new TimelineProjection(rows);
this.states.set(agentId, {
epoch: options?.epoch ?? randomUUID(),
projection,
minSeq: projection.getRows()[0]?.seqStart ?? 0,
nextSeq,
});
}
delete(agentId: string): void {
this.states.delete(agentId);
}
getItems(agentId: string): AgentTimelineItem[] {
return this.requireState(agentId)
.projection.getRows()
.map((row) => row.item);
}
getRows(agentId: string): ProjectedTimelineRow[] {
return this.requireState(agentId).projection.getRows().map(cloneRow);
}
getSubmittedUserMessage(agentId: string, clientMessageId: string): AgentTimelineRow | null {
const row = this.requireState(agentId)
.projection.getRows()
.find(
(candidate) =>
candidate.item.type === "user_message" &&
candidate.item.clientMessageId === clientMessageId,
);
return row ? cloneRow(row) : null;
}
enrichSubmittedUserMessage(
agentId: string,
clientMessageId: string,
providerMessageId: string,
): AgentTimelineRow | null {
return this.requireState(agentId).projection.enrichSubmittedUserMessage(
clientMessageId,
providerMessageId,
);
}
getEpoch(agentId: string): string {
return this.requireState(agentId).epoch;
}
getHead(agentId: string): { epoch: string; seq: number } {
const state = this.requireState(agentId);
return { epoch: state.epoch, seq: state.nextSeq - 1 };
}
fetch(agentId: string, options?: AgentTimelineFetchOptions): AgentTimelineFetchResult {
const state = this.requireState(agentId);
const direction = options?.direction ?? "tail";
const cursor = options?.cursor;
const hasRows = state.minSeq !== 0;
const window = { minSeq: state.minSeq, maxSeq: state.nextSeq - 1, nextSeq: state.nextSeq };
const staleCursor = cursor !== undefined && cursor.epoch !== state.epoch;
const gap =
!staleCursor &&
direction === "after" &&
cursor !== undefined &&
hasRows &&
cursor.seq < state.minSeq - 1;
const reset = staleCursor || gap;
const page = state.projection.selectPage({
bounds: window,
direction: reset ? "tail" : direction,
cursorSeq: cursor?.seq,
limit: options?.limit ?? DEFAULT_TIMELINE_FETCH_LIMIT,
wholeTurns: options?.wholeTurns,
});
return {
epoch: state.epoch,
direction,
reset,
staleCursor,
gap,
window,
hasOlder: page.hasOlder,
hasNewer: page.hasNewer,
startSeq: page.startSeq,
endSeq: page.endSeq,
// Stored rows are already within budget; this bounds assistant and reasoning merges.
rows: page.entries.map((entry) =>
Object.assign({ seq: entry.seqEnd }, entry, {
item: limitAgentTimelineItemContent(entry.item),
}),
),
};
}
append(
agentId: string,
item: AgentTimelineItem,
options?: { timestamp?: string; providerMessageId?: string; turnId?: string },
): AgentTimelineRow {
const state = this.requireState(agentId);
const row: AgentTimelineRow = {
seq: state.nextSeq,
timestamp: options?.timestamp ?? new Date().toISOString(),
item,
...(options?.turnId ? { turnId: options.turnId } : {}),
...(options?.providerMessageId ? { providerMessageId: options.providerMessageId } : {}),
};
state.nextSeq += 1;
if (state.minSeq === 0) state.minSeq = row.seq;
state.projection.append(row);
return cloneRow(row);
}
getLastItem(agentId: string): AgentTimelineItem | null {
const state = this.requireState(agentId);
return state.projection.getLastItem(state.nextSeq - 1);
}
getLastAssistantMessage(agentId: string): string | null {
return this.requireState(agentId).projection.getLastAssistantMessage();
}
private requireState(agentId: string): AgentTimelineState {
const state = this.states.get(agentId);
if (!state) {
throw new Error(`Unknown agent '${agentId}'`);
}
return state;
}
private buildRowsFromItems(
items: readonly AgentTimelineItem[],
startSeq: number,
timestamp: string,
): AgentTimelineRow[] {
let nextSeq = startSeq;
return items.map((item) => {
const row: AgentTimelineRow = {
seq: nextSeq,
timestamp,
item,
};
nextSeq += 1;
return row;
});
}
}
@@ -100,7 +100,6 @@ function makeManagedAgent(args: {
...(args.nativeHandle ? { nativeHandle: args.nativeHandle } : {}),
metadata: { provider, cwd: args.cwd },
},
historyPrimed: true,
lastUserMessageAt: null,
attention: { requiresAttention: false },
foregroundTurnWaiters: new Set(),
@@ -678,8 +677,7 @@ class ProviderImportHarness {
this.activeAgent = this.snapshot;
return this.snapshot;
},
hydrateTimelineFromProvider: async () => {},
getTimeline: () => this.timeline,
getTimeline: async () => this.timeline,
closeAgent: async (agentId: string) => {
this.closedAgentIds.push(agentId);
this.activeAgent = null;
@@ -239,7 +239,7 @@ async function importProviderSessionNow(
});
return {
snapshot,
timelineSize: input.agentManager.getTimeline(snapshot.id).length,
timelineSize: (await input.agentManager.getTimeline(snapshot.id)).length,
};
} catch (error) {
await rollbackArchivedImport(input, archivedRecord, archivedRecord.archivedAt);
@@ -258,7 +258,7 @@ async function importProviderSessionNow(
return {
snapshot,
timelineSize: input.agentManager.getTimeline(snapshot.id).length,
timelineSize: (await input.agentManager.getTimeline(snapshot.id)).length,
};
}
@@ -567,12 +567,12 @@ describe("Suite A: Core Fixes", () => {
const notifications = await waitFor({
timeoutMs: 10000,
label: `${outcome} notification`,
check: () => {
const items = daemonHandle.daemon.agentManager
.getTimeline(parentAgentId)
.filter(
(item) => item.type === "tool_call" && item.agentMessage?.sender.id === childId,
);
check: async () => {
const items = (
await daemonHandle.daemon.agentManager.getTimeline(parentAgentId)
).filter(
(item) => item.type === "tool_call" && item.agentMessage?.sender.id === childId,
);
return items.length > 0 ? items : null;
},
});
@@ -267,7 +267,7 @@ function buildAgentManagerSpies() {
listAgents: vi.fn().mockReturnValue([]),
getTimeline: vi.fn().mockReturnValue([]),
resumeAgentFromPersistence: vi.fn(),
hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined),
hasStoredHistory: vi.fn().mockResolvedValue(false),
appendTimelineItem: vi.fn().mockResolvedValue(undefined),
emitLiveTimelineItem: vi.fn().mockResolvedValue(undefined),
getPluginTools: () => [],
@@ -6691,12 +6691,13 @@ describe("agent snapshot MCP serialization", () => {
id: "archived-activity-agent",
currentModeId: "default",
} as ManagedAgent;
spies.agentManager.getAgent
.mockReturnValueOnce(null)
.mockReturnValue(snapshot)
.mockReturnValue(snapshot);
let resumed = false;
spies.agentManager.getAgent.mockImplementation(() => (resumed ? snapshot : null));
spies.agentStorage.get.mockResolvedValue(record);
spies.agentManager.resumeAgentFromPersistence.mockResolvedValue(snapshot);
spies.agentManager.resumeAgentFromPersistence.mockImplementation(async () => {
resumed = true;
return snapshot;
});
spies.agentManager.getTimeline.mockReturnValue([
{
kind: "status",
@@ -6722,10 +6723,35 @@ describe("agent snapshot MCP serialization", () => {
}),
);
expect(spies.agentManager.resumeAgentFromPersistence).toHaveBeenCalled();
expect(spies.agentManager.hydrateTimelineFromProvider).toHaveBeenCalledWith(
"archived-activity-agent",
{ broadcast: expect.any(Function) },
});
it("reads get_agent_activity from stored history without resuming the provider", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const record = createStoredRecord({ id: "stored-activity-agent", lastModeId: "plan" });
spies.agentManager.getAgent.mockReturnValue(null);
spies.agentStorage.get.mockResolvedValue(record);
spies.agentManager.hasStoredHistory.mockResolvedValue(true);
spies.agentManager.getTimeline.mockReturnValue([
{ type: "assistant_message", text: "Stored reply" },
]);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
logger,
providerSnapshotManager: createClaudeOnlyManager(),
});
const tool = registeredTool(server, "get_agent_activity");
const response = await tool.handler({ agentId: "stored-activity-agent" });
expect(response.structuredContent).toEqual(
expect.objectContaining({
agentId: "stored-activity-agent",
updateCount: 1,
currentModeId: "plan",
}),
);
expect(spies.agentManager.resumeAgentFromPersistence).not.toHaveBeenCalled();
});
it("get_agent_activity limit counts projected messages, not raw deltas", async () => {
@@ -142,7 +142,7 @@ export async function waitForAgentWithTimeout(
} catch (error) {
if (error instanceof Error && error.message === "wait timeout") {
const snapshot = agentManager.getAgent(agentId);
const timeline = agentManager.getTimeline(agentId);
const timeline = await agentManager.getTimeline(agentId);
const recent = selectItemsByProjectedLimit({
items: timeline,
direction: "tail",
@@ -16,7 +16,12 @@ import type {
import { setImmediate as nextTurn } from "node:timers/promises";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import type { AgentClient, AgentStreamEvent } from "./agent-sdk-types.js";
import { RewindOutcomeUnknownError } from "./rewind/rewind.js";
import {
RewindNotAppliedError,
type AgentClient,
type AgentStreamEvent,
} from "./agent-sdk-types.js";
import { toStoredAgentRecord } from "./agent-projections.js";
import { AgentManager } from "./agent-manager.js";
import { buildProviderRegistry } from "./provider-registry.js";
@@ -27,7 +32,7 @@ import {
StaleProviderSessionError,
} from "./stale-provider-session-error.js";
test("long concurrent plugin and child streams retain text-sized history and release it on archive", () => {
test("long concurrent plugin and child streams retain text-sized history through archive and release it on delete", () => {
const fixture = fileURLToPath(new URL("./test-utils/plugin-stream-memory.ts", import.meta.url));
const output = execFileSync(
process.execPath,
@@ -37,8 +42,12 @@ test("long concurrent plugin and child streams retain text-sized history and rel
timeout: 20_000,
},
);
const memory = JSON.parse(output) as { retained: number; closed: number };
const memory = JSON.parse(output) as { retained: number; archived: number; closed: number };
expect(memory.retained).toBeLessThan(24 * 1024 * 1024);
// Archiving closes the runtimes and keeps the stored history; it adds nothing.
expect(memory.archived).toBeLessThanOrEqual(memory.retained);
// Deleting the agents releases their histories and their children's.
expect(memory.closed).toBeLessThan(memory.archived);
expect(memory.closed).toBeLessThan(6 * 1024 * 1024);
});
@@ -377,7 +386,7 @@ async function withRewindAgent(
manager: AgentManager;
agentId: string;
nativeMessages: string[];
messages: () => string[];
messages: () => Promise<string[]>;
}) => Promise<void>,
options: Parameters<typeof createRewindProviderHarness>[0] = {},
) {
@@ -395,17 +404,15 @@ async function withRewindAgent(
undefined,
{ workspaceId: undefined },
);
const messages = () =>
manager
.getTimeline(agent.id)
.flatMap((item) =>
item.type === "user_message" || item.type === "assistant_message" ? [item.text] : [],
);
const messages = async () =>
(await manager.getTimeline(agent.id)).flatMap((item) =>
item.type === "user_message" || item.type === "assistant_message" ? [item.text] : [],
);
try {
await manager.runAgent(agent.id, "A", { clientMessageId: "client-A" });
await manager.runAgent(agent.id, "B", { clientMessageId: "client-B" });
await manager.runAgent(agent.id, "C", { clientMessageId: "client-C" });
expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C"]);
expect(await messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C"]);
await run({ manager, agentId: agent.id, nativeMessages: harness.nativeMessages, messages });
} finally {
await manager.closeAgent(agent.id);
@@ -415,11 +422,11 @@ async function withRewindAgent(
test("conversation rewind discards withdrawn plugin turns before and after a replacement prompt", async () => {
await withRewindAgent(async ({ manager, agentId, nativeMessages, messages }) => {
await manager.rewind(agentId, "user-B", "conversation");
const afterRewind = messages();
await manager.rewind(agentId, "client-B", "conversation");
const afterRewind = await messages();
expect(nativeMessages).toEqual(["A"]);
await manager.runAgent(agentId, "D", { clientMessageId: "client-D" });
const afterReplacement = messages();
const afterReplacement = await messages();
expect(nativeMessages).toEqual(["A", "D"]);
expect({ afterRewind, afterReplacement }).toEqual({
afterRewind: ["A", "Reply A"],
@@ -430,11 +437,20 @@ test("conversation rewind discards withdrawn plugin turns before and after a rep
test("file-only plugin rewind preserves the conversation before and after a replacement prompt", async () => {
await withRewindAgent(async ({ manager, agentId, nativeMessages, messages }) => {
await manager.rewind(agentId, "user-B", "files");
await manager.rewind(agentId, "client-B", "files");
expect(nativeMessages).toEqual(["A", "B", "C"]);
expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C"]);
expect(await messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C"]);
await manager.runAgent(agentId, "D", { clientMessageId: "client-D" });
expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C", "D", "Reply D"]);
expect(await messages()).toEqual([
"A",
"Reply A",
"B",
"Reply B",
"C",
"Reply C",
"D",
"Reply D",
]);
});
});
@@ -726,18 +742,22 @@ test("failed plugin rewind replay blocks prompts until retry without repeating n
);
});
test("retrying a failed plugin rewind replay replaces the manager timeline before prompting", async () => {
test("a failed plugin rewind replay blocks prompts until a reload settles the committed rewind", async () => {
let failReplay = true;
await withRewindAgent(
async ({ manager, agentId, messages }) => {
await expect(manager.rewind(agentId, "user-B", "conversation")).rejects.toThrow(
"replay failed",
// The native revert committed, but the session could not replay what it kept.
await expect(manager.rewind(agentId, "client-B", "conversation")).rejects.toMatchObject({
name: "RewindOutcomeUnknownError",
cause: { message: "replay failed" },
});
await expect(manager.runAgent(agentId, "D")).rejects.toBeInstanceOf(
RewindOutcomeUnknownError,
);
await expect(manager.runAgent(agentId, "D")).rejects.toThrow("Retry the rewind or reload");
await manager.rewind(agentId, "user-B", "conversation");
expect(messages()).toEqual(["A", "Reply A"]);
await manager.reloadAgentSession(agentId);
expect(await messages()).toEqual(["A", "Reply A"]);
await manager.runAgent(agentId, "D");
expect(messages()).toEqual(["A", "Reply A", "D", "Reply D"]);
expect(await messages()).toEqual(["A", "Reply A", "D", "Reply D"]);
},
{
handleInput: async (input, emit) => {
@@ -759,12 +779,12 @@ test("reload after failed plugin rewind replay resumes the committed fork", asyn
let failReplay = true;
await withRewindAgent(
async ({ manager, agentId, messages }) => {
await expect(manager.rewind(agentId, "user-B", "conversation")).rejects.toThrow(
"replay failed",
);
await expect(manager.rewind(agentId, "client-B", "conversation")).rejects.toMatchObject({
name: "RewindOutcomeUnknownError",
cause: { message: "replay failed" },
});
await manager.reloadAgentSession(agentId, undefined, { rehydrateFromDisk: true });
await manager.hydrateTimelineFromProvider(agentId, { force: true });
expect(messages()).toEqual(["A", "Reply A"]);
expect(await messages()).toEqual(["A", "Reply A"]);
},
{
forkOnRevert: true,
@@ -1662,7 +1682,7 @@ describe("PluginAgentClientRegistry", () => {
state: "completed",
});
await nextTurn();
const [child] = manager.listProviderSubagents(agent.id);
const [child] = await manager.listProviderSubagents(agent.id);
expect(child).toMatchObject({
id: "reusable-child",
title: "Plugin child",
@@ -1676,7 +1696,7 @@ describe("PluginAgentClientRegistry", () => {
state: "started",
});
await nextTurn();
expect(manager.listProviderSubagents(agent.id)).toMatchObject([
expect(await manager.listProviderSubagents(agent.id)).toMatchObject([
{ id: child.id, title: child.title, status: "running" },
]);
@@ -1687,7 +1707,7 @@ describe("PluginAgentClientRegistry", () => {
state: outcome,
});
await nextTurn();
expect(manager.listProviderSubagents(agent.id)).toMatchObject([
expect(await manager.listProviderSubagents(agent.id)).toMatchObject([
{ id: child.id, title: child.title, status: outcome },
]);
} finally {
@@ -2100,6 +2120,80 @@ describe("PluginAgentClientRegistry", () => {
});
});
describe("plugin conversation rewind", () => {
async function openRevertableSession(reply: "request.completed" | "request.failed") {
const harness = createProviderHarness({
capabilities: [...CAPABILITIES, "session.revert.conversation"],
openChildren: (sessionId, emit) => {
emit({
type: "timeline.item",
sessionId,
item: { type: "user_message", id: "prompt-b", text: "B", revertToken: "before-b" },
});
},
handleInput: async (input, emit) => {
if (input.type !== "session.revert") return false;
emit(
reply === "request.completed"
? { type: "request.completed", requestId: input.requestId }
: { type: "request.failed", requestId: input.requestId, error: { message: "Busy" } },
);
return true;
},
});
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const session = await registry.clients()[harness.registration.id]!.createSession({
provider: harness.registration.id,
cwd: "/workspace",
});
return { harness, registry, session };
}
// The plugin protocol completes a revert without saying which messages it removed, so the
// resumed session's replay settles it.
test("a completed revert is unreported", async () => {
const { harness, registry, session } = await openRevertableSession("request.completed");
try {
await expect(session.revertConversation!({ messageId: "prompt-b" })).resolves.toEqual({
kind: "unreported",
});
expect(harness.inputs.filter((input) => input.type === "session.revert")).toEqual([
expect.objectContaining({ token: "before-b", scope: "conversation" }),
]);
} finally {
await session.close();
registry.replace([]);
}
});
// A failed request may still have removed messages.
test("a failed revert leaves the outcome unknown", async () => {
const { registry, session } = await openRevertableSession("request.failed");
try {
const rewind = session.revertConversation!({ messageId: "prompt-b" });
await expect(rewind).rejects.toThrow("Busy");
await expect(rewind).rejects.not.toBeInstanceOf(RewindNotAppliedError);
} finally {
await session.close();
registry.replace([]);
}
});
test("a message without a revert token is not sent to the plugin", async () => {
const { harness, registry, session } = await openRevertableSession("request.completed");
try {
await expect(session.revertConversation!({ messageId: "unknown" })).rejects.toBeInstanceOf(
RewindNotAppliedError,
);
expect(harness.inputs.filter((input) => input.type === "session.revert")).toEqual([]);
} finally {
await session.close();
registry.replace([]);
}
});
});
type RequestKind = "session.open" | "catalog" | "session.configure" | "session.prompt";
async function requestFromClient(client: AgentClient, kind: RequestKind): Promise<unknown> {
@@ -57,7 +57,9 @@ import type {
ProviderRefreshContext,
SteerActiveTurnOptions,
SteerResult,
ConversationRewindResult,
} from "./agent-sdk-types.js";
import { RewindNotAppliedError } from "./agent-sdk-types.js";
import {
resolveProviderLaunch,
checkProviderLaunchAvailable,
@@ -1455,16 +1457,21 @@ class PluginAgentSession implements AgentSession {
return ended;
}
async revertConversation(input: { messageId: string }): Promise<void> {
// A plugin completes `session.revert` without saying which messages it removed, and a failed
// request may have removed some, so neither is a cut. Paseo settles a completed one from the
// history the plugin replays when the session is resumed.
async revertConversation(input: { messageId: string }): Promise<ConversationRewindResult> {
await this.revert(input.messageId, "conversation");
return { kind: "unreported" };
}
async revertFiles(input: { messageId: string }): Promise<void> {
await this.revert(input.messageId, "files");
}
async revertBoth(input: { messageId: string }): Promise<void> {
async revertBoth(input: { messageId: string }): Promise<ConversationRewindResult> {
await this.revert(input.messageId, "both");
return { kind: "unreported" };
}
timelineHistory(): ImportedProviderSession["timeline"] {
@@ -1869,7 +1876,9 @@ class PluginAgentSession implements AgentSession {
throw new Error("Plugin conversation rewind requires session persistence for history replay");
}
const token = this.revertTokens.get(messageId);
if (token === undefined) throw new Error(`No provider revert token for message ${messageId}`);
if (token === undefined) {
throw new RewindNotAppliedError(`No provider revert token for message ${messageId}`);
}
await this.bridge.revert({
type: "session.revert",
requestId: randomUUID(),
@@ -60,13 +60,19 @@ test("projects Codex child history and confines old-client degradation to the ch
);
}
await expect
.poll(() => ctx.daemon.daemon.agentManager.listProviderSubagents(agent.id).length)
.poll(
async () => (await ctx.daemon.daemon.agentManager.listProviderSubagents(agent.id)).length,
)
.toBe(1);
await expect
.poll(
() =>
ctx.daemon.daemon.agentManager.fetchProviderSubagentTimeline(agent.id, "child-thread")
.window.maxSeq,
async () =>
(
await ctx.daemon.daemon.agentManager.fetchProviderSubagentTimeline(
agent.id,
"child-thread",
)
).window.maxSeq,
)
.toBe(3);
const child = await ctx.client.fetchProviderSubagentTimeline(agent.id, "child-thread", {
@@ -170,9 +176,10 @@ test("resuming Codex restores child descriptors and complete messages without hi
test("retains only the latest cumulative Pi tool progress payload", async () => {
const { parseToolArgs, parseToolResult, mapToolDetail } =
await import("./providers/pi/tool-call-mapper.js");
const { InMemoryAgentTimelineStore } = await import("./agent-timeline-store.js");
const store = new InMemoryAgentTimelineStore();
store.initialize("agent");
const { AgentTimeline } = await import("./timeline/index.js");
const timeline = new AgentTimeline();
const scope = { agentId: "agent" };
await timeline.create(scope);
const tool = parseToolArgs("custom_progress", {});
const messages: string[] = [];
for (let index = 0; index < 20; index++) {
@@ -181,16 +188,20 @@ test("retains only the latest cumulative Pi tool progress payload", async () =>
content: [{ type: "text", text: "running" }],
details: { results: [{ messages: [...messages] }] },
});
store.append("agent", {
type: "tool_call",
callId: "same-call",
name: "custom_progress",
status: "running",
error: null,
detail: mapToolDetail(tool, result),
});
await timeline.append(scope, [
{
item: {
type: "tool_call",
callId: "same-call",
name: "custom_progress",
status: "running",
error: null,
detail: mapToolDetail(tool, result),
},
},
]);
}
const history = store.fetch("agent", { limit: 0 });
const history = await timeline.fetch(scope, { limit: 0 });
expect(history.rows).toHaveLength(1);
expect(history.rows[0]).toMatchObject({
seqStart: 1,
@@ -50,7 +50,6 @@ const CLOSED_PARENT: ManagedAgent = {
inFlightPermissionResponses: new Set(),
pendingReplacement: false,
persistence: null,
historyPrimed: false,
lastUserMessageAt: null,
activeTurnId: null,
activeTurnStartedAt: null,
@@ -1,11 +1,24 @@
import { describe, expect, test } from "vitest";
import type { AgentTimelineItem } from "../agent-sdk-types.js";
import { AgentTimeline } from "../timeline/index.js";
import { ProviderSubagentStore } from "./store.js";
describe("ProviderSubagentStore", () => {
test("keeps provider children and their timelines scoped to the parent agent", () => {
const subagents = new ProviderSubagentStore();
function spawningCall(callId: string): AgentTimelineItem {
return {
type: "tool_call",
callId,
name: "Task",
status: "completed",
error: null,
detail: { type: "unknown", input: null, output: null },
};
}
subagents.apply("parent-a", "codex", {
describe("ProviderSubagentStore", () => {
test("keeps provider children and their timelines scoped to the parent agent", async () => {
const subagents = new ProviderSubagentStore(new AgentTimeline());
await subagents.apply("parent-a", "codex", {
type: "upsert",
id: "child-1",
title: "Explore",
@@ -13,19 +26,19 @@ describe("ProviderSubagentStore", () => {
status: "running",
timestamp: "2026-07-12T10:00:00.000Z",
});
subagents.apply("parent-a", "codex", {
await subagents.apply("parent-a", "codex", {
type: "timeline",
id: "child-1",
item: { type: "assistant_message", text: "Found it." },
timestamp: "2026-07-12T10:00:01.000Z",
});
subagents.apply("parent-a", "codex", {
await subagents.apply("parent-a", "codex", {
type: "upsert",
id: "child-1",
status: "completed",
timestamp: "2026-07-12T10:00:02.000Z",
});
subagents.apply("parent-b", "claude", {
await subagents.apply("parent-b", "claude", {
type: "upsert",
id: "child-1",
title: "Review",
@@ -33,7 +46,7 @@ describe("ProviderSubagentStore", () => {
timestamp: "2026-07-12T10:00:03.000Z",
});
expect(subagents.list("parent-a")).toEqual([
expect(await subagents.list("parent-a")).toEqual([
expect.objectContaining({
id: "child-1",
parentAgentId: "parent-a",
@@ -45,25 +58,28 @@ describe("ProviderSubagentStore", () => {
updatedAt: "2026-07-12T10:00:02.000Z",
}),
]);
expect(subagents.fetchTimeline("parent-a", "child-1").rows).toMatchObject([
expect((await subagents.fetchTimeline("parent-a", "child-1")).rows).toMatchObject([
{
seq: 1,
timestamp: "2026-07-12T10:00:01.000Z",
item: { type: "assistant_message", text: "Found it." },
},
]);
expect(subagents.list("parent-b")[0]).toMatchObject({ provider: "claude", title: "Review" });
expect(subagents.deleteParent("parent-a")).toEqual([
expect((await subagents.list("parent-b"))[0]).toMatchObject({
provider: "claude",
title: "Review",
});
expect(await subagents.deleteParent("parent-a")).toEqual([
{ type: "remove", parentAgentId: "parent-a", subagentId: "child-1" },
]);
expect(subagents.list("parent-a")).toEqual([]);
expect(subagents.list("parent-b")).toHaveLength(1);
expect(await subagents.list("parent-a")).toEqual([]);
expect(await subagents.list("parent-b")).toHaveLength(1);
});
test("limits oversized provider child tool output before storage", () => {
const subagents = new ProviderSubagentStore();
test("limits oversized provider child tool output before storage", async () => {
const subagents = new ProviderSubagentStore(new AgentTimeline());
const output = "x".repeat(70 * 1024);
const update = subagents.apply("parent-a", "opencode", {
const update = await subagents.apply("parent-a", "opencode", {
type: "timeline",
id: "child-1",
item: {
@@ -76,25 +92,79 @@ describe("ProviderSubagentStore", () => {
},
});
expect(update.type).toBe("timeline");
const [row] = subagents.fetchTimeline("parent-a", "child-1").rows;
expect(update?.type).toBe("timeline");
const [row] = (await subagents.fetchTimeline("parent-a", "child-1")).rows;
expect(row?.item).toMatchObject({
type: "tool_call",
detail: { type: "shell", output: expect.stringMatching(/^x+…\[truncated \d+\.\d KB\]$/) },
});
});
test("pages provider history on projected item boundaries", () => {
const subagents = new ProviderSubagentStore();
test("retires the children of rewound tool calls and drops their late events", async () => {
const timeline = new AgentTimeline();
const subagents = new ProviderSubagentStore(timeline);
const parent = { agentId: "parent-a" };
await timeline.create(parent, {});
await timeline.append(parent, [
{ item: { type: "user_message", text: "A", messageId: "a" }, turnId: "t1" },
{ item: spawningCall("call-a"), turnId: "t1" },
{ item: { type: "user_message", text: "B", messageId: "b" }, turnId: "t2" },
{ item: spawningCall("call-b"), turnId: "t2" },
]);
const upsert = (id: string, fields: { toolCallId?: string; parentSubagentId?: string }) =>
subagents.apply("parent-a", "claude", {
type: "upsert",
id,
status: "running",
timestamp: "2026-07-12T10:00:00.000Z",
...fields,
});
await upsert("kept", { toolCallId: "call-a" });
await upsert("rewound", { toolCallId: "call-b" });
await upsert("grandchild", { parentSubagentId: "rewound" });
await subagents.apply("parent-a", "claude", {
type: "timeline",
id: "rewound",
item: { type: "assistant_message", text: "Working on B." },
});
const intent = await timeline.beginRewind(parent, "b");
await timeline.recordRewindCut(parent, intent.id, { kind: "at_target" });
expect(await timeline.completeRewind(parent, intent.id)).toMatchObject({
kind: "retired",
retiredChildIds: ["rewound", "grandchild"],
});
expect((await subagents.list("parent-a")).map((subagent) => subagent.id)).toEqual(["kept"]);
// The retired child's history stays stored; a late event neither revives nor extends it.
expect(
await subagents.apply("parent-a", "claude", {
type: "timeline",
id: "rewound",
item: { type: "assistant_message", text: "Late output." },
}),
).toBeNull();
expect(await upsert("rewound", { toolCallId: "call-b" })).toBeNull();
expect(
await subagents.apply("parent-a", "claude", { type: "remove", id: "rewound" }),
).toBeNull();
expect((await subagents.list("parent-a")).map((subagent) => subagent.id)).toEqual(["kept"]);
expect(
(await subagents.fetchTimeline("parent-a", "rewound")).rows.map((row) => row.item),
).toEqual([{ type: "assistant_message", text: "Working on B." }]);
});
test("pages provider history on projected item boundaries", async () => {
const subagents = new ProviderSubagentStore(new AgentTimeline());
for (let index = 0; index < 101; index += 1) {
subagents.apply("parent-a", "opencode", {
await subagents.apply("parent-a", "opencode", {
type: "timeline",
id: "child-1",
item: { type: "assistant_message", text: String(index) },
});
}
const page = subagents.fetchTimeline("parent-a", "child-1", {
const page = await subagents.fetchTimeline("parent-a", "child-1", {
direction: "tail",
limit: 1,
});
@@ -1,11 +1,10 @@
import type { AgentProvider, AgentTimelineItem } from "../agent-sdk-types.js";
import { limitAgentTimelineItemContent } from "../agent-timeline-content.js";
import { InMemoryAgentTimelineStore } from "../agent-timeline-store.js";
import type {
AgentTimelineFetchOptions,
AgentTimelineFetchResult,
AgentTimelineRow,
} from "../agent-timeline-store-types.js";
import type { AgentTimeline, TimelineScope } from "../timeline/index.js";
export type ProviderSubagentStatus = "running" | "completed" | "failed" | "canceled";
@@ -62,8 +61,8 @@ export type ProviderSubagentStoreEvent =
}
| { type: "remove"; parentAgentId: string; subagentId: string };
function storeKey(parentAgentId: string, subagentId: string): string {
return `${parentAgentId}\0${subagentId}`;
function childScope(parentAgentId: string, subagentId: string): TimelineScope {
return { agentId: parentAgentId, subagentId };
}
/**
@@ -75,92 +74,104 @@ function stickyField<T>(next: T | undefined, previous: T | null | undefined): T
return next === undefined ? (previous ?? null) : next;
}
/**
* Applies provider child events to the parent's timeline, which stores each child's descriptor,
* history and retirement next to the parent's own history.
*/
export class ProviderSubagentStore {
private readonly descriptors = new Map<string, ProviderSubagentDescriptor>();
private readonly timelines = new InMemoryAgentTimelineStore();
constructor(private readonly timeline: AgentTimeline) {}
apply(
/** Resolves null when the event belongs to a retired child; nothing is recorded. */
async apply(
parentAgentId: string,
provider: AgentProvider,
event: ProviderSubagentInputEvent,
): ProviderSubagentStoreEvent {
const key = storeKey(parentAgentId, event.id);
): Promise<ProviderSubagentStoreEvent | null> {
const scope = childScope(parentAgentId, event.id);
if (event.type === "remove") {
this.descriptors.delete(key);
this.timelines.delete(key);
if (!(await this.timeline.removeChild(scope))) return null;
return { type: "remove", parentAgentId, subagentId: event.id };
}
if (event.type === "timeline") {
if (!this.timelines.has(key)) {
this.timelines.initialize(key);
}
const row = this.timelines.append(key, limitAgentTimelineItemContent(event.item), {
timestamp: event.timestamp,
});
const commits = await this.timeline.recordChildEntries(scope, [
{ item: event.item, timestamp: event.timestamp },
]);
if (!commits) return null;
const [{ row, epoch }] = commits;
return {
type: "timeline",
parentAgentId,
subagentId: event.id,
provider,
row,
epoch: this.timelines.getEpoch(key),
epoch,
};
}
const previous = this.descriptors.get(key);
if (!this.timelines.has(key)) {
this.timelines.initialize(key);
}
const timestamp = event.timestamp ?? new Date().toISOString();
const subagent: ProviderSubagentDescriptor = {
id: event.id,
parentAgentId,
provider,
title: stickyField(event.title, previous?.title),
description: stickyField(event.description, previous?.description),
status: event.status ?? previous?.status ?? "running",
createdAt: previous?.createdAt ?? timestamp,
updatedAt: timestamp,
toolCallId: stickyField(event.toolCallId, previous?.toolCallId),
cwd: stickyField(event.cwd, previous?.cwd),
subtitle: stickyField(event.subtitle, previous?.subtitle),
parentSubagentId: stickyField(event.parentSubagentId, previous?.parentSubagentId),
};
this.descriptors.set(key, subagent);
return { type: "upsert", subagent };
const subagent = await this.timeline.recordChild(scope, (previous) => {
const timestamp = event.timestamp ?? new Date().toISOString();
return {
id: event.id,
parentAgentId,
provider,
title: stickyField(event.title, previous?.title),
description: stickyField(event.description, previous?.description),
status: event.status ?? previous?.status ?? "running",
createdAt: previous?.createdAt ?? timestamp,
updatedAt: timestamp,
toolCallId: stickyField(event.toolCallId, previous?.toolCallId),
cwd: stickyField(event.cwd, previous?.cwd),
subtitle: stickyField(event.subtitle, previous?.subtitle),
parentSubagentId: stickyField(event.parentSubagentId, previous?.parentSubagentId),
};
});
return subagent ? { type: "upsert", subagent } : null;
}
list(parentAgentId: string): ProviderSubagentDescriptor[] {
return [...this.descriptors.values()]
.filter((subagent) => subagent.parentAgentId === parentAgentId)
/**
* Applies the child events of a provider history read. Children the parent already holds,
* listed or retired, are skipped: like the parent's, their owned history is never changed.
*/
async applyHistory(
parentAgentId: string,
events: readonly { provider: AgentProvider; event: ProviderSubagentInputEvent }[],
): Promise<ProviderSubagentStoreEvent[]> {
const held = new Set(
(await this.timeline.listChildren(parentAgentId)).map((child) => child.subagentId),
);
const updates: ProviderSubagentStoreEvent[] = [];
for (const { provider, event } of events) {
if (held.has(event.id)) continue;
const update = await this.apply(parentAgentId, provider, event);
if (update) updates.push(update);
}
return updates;
}
/** The parent's listed children, oldest first. Retired children are not listed. */
async list(parentAgentId: string): Promise<ProviderSubagentDescriptor[]> {
return (await this.timeline.listChildren(parentAgentId))
.flatMap((child) => (!child.retired && child.descriptor ? [child.descriptor] : []))
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
}
listAll(): ProviderSubagentDescriptor[] {
return [...this.descriptors.values()];
}
get(parentAgentId: string, subagentId: string): ProviderSubagentDescriptor | null {
return this.descriptors.get(storeKey(parentAgentId, subagentId)) ?? null;
async get(parentAgentId: string, subagentId: string): Promise<ProviderSubagentDescriptor | null> {
return (await this.list(parentAgentId)).find((child) => child.id === subagentId) ?? null;
}
fetchTimeline(
parentAgentId: string,
subagentId: string,
options?: AgentTimelineFetchOptions,
): AgentTimelineFetchResult {
return this.timelines.fetch(storeKey(parentAgentId, subagentId), options);
): Promise<AgentTimelineFetchResult> {
return this.timeline.fetch(childScope(parentAgentId, subagentId), options);
}
deleteParent(parentAgentId: string): ProviderSubagentStoreEvent[] {
const events: ProviderSubagentStoreEvent[] = [];
for (const subagent of this.list(parentAgentId)) {
const key = storeKey(parentAgentId, subagent.id);
this.descriptors.delete(key);
this.timelines.delete(key);
events.push({ type: "remove", parentAgentId, subagentId: subagent.id });
}
return events;
/** Deletes the parent's history and every child's, retired children included. */
async deleteParent(parentAgentId: string): Promise<ProviderSubagentStoreEvent[]> {
const listed = await this.list(parentAgentId);
await this.timeline.delete({ agentId: parentAgentId });
return listed.map((child) => ({ type: "remove", parentAgentId, subagentId: child.id }));
}
}
@@ -8,7 +8,11 @@ import { ClaudeAgentClient } from "./agent.js";
import { claudeProjectDir } from "./project-dir.js";
import { FakeClaudeSdk } from "./test-rewind-claude-sdk.js";
import { streamSession } from "../test-utils/session-stream-adapter.js";
import type { AgentSession } from "../../agent-sdk-types.js";
import {
RewindNotAppliedError,
type AgentSession,
type ConversationRewindCut,
} from "../../agent-sdk-types.js";
const SESSION_ID = "session-1";
@@ -208,14 +212,17 @@ describe("Claude rewind across a turn that produced no response", () => {
const rewindSdk = new FakeClaudeSdk();
const session = await createSession(conversation, rewindSdk);
let cut: ConversationRewindCut | void;
try {
await runTurns(session, 3);
await session.revertConversation?.({ messageId: conversation.userMessageIds[2] });
cut = await session.revertConversation?.({ messageId: conversation.userMessageIds[2] });
} finally {
await session.close();
}
expect(rewindSdk.recordedForks).toEqual([{ upToMessageId: "assistant-1" }]);
// The unanswered turn goes with the target, so the fork keeps only the first prompt.
expect(cut).toEqual({ kind: "after", retainedMessageId: conversation.userMessageIds[0] });
});
test("starts a fresh session when no earlier turn produced a response", async () => {
@@ -223,14 +230,16 @@ describe("Claude rewind across a turn that produced no response", () => {
const rewindSdk = new FakeClaudeSdk();
const session = await createSession(conversation, rewindSdk);
let cut: ConversationRewindCut | void;
try {
await runTurns(session, 2);
await session.revertConversation?.({ messageId: conversation.userMessageIds[1] });
cut = await session.revertConversation?.({ messageId: conversation.userMessageIds[1] });
} finally {
await session.close();
}
expect(rewindSdk.recordedForks).toEqual([]);
expect(cut).toEqual({ kind: "after", retainedMessageId: null });
});
test("still forks at the previous turn when every turn produced a response", async () => {
@@ -241,14 +250,52 @@ describe("Claude rewind across a turn that produced no response", () => {
const rewindSdk = new FakeClaudeSdk();
const session = await createSession(conversation, rewindSdk);
let cut: ConversationRewindCut | void;
try {
await runTurns(session, 2);
await session.revertConversation?.({ messageId: conversation.userMessageIds[1] });
cut = await session.revertConversation?.({ messageId: conversation.userMessageIds[1] });
} finally {
await session.close();
}
expect(rewindSdk.recordedForks).toEqual([{ upToMessageId: "assistant-1" }]);
expect(cut).toEqual({ kind: "at_target" });
});
});
describe("Claude rewind outcome", () => {
test("a failed fork leaves the session on its conversation and reports nothing applied", async () => {
const conversation = createConversation([
{ assistantMessageId: "assistant-1" },
{ assistantMessageId: "assistant-2" },
]);
const rewindSdk = new FakeClaudeSdk();
rewindSdk.forkError = new Error("transcript is locked");
const session = await createSession(conversation, rewindSdk);
try {
await runTurns(session, 2);
const rewind = session.revertConversation?.({ messageId: conversation.userMessageIds[1] });
await expect(rewind).rejects.toBeInstanceOf(RewindNotAppliedError);
await expect(rewind).rejects.toThrow("transcript is locked");
expect((await session.getRuntimeInfo()).sessionId).toBe(SESSION_ID);
} finally {
await session.close();
}
});
test("an unknown target is reported as not applied", async () => {
const conversation = createConversation([{ assistantMessageId: "assistant-1" }]);
const session = await createSession(conversation, new FakeClaudeSdk());
try {
await runTurns(session, 1);
await expect(session.revertConversation?.({ messageId: "unknown" })).rejects.toBeInstanceOf(
RewindNotAppliedError,
);
} finally {
await session.close();
}
});
});
@@ -130,6 +130,7 @@ import {
type AgentTimelineItem,
type AgentUsage,
type AgentRuntimeInfo,
type ConversationRewindCut,
type FetchCatalogOptions,
type ImportableProviderSession,
type ImportProviderSessionContext,
@@ -140,6 +141,7 @@ import {
type ProviderRefreshContext,
type ResolveAgentDefaultModeInput,
type ToolCallDetail,
RewindNotAppliedError,
} from "../../agent-sdk-types.js";
import { importSessionFromPersistence } from "../../provider-session-import.js";
import { runProviderRefreshActivity } from "../../provider-refresh-deadline.js";
@@ -368,9 +370,10 @@ interface ClaudeRewindTurnAnchor {
assistantMessageId: string | null;
}
/** Where a conversation rewind forks, and which prompts the fork keeps. */
type ClaudeConversationRewindTarget =
| { kind: "fresh-session" }
| { kind: "fork"; messageId: string };
| { kind: "fork"; messageId: string; cut: ConversationRewindCut };
const CLAUDE_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
@@ -3166,22 +3169,43 @@ class ClaudeAgentSession implements AgentSession {
return Array.from(commandMap.values()).sort((a, b) => a.name.localeCompare(b.name));
}
async revertConversation(input: { messageId: string }): Promise<void> {
await this.ensureHistoryReady();
const target = await this.resolveConversationRewindTarget(input.messageId);
async revertConversation(input: { messageId: string }): Promise<ConversationRewindCut> {
let target: ClaudeConversationRewindTarget;
try {
await this.ensureHistoryReady();
target = await this.resolveConversationRewindTarget(input.messageId);
} catch (error) {
throw RewindNotAppliedError.from(error);
}
if (target.kind === "fresh-session") {
this.startFreshConversationSession();
return;
return { kind: "after", retainedMessageId: null };
}
// A fork leaves the original session as it was; the rewind happened once this session
// continues the fork instead.
const currentSessionId = this.claudeSessionId;
await revertClaudeConversation({
sdk: this.rewindSdk,
sessionId: currentSessionId,
transcriptPath: currentSessionId ? await this.resolveHistoryPath(currentSessionId) : null,
messageId: target.messageId,
resolveMessageId: (messageId) => this.resolveClaudeMessageId(messageId),
setSessionId: (sessionId) => this.rebindConversationSession(sessionId),
});
let failure: unknown = null;
try {
await revertClaudeConversation({
sdk: this.rewindSdk,
sessionId: currentSessionId,
transcriptPath: currentSessionId ? await this.resolveHistoryPath(currentSessionId) : null,
messageId: target.messageId,
resolveMessageId: (messageId) => this.resolveClaudeMessageId(messageId),
setSessionId: (sessionId) => this.rebindConversationSession(sessionId),
});
} catch (error) {
failure = error;
}
if (this.claudeSessionId === currentSessionId) {
throw RewindNotAppliedError.from(
failure ?? new Error("Claude did not switch to the rewound session"),
);
}
if (failure) {
this.logger.warn({ err: failure }, "Claude rewound the conversation but failed afterwards");
}
return target.cut;
}
async revertFiles(input: { messageId: string }): Promise<void> {
@@ -3193,9 +3217,13 @@ class ClaudeAgentSession implements AgentSession {
});
}
async revertBoth(input: { messageId: string }): Promise<void> {
await this.revertFiles(input);
await this.revertConversation(input);
async revertBoth(input: { messageId: string }): Promise<ConversationRewindCut> {
try {
await this.revertFiles(input);
} catch (error) {
throw RewindNotAppliedError.from(error);
}
return this.revertConversation(input);
}
private resolveSlashCommandInvocation(prompt: AgentPromptInput): SlashCommandInvocation | null {
@@ -3519,7 +3547,8 @@ class ClaudeAgentSession implements AgentSession {
? await findSteeredMessageParent(transcriptPath, targetUserMessageId)
: null;
if (steerParent) {
return { kind: "fork", messageId: steerParent };
// The fork point is just before the steered message, so exactly it and what followed go.
return { kind: "fork", messageId: steerParent, cut: { kind: "at_target" } };
}
throw new Error(`Claude rewind target ${messageId} is not in the tracked conversation`);
}
@@ -3530,9 +3559,16 @@ class ClaudeAgentSession implements AgentSession {
// the immediately preceding anchor let one such turn fail every later rewind in the
// conversation.
for (let previous = index - 1; previous >= 0; previous -= 1) {
const assistantMessageId = this.rewindTurnAnchors[previous]?.assistantMessageId;
if (assistantMessageId) {
return { kind: "fork", messageId: assistantMessageId };
const anchor = this.rewindTurnAnchors[previous];
if (anchor?.assistantMessageId) {
return {
kind: "fork",
messageId: anchor.assistantMessageId,
cut:
previous === index - 1
? { kind: "at_target" }
: { kind: "after", retainedMessageId: anchor.userMessageId },
};
}
}
return { kind: "fresh-session" };
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { ProviderSubagentStore } from "../../../provider-subagents/store.js";
import { AgentTimeline } from "../../../timeline/index.js";
import { foldSubagentObservations, type SubagentObservation } from "./observation.js";
describe("foldSubagentObservations", () => {
@@ -66,9 +67,9 @@ describe("foldSubagentObservations", () => {
);
});
it("accumulates through the store so a later status keeps the declared identity", () => {
it("accumulates through the store so a later status keeps the declared identity", async () => {
// The property the whole design rests on: sources emit sparse facts, the store merges them.
const store = new ProviderSubagentStore();
const store = new ProviderSubagentStore(new AgentTimeline());
const events = foldSubagentObservations([
{ kind: "declared", id: "toolu_1", title: "Explore", description: "Find the code" },
{ kind: "status", id: "toolu_1", status: "completed" },
@@ -76,7 +77,7 @@ describe("foldSubagentObservations", () => {
let descriptor = null;
for (const event of events) {
const applied = store.apply("parent", "claude", event);
const applied = await store.apply("parent", "claude", event);
if (applied.type === "upsert") descriptor = applied.subagent;
}
@@ -102,15 +103,15 @@ describe("subtitle observations", () => {
expect(event).not.toHaveProperty("status");
});
it("does not revert a finished subagent when presentation changes", () => {
const store = new ProviderSubagentStore();
it("does not revert a finished subagent when presentation changes", async () => {
const store = new ProviderSubagentStore(new AgentTimeline());
let descriptor = null;
for (const event of foldSubagentObservations([
{ kind: "declared", id: "toolu_1", title: "Explore" },
{ kind: "status", id: "toolu_1", status: "completed" },
{ kind: "subtitle", id: "toolu_1", subtitle: "Explore · Opus 5 · 32.3k tokens" },
])) {
const applied = store.apply("parent", "claude", event);
const applied = await store.apply("parent", "claude", event);
if (applied.type === "upsert") descriptor = applied.subagent;
}
expect(descriptor).toMatchObject({
@@ -119,14 +120,14 @@ describe("subtitle observations", () => {
});
});
it("keeps identity when a later subtitle arrives", () => {
const store = new ProviderSubagentStore();
it("keeps identity when a later subtitle arrives", async () => {
const store = new ProviderSubagentStore(new AgentTimeline());
let descriptor = null;
for (const event of foldSubagentObservations([
{ kind: "declared", id: "toolu_1", title: "Explore", description: "Find the code" },
{ kind: "subtitle", id: "toolu_1", subtitle: "Explore · Opus 5" },
])) {
const applied = store.apply("parent", "claude", event);
const applied = await store.apply("parent", "claude", event);
if (applied.type === "upsert") descriptor = applied.subagent;
}
expect(descriptor).toMatchObject({
@@ -11,6 +11,7 @@ import {
ProviderSubagentStore,
type ProviderSubagentDescriptor,
} from "../../../provider-subagents/store.js";
import { AgentTimeline } from "../../../timeline/index.js";
import { ClaudeTaskProtocolSource } from "./live-source.js";
import { foldSubagentObservations, type SubagentObservation } from "./observation.js";
import {
@@ -40,11 +41,13 @@ function parentWithTaskCall(failed = false): ClaudeReplayParentFacts {
};
}
function applyToStore(observations: SubagentObservation[]): ProviderSubagentDescriptor | null {
const store = new ProviderSubagentStore();
async function applyToStore(
observations: SubagentObservation[],
): Promise<ProviderSubagentDescriptor | null> {
const store = new ProviderSubagentStore(new AgentTimeline());
let descriptor: ProviderSubagentDescriptor | null = null;
for (const event of foldSubagentObservations(observations)) {
const applied = store.apply("parent", "claude", event);
const applied = await store.apply("parent", "claude", event);
if (applied.type === "upsert") descriptor = applied.subagent;
}
return descriptor;
@@ -247,7 +250,7 @@ describe("observeReplaySubagents", () => {
it("drops a terminal subagent the parent has no Task call for", async () => {
// A legacy grandchild: its toolUseId names a Task call made inside a sibling's transcript, so
// no tool_result for it can ever reach the parent. Left running, it never stops.
const descriptor = applyToStore(
const descriptor = await applyToStore(
(
await observeReplaySubagents({
subagents: [
@@ -275,7 +278,7 @@ describe("observeReplaySubagents", () => {
it("drops a terminal skill-spawned subagent that has no link at all", async () => {
// A /code-review run: no Task tool_use exists to name it, so its sidecar carries only
// agentType and its id appears nowhere in the parent transcript.
const descriptor = applyToStore(
const descriptor = await applyToStore(
(
await observeReplaySubagents({
subagents: [
@@ -301,7 +304,7 @@ describe("observeReplaySubagents", () => {
});
it("reports a scraped link's failure as failed", async () => {
const descriptor = applyToStore(
const descriptor = await applyToStore(
(
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
@@ -343,7 +346,7 @@ describe("observeReplaySubagents", () => {
});
it("finishes a terminal Task child when the parent has not recorded its outcome", async () => {
const descriptor = applyToStore(
const descriptor = await applyToStore(
(
await observeReplaySubagents({
subagents: [
@@ -619,7 +622,7 @@ describe("replay usage", () => {
});
it("lands the complete provider-owned subtitle on the descriptor", async () => {
const store = new ProviderSubagentStore();
const store = new ProviderSubagentStore(new AgentTimeline());
const observations = (
await observeReplaySubagents({
subagents: [
@@ -640,10 +643,10 @@ describe("replay usage", () => {
})
).observations;
for (const event of foldSubagentObservations(observations)) {
store.apply("parent", "claude", event);
await store.apply("parent", "claude", event);
}
expect(store.get("parent", TOOL_USE_ID)?.subtitle).toBe("general-purpose · 7 tokens");
expect((await store.get("parent", TOOL_USE_ID))?.subtitle).toBe("general-purpose · 7 tokens");
});
});
@@ -689,8 +692,8 @@ describe("live and replay agree", () => {
})
).observations;
const fromWire = applyToStore(liveObservations);
const fromDisk = applyToStore(replayObservations);
const fromWire = await applyToStore(liveObservations);
const fromDisk = await applyToStore(replayObservations);
const identity = (descriptor: ProviderSubagentDescriptor | null) => ({
id: descriptor?.id,
@@ -7,6 +7,7 @@ import {
import { describe, expect, it } from "vitest";
import { ProviderSubagentStore } from "../../../provider-subagents/store.js";
import { AgentTimeline } from "../../../timeline/index.js";
import { foldSubagentObservations } from "./observation.js";
import {
observeReplayWorkflows,
@@ -82,12 +83,12 @@ describe("Claude workflow replay", () => {
],
parentEntries: parentEntries(),
});
const store = new ProviderSubagentStore();
const store = new ProviderSubagentStore(new AgentTimeline());
for (const event of foldSubagentObservations(observations)) {
store.apply("parent", "claude", event);
await store.apply("parent", "claude", event);
}
expect(store.get("parent", TOOL_CALL_ID)).toMatchObject({
expect(await store.get("parent", TOOL_CALL_ID)).toMatchObject({
id: TOOL_CALL_ID,
title: "Workflow",
description: "Runs one deterministic child and returns its structured result",
@@ -107,12 +108,12 @@ describe("Claude workflow replay", () => {
},
]),
});
const store = new ProviderSubagentStore();
const store = new ProviderSubagentStore(new AgentTimeline());
for (const event of foldSubagentObservations(observations)) {
store.apply("parent", "claude", event);
await store.apply("parent", "claude", event);
}
expect(store.get("parent", TOOL_CALL_ID)).toMatchObject({
expect(await store.get("parent", TOOL_CALL_ID)).toMatchObject({
id: TOOL_CALL_ID,
title: "Workflow",
description: "Structured result workflow",
@@ -5,6 +5,7 @@ export class FakeClaudeSdk implements ClaudeRewindSdk {
readonly recordedFileRewinds: Array<{ userMessageId: string }> = [];
private nextSessionId = "forked-session-1";
forkError: Error | null = null;
setNextSessionId(sessionId: string): void {
this.nextSessionId = sessionId;
@@ -15,6 +16,7 @@ export class FakeClaudeSdk implements ClaudeRewindSdk {
options: { upToMessageId: string; transcriptPath: string },
): Promise<{ sessionId: string }> {
this.recordedForks.push({ upToMessageId: options.upToMessageId });
if (this.forkError) throw this.forkError;
return { sessionId: this.nextSessionId };
}
@@ -271,9 +271,8 @@ describe("Codex app-server provider (local e2e)", () => {
const threadId = manager.getAgent(agentId)?.persistence?.sessionId;
expect(threadId).toBeTruthy();
for (let i = 0; i < 3; i++) {
const reloaded = await manager.reloadAgentSession(agentId, undefined, {
rehydrateFromDisk: true,
});
const reloaded = await manager.reloadAgentSession(agentId);
await manager.reconcileProviderHistory(agentId);
expect(reloaded.persistence?.sessionId).toBe(threadId);
expect(reloaded.lifecycle).toBe("idle");
}
@@ -31,6 +31,7 @@ import {
normalizeCodexOutputSchema,
toAgentUsage,
} from "./codex-app-server-agent.js";
import { AgentTimeline, type TimelineEntry } from "../timeline/index.js";
describe("mapCodexPlanUpdateToTodo", () => {
test("preserves checklist progress without creating a plan card", () => {
@@ -670,7 +671,6 @@ function createReopenableCodexAgent(threads: Record<string, unknown[]>) {
async function open(): Promise<void> {
await manager.resumeAgentFromPersistence(handle, config, agentId);
await manager.hydrateTimelineFromProvider(agentId);
}
return {
@@ -685,7 +685,7 @@ function createReopenableCodexAgent(threads: Record<string, unknown[]>) {
// Cold-loads the agent, closes its runtime, and reopens it while Paseo keeps its timeline.
async loadCloseAndReopen(): Promise<AgentTimelineItem[]> {
await open();
const projected = manager.getTimeline(agentId);
const projected = await manager.getTimeline(agentId);
await manager.closeAgent(agentId);
await open();
return projected;
@@ -2241,7 +2241,7 @@ describe("Codex app-server provider", () => {
"thread/read",
]);
expect(historyMethods(1)).toEqual(["thread/loaded/list", "thread/resume"]);
expect(codex.manager.getTimeline(codex.agentId)).toEqual(projected);
expect(await codex.manager.getTimeline(codex.agentId)).toEqual(projected);
} finally {
await codex.close();
}
@@ -2267,7 +2267,7 @@ describe("Codex app-server provider", () => {
expect(codex.latestAppServer().recordedRollbacks).toEqual([
{ threadId: "forked-thread", numTurns: 1 },
]);
expect(codex.manager.getTimeline(codex.agentId)).toEqual([
expect(await codex.manager.getTimeline(codex.agentId)).toEqual([
expect.objectContaining({ type: "user_message", messageId: "user-1", text: "First" }),
expect.objectContaining({ type: "assistant_message", text: "First answer." }),
]);
@@ -2320,19 +2320,19 @@ describe("Codex app-server provider", () => {
appServer.completeTurn({ threadId: "saved-thread" });
await run;
const toolCalls = codex.manager
.getTimeline(codex.agentId)
.filter((item) => item.type === "tool_call");
const toolCalls = (await codex.manager.getTimeline(codex.agentId)).filter(
(item) => item.type === "tool_call",
);
expect(new Set(toolCalls.map((item) => item.callId))).toEqual(new Set(["spawn-child"]));
expect(toolCalls.at(-1)).toMatchObject({ callId: "spawn-child", status: "completed" });
expect(codex.manager.getProviderSubagent(codex.agentId, "child-thread")).toMatchObject({
expect(await codex.manager.getProviderSubagent(codex.agentId, "child-thread")).toMatchObject({
toolCallId: "spawn-child",
status: "completed",
});
expect(
codex.manager
.fetchProviderSubagentTimeline(codex.agentId, "child-thread")
.rows.map((row) => row.item),
(await codex.manager.fetchProviderSubagentTimeline(codex.agentId, "child-thread")).rows.map(
(row) => row.item,
),
).toEqual([
expect.objectContaining({ type: "assistant_message", text: "Child started." }),
expect.objectContaining({ type: "assistant_message", text: "Child replied." }),
@@ -2605,6 +2605,89 @@ describe("Codex app-server provider", () => {
await session.close();
});
test("a steered prompt's rewind reports the native turn the fork removed, and the timeline keeps what Codex kept", async () => {
const appServer = createFakeCodexAppServer({
initialize: () => ({ userAgent: "paseo/0.159.0 (Ubuntu 26.4.0; x86_64) (paseo; 0)" }),
"thread/read": () => ({
thread: { id: "thread-1", historyMode: "legacy", turns: [] },
}),
"thread/rollback": () => ({
__jsonRpcError: {
code: -32600,
message: "Invalid request: unknown variant `thread/rollback`",
},
}),
});
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
await session.startTurn("remember zero");
emitCodexUserMessage(appServer, {
id: "codex-zero",
text: "remember zero",
turnId: "turn-zero",
});
appServer.completeTurn();
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => setImmediate(resolve));
await session.startTurn("remember first");
emitCodexUserMessage(appServer, {
id: "codex-first",
text: "remember first",
turnId: "turn-first",
});
// A steer joins the running native turn.
emitCodexUserMessage(appServer, {
id: "codex-second",
text: "remember second",
turnId: "turn-first",
});
appServer.completeTurn();
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => setImmediate(resolve));
const cut = await session.revertConversation({ messageId: "codex-second" });
expect(
appServer
.requests()
.filter((request) => request.method === "thread/fork")
.map((request) => (request.params as { beforeTurnId?: string }).beforeTurnId),
).toEqual(["turn-first"]);
expect(cut).toEqual({ kind: "after", retainedMessageId: "codex-zero" });
const timeline = new AgentTimeline();
const scope = { agentId: "agent" };
await timeline.create(scope);
await timeline.append(scope, [
prompt("zero"),
{ item: { type: "assistant_message", text: "reply zero" } },
prompt("first"),
prompt("second"),
{ item: { type: "assistant_message", text: "reply second" } },
]);
const intent = await timeline.beginRewind(scope, "second");
await timeline.recordRewindCut(scope, intent.id, cut);
await timeline.completeRewind(scope, intent.id);
expect((await timeline.readRows(scope)).map((row) => row.item)).toEqual([
{ type: "user_message", text: "zero", messageId: "zero" },
{ type: "assistant_message", text: "reply zero" },
]);
appServer.assertNoErrors();
await session.close();
function prompt(name: string): TimelineEntry {
return {
item: { type: "user_message", text: name, messageId: name },
providerMessageId: `codex-${name}`,
};
}
});
test("keeps a background terminal controllable on its original thread after rewind", async () => {
let live = true;
const appServer = createFakeCodexAppServer({
@@ -41,6 +41,9 @@ import {
type ProviderCatalog,
type ProviderRefreshContext,
type ResolveAgentDefaultModeInput,
type ConversationRewindCut,
type ConversationRewindResult,
RewindNotAppliedError,
} from "../agent-sdk-types.js";
import { importSessionFromPersistence } from "../provider-session-import.js";
import { runProviderRefreshActivity } from "../provider-refresh-deadline.js";
@@ -4689,6 +4692,11 @@ export class CodexAppServerAgentSession implements AgentSession {
: { index, turnId: this.userMessageProviderTurnIds.get(messageId) ?? null };
},
count: () => this.userMessageTurnIds.length,
list: () =>
this.userMessageTurnIds.map((messageId) => ({
messageId,
turnId: this.userMessageProviderTurnIds.get(messageId) ?? null,
})),
};
}
@@ -5126,37 +5134,63 @@ export class CodexAppServerAgentSession implements AgentSession {
: null;
}
async revertConversation(input: { messageId: string }): Promise<void> {
await this.connect();
if (!this.client) {
throw new Error("Codex client is not initialized");
async revertConversation(input: { messageId: string }): Promise<ConversationRewindResult> {
let threadBefore: string | null;
let client: CodexAppServerClient;
try {
await this.connect();
if (!this.client) {
throw new Error("Codex client is not initialized");
}
if (this.currentThreadId) {
await this.ensureThreadLoaded();
} else {
await this.ensureThread();
}
await this.ensureHistoryIndexed();
threadBefore = this.currentThreadId;
client = this.client;
} catch (error) {
throw RewindNotAppliedError.from(error);
}
if (this.currentThreadId) {
await this.ensureThreadLoaded();
} else {
await this.ensureThread();
}
await this.ensureHistoryIndexed();
await revertCodexConversation({
client: this.client,
threadId: this.currentThreadId,
messageId: input.messageId,
cwd: this.config.cwd ?? null,
model: this.config.model ?? null,
serviceTier: this.serviceTier,
config: this.buildCodexInnerConfig(),
userMessageTurns: this.codexUserMessageTurns(),
threadRollbackAvailable: this.threadRollbackAvailable,
setThreadId: async (threadId) => {
this.currentThreadId = threadId;
// Codex writes a forked thread's rollout when it forks it, even before any turn.
this.savedThreadId = threadId;
this.cachedRuntimeInfo = null;
this.stagedHistory = await this.readThreadHistory(this.client);
this.reconcileAsyncQuestionsAfterRewind(this.stagedHistory.timeline);
},
});
// The rewind forks; the thread it started from stays as it was. It happened once this session
// continues the fork.
let failure: unknown = null;
let cut: ConversationRewindCut | null = null;
try {
cut = await revertCodexConversation({
client,
threadId: threadBefore,
messageId: input.messageId,
cwd: this.config.cwd ?? null,
model: this.config.model ?? null,
serviceTier: this.serviceTier,
config: this.buildCodexInnerConfig(),
userMessageTurns: this.codexUserMessageTurns(),
threadRollbackAvailable: this.threadRollbackAvailable,
setThreadId: async (threadId) => {
this.currentThreadId = threadId;
// Codex writes a forked thread's rollout when it forks it, even before any turn.
this.savedThreadId = threadId;
this.cachedRuntimeInfo = null;
this.stagedHistory = await this.readThreadHistory(client);
this.reconcileAsyncQuestionsAfterRewind(this.stagedHistory.timeline);
},
});
} catch (error) {
failure = error;
}
if (this.currentThreadId === threadBefore) {
throw RewindNotAppliedError.from(
failure ?? new Error("Codex did not switch to the rewound thread"),
);
}
if (failure) {
this.logger.warn({ err: failure }, "Codex rewound the conversation but failed afterwards");
}
// A failure after the switch leaves what the fork kept unreported; the resumed history shows it.
return cut ?? { kind: "unreported" };
}
async interrupt(): Promise<void> {
@@ -7,7 +7,7 @@ import {
waitForTimelineToolCall,
} from "./codex/test-utils/fake-app-server.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import { RewindNotAppliedError, type AgentStreamEvent } from "../agent-sdk-types.js";
import { AgentManager } from "../agent-manager.js";
const questionItem = {
@@ -170,9 +170,10 @@ test("failed rewind preserves question state", async () => {
const { session, events } = await setupRewind(true);
try {
const before = session.describePersistence();
await expect(session.revertConversation({ messageId: "rewind-here" })).rejects.toThrow(
"Rewind failed",
);
const rewind = session.revertConversation({ messageId: "rewind-here" });
await expect(rewind).rejects.toThrow("Rewind failed");
// The fork was never adopted, so the conversation is known to be unchanged.
await expect(rewind).rejects.toBeInstanceOf(RewindNotAppliedError);
expect(session.describePersistence()).toEqual(before);
expect(session.getPendingPermissions().map((p) => p.id)).toEqual([
"permission-earlier-pending",
@@ -184,8 +185,8 @@ test("failed rewind preserves question state", async () => {
}
});
async function manage(session: CodexAppServerAgentSession) {
const manager = new AgentManager({
function managerFor(session: CodexAppServerAgentSession): AgentManager {
return new AgentManager({
clients: {
codex: {
provider: "codex",
@@ -198,6 +199,10 @@ async function manage(session: CodexAppServerAgentSession) {
},
logger: createTestLogger(),
});
}
async function manage(session: CodexAppServerAgentSession) {
const manager = managerFor(session);
const agent = await manager.createAgent(
{ provider: "codex", cwd: tmpdir(), model: "gpt-5.4" },
undefined,
@@ -208,9 +213,20 @@ async function manage(session: CodexAppServerAgentSession) {
test("manager publishes and saves the provider state after rewind", async () => {
const { session } = await setupRewind();
const { manager, agent } = await manage(session);
const manager = managerFor(session);
// A resumed agent imports the provider history it can rewind.
const agent = await manager.resumeAgentFromPersistence(session.describePersistence(), {
cwd: tmpdir(),
model: "gpt-5.4",
});
try {
await manager.rewind(agent.id, "rewind-here", "conversation");
expect(session.describePersistence().sessionId).toBe("forked-thread");
expect(
(await manager.fetchTimeline(agent.id, { limit: 0 })).rows.flatMap((row) =>
row.item.type === "user_message" ? [row.item.text] : [],
),
).toEqual(["earlier"]);
const snapshot = manager.getAgent(agent.id)!;
expect(Array.from(snapshot.pendingPermissions.keys())).toEqual(["permission-earlier-pending"]);
expect(snapshot.persistence).toEqual(session.describePersistence());
@@ -7,6 +7,7 @@ import type {
CodexThreadRollbackResponse,
} from "./app-server-transport.js";
import {
type CodexUserMessageTurn,
type CodexUserMessageTurnIndex,
type CodexRewindClient,
revertCodexConversation,
@@ -75,6 +76,15 @@ class CodexMessageTurns implements CodexUserMessageTurnIndex {
count(): number {
return this.indexesByMessageId.size;
}
list(): CodexUserMessageTurn[] {
return [...this.indexesByMessageId]
.sort(([, left], [, right]) => left - right)
.map(([messageId]) => ({
messageId,
turnId: this.turnIdsByMessageId.get(messageId) ?? null,
}));
}
}
describe("Codex Rewind", () => {
@@ -9,6 +9,7 @@ import {
parseCodexThreadRollbackResponse,
} from "./app-server-transport.js";
import { readCodexThreadHistoryMode } from "./thread-history.js";
import type { ConversationRewindCut } from "../../agent-sdk-types.js";
export interface CodexRewindClient {
forkThread?(params: CodexThreadForkParams): Promise<CodexThreadForkResponse>;
@@ -16,9 +17,48 @@ export interface CodexRewindClient {
request(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;
}
export interface CodexUserMessageTurn {
messageId: string;
/** The native turn the message started or joined; null when Codex did not say. */
turnId: string | null;
}
export interface CodexUserMessageTurnIndex {
resolve(messageId: string): { index: number; turnId: string | null } | null;
count(): number;
/** The thread's user messages, in order. A steered message shares its native turn's id. */
list(): readonly CodexUserMessageTurn[];
}
/**
* What the provider kept, given the first user message the rewind removed. Codex removes whole
* native turns, so a steered target takes the earlier messages of its turn with it.
*/
function rewindCut(
messages: readonly CodexUserMessageTurn[],
targetIndex: number,
firstRemoved: number,
): ConversationRewindCut {
if (firstRemoved === targetIndex) return { kind: "at_target" };
return { kind: "after", retainedMessageId: messages[firstRemoved - 1]?.messageId ?? null };
}
/** The index of the first message in the native turn of `messages[index]`. */
function turnStart(messages: readonly CodexUserMessageTurn[], index: number): number {
const turnId = messages[index]?.turnId ?? null;
if (turnId === null) return index;
let start = index;
while (start > 0 && messages[start - 1]!.turnId === turnId) start -= 1;
return start;
}
/** The index of the first message a rollback of the last `numTurns` native turns removes. */
function rollbackStart(messages: readonly CodexUserMessageTurn[], numTurns: number): number {
let start = messages.length;
for (let turns = 0; turns < numTurns && start > 0; turns += 1) {
start = turnStart(messages, start - 1);
}
return start;
}
async function forkCodexThread(
@@ -52,7 +92,7 @@ export async function revertCodexConversation(input: {
userMessageTurns: CodexUserMessageTurnIndex;
threadRollbackAvailable: boolean;
setThreadId: (threadId: string) => void | Promise<void>;
}): Promise<void> {
}): Promise<ConversationRewindCut> {
if (!input.threadId) {
throw new Error("Codex thread is not ready for rewind");
}
@@ -62,6 +102,7 @@ export async function revertCodexConversation(input: {
throw new Error(`Codex could not find user message ${input.messageId} in the current thread`);
}
const messages = input.userMessageTurns.list();
const currentUserTurnCount = input.userMessageTurns.count();
const numTurns = currentUserTurnCount - targetTurn.index;
if (numTurns < 0) {
@@ -93,7 +134,7 @@ export async function revertCodexConversation(input: {
beforeTurnId: targetTurn.turnId,
});
await input.setThreadId(forked.thread.id);
return;
return rewindCut(messages, targetTurn.index, turnStart(messages, targetTurn.index));
}
// Legacy threads on Codex before 0.156 fork and then roll back. Fork is
@@ -109,4 +150,5 @@ export async function revertCodexConversation(input: {
numTurns,
});
await input.setThreadId(rolledBack.thread.id);
return rewindCut(messages, targetTurn.index, rollbackStart(messages, numTurns));
}
@@ -62,8 +62,11 @@ describe("MockLoadTestAgentSession rewind", () => {
);
await expect(rewind.files({ messageId })).resolves.toBeUndefined();
await expect(rewind.conversation({ messageId })).resolves.toBeUndefined();
await expect(rewind.both({ messageId })).resolves.toBeUndefined();
await expect(rewind.conversation({ messageId })).resolves.toEqual({ kind: "at_target" });
// The rewind removed the message, so it is no longer a target.
await expect(rewind.both({ messageId })).rejects.toThrow(
`Mock rewind target ${messageId} was not found in session history`,
);
await session.close();
});
});
@@ -535,7 +535,7 @@ describe("MockLoadTestAgentClient", () => {
await vi.advanceTimersByTimeAsync(10_000);
await resultPromise;
const timeline = manager.getTimeline(agent.id);
const timeline = await manager.getTimeline(agent.id);
const assistantMessages = timeline.filter((item) => item.type === "assistant_message");
const toolCalls = timeline.filter((item) => item.type === "tool_call");
@@ -30,7 +30,9 @@ import type {
SteerResult,
ToolCallDetail,
ToolCallTimelineItem,
ConversationRewindCut,
} from "../agent-sdk-types.js";
import { RewindNotAppliedError } from "../agent-sdk-types.js";
import { importSessionFromPersistence } from "../provider-session-import.js";
import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
@@ -1131,10 +1133,11 @@ export class MockLoadTestAgentSession implements AgentSession {
this.listeners.clear();
}
async revertConversation(input: { messageId: string }): Promise<void> {
async revertConversation(input: { messageId: string }): Promise<ConversationRewindCut> {
this.failConfiguredRewind();
this.validateRewindTarget(input.messageId);
this.keepFirstUserMessageHistory();
this.keepHistoryBefore(input.messageId);
return { kind: "at_target" };
}
async revertFiles(input: { messageId: string }): Promise<void> {
@@ -1142,10 +1145,11 @@ export class MockLoadTestAgentSession implements AgentSession {
this.validateRewindTarget(input.messageId);
}
async revertBoth(input: { messageId: string }): Promise<void> {
async revertBoth(input: { messageId: string }): Promise<ConversationRewindCut> {
this.failConfiguredRewind();
this.validateRewindTarget(input.messageId);
this.keepFirstUserMessageHistory();
this.keepHistoryBefore(input.messageId);
return { kind: "at_target" };
}
async setModel(modelId: string | null): Promise<void> {
@@ -1173,7 +1177,7 @@ export class MockLoadTestAgentSession implements AgentSession {
private failConfiguredRewind(): void {
if (this.rewindError) {
throw new Error(this.rewindError);
throw new RewindNotAppliedError(this.rewindError);
}
}
@@ -1185,7 +1189,9 @@ export class MockLoadTestAgentSession implements AgentSession {
event.item.messageId === messageId,
);
if (!isKnownUserMessage) {
throw new Error(`Mock rewind target ${messageId} was not found in session history`);
throw new RewindNotAppliedError(
`Mock rewind target ${messageId} was not found in session history`,
);
}
}
@@ -1721,16 +1727,14 @@ export class MockLoadTestAgentSession implements AgentSession {
this.history.push(event);
}
private keepFirstUserMessageHistory(): void {
const nextHistory: AgentStreamEvent[] = [];
for (const event of this.history) {
if (event.type === "timeline" && event.item.type === "user_message") {
nextHistory.push(event);
break;
}
}
this.history.length = 0;
this.history.push(...nextHistory);
private keepHistoryBefore(messageId: string): void {
const targetIndex = this.history.findIndex(
(event) =>
event.type === "timeline" &&
event.item.type === "user_message" &&
event.item.messageId === messageId,
);
this.history.length = targetIndex;
}
private clearTurnTimer(turn: ActiveTurn): void {
@@ -41,6 +41,8 @@ import {
type SteerActiveTurnOptions,
type SteerResult,
type ToolCallDetail,
type ConversationRewindCut,
RewindNotAppliedError,
} from "../../agent-sdk-types.js";
import type { PaseoToolCatalog } from "../../tools/types.js";
import { importSessionFromPersistence } from "../../provider-session-import.js";
@@ -1198,17 +1200,21 @@ export class OmpAgentSession implements AgentSession {
}
}
async revertConversation(input: { messageId: string }): Promise<void> {
async revertConversation(input: { messageId: string }): Promise<ConversationRewindCut> {
if (this.activeTurnId) {
throw new Error("Cannot rewind the OMP conversation while a turn is active");
throw new RewindNotAppliedError("Cannot rewind the OMP conversation while a turn is active");
}
const target = input.messageId.trim();
if (!target) {
throw new Error("OMP rewind requires a user message id");
throw new RewindNotAppliedError("OMP rewind requires a user message id");
}
// A failed branch call may still have moved the session, so its error is left unknown.
await this.runtimeSession.branch(target);
await this.refreshState();
this.activeToolCalls.clear();
await this.refreshState().catch((error: unknown) => {
this.logger.warn({ err: error }, "OMP rewound the conversation but failed to refresh state");
});
return { kind: "at_target" };
}
async canStopRuntime(): Promise<boolean> {
@@ -60,6 +60,8 @@ import {
type SteerResult,
type ToolCallDetail,
type ToolCallTimelineItem,
type ConversationRewindCut,
RewindNotAppliedError,
} from "../agent-sdk-types.js";
import { importSessionFromPersistence } from "../provider-session-import.js";
import {
@@ -3755,14 +3757,18 @@ class OpenCodeAgentSession implements AgentSession {
}
}
async revertBoth(input: { messageId: string }): Promise<void> {
await this.reconnectIfServerExited();
async revertBoth(input: { messageId: string }): Promise<ConversationRewindCut> {
await this.reconnectIfServerExited().catch((error: unknown) => {
throw RewindNotAppliedError.from(error);
});
// A rejected revert may still have staged its removal, so its error is left unknown.
await revertOpenCodeConversationAndFiles({
client: this.client,
sessionId: this.sessionId,
cwd: this.config.cwd,
messageId: input.messageId,
});
return { kind: "at_target" };
}
private abortSession(turnId: string | null, reason: string): Promise<void> {
@@ -21,9 +21,11 @@ import type {
AgentSession,
AgentSessionConfig,
AgentStreamEvent,
ConversationRewindCut,
SteerActiveTurnOptions,
SteerResult,
} from "../../../agent-sdk-types.js";
import { RewindNotAppliedError } from "../../../agent-sdk-types.js";
import { toDiagnosticErrorMessage } from "../../diagnostic-utils.js";
@@ -451,15 +453,21 @@ export class OpenCodeV2Session implements AgentSession {
interrupt() {
return this.turns.interrupt();
}
async revertBoth(input: { messageId: string }) {
await this.interrupt();
await this.reconnectIfExited();
async revertBoth(input: { messageId: string }): Promise<ConversationRewindCut> {
try {
await this.interrupt();
await this.reconnectIfExited();
} catch (error) {
throw RewindNotAppliedError.from(error);
}
// A rejected stage or commit may still have changed the session, so its error is left unknown.
await this.client.session.revert.stage({
sessionID: this.id,
messageID: input.messageId,
files: true,
});
await this.client.session.revert.commit({ sessionID: this.id });
return { kind: "at_target" };
}
getPendingPermissions() {
return [...this.permissions.list()];
@@ -41,6 +41,8 @@ import {
type ListImportableSessionsOptions,
type ProviderCatalog,
type ProviderRefreshContext,
type ConversationRewindCut,
RewindNotAppliedError,
} from "../../agent-sdk-types.js";
import { importSessionFromPersistence } from "../../provider-session-import.js";
import { runProviderRefreshActivity } from "../../provider-refresh-deadline.js";
@@ -1605,16 +1607,25 @@ export class PiRpcAgentSession implements AgentSession {
}
}
async revertConversation(input: { messageId: string }): Promise<void> {
if (this.activeTurnId) {
throw new Error("Cannot rewind the Pi conversation while a turn is active");
}
await this.refreshState().catch(() => undefined);
await this.requestEntryCapture("rewind");
const targetEntry = this.treeUserEntriesById.get(input.messageId);
if (!targetEntry) {
throw new Error(`Pi rewind target ${input.messageId} was not found in captured tree entries`);
async revertConversation(input: { messageId: string }): Promise<ConversationRewindCut> {
let targetEntry: PiCapturedEntry;
try {
if (this.activeTurnId) {
throw new Error("Cannot rewind the Pi conversation while a turn is active");
}
await this.refreshState().catch(() => undefined);
await this.requestEntryCapture("rewind");
const entry = this.treeUserEntriesById.get(input.messageId);
if (!entry) {
throw new Error(
`Pi rewind target ${input.messageId} was not found in captured tree entries`,
);
}
targetEntry = entry;
} catch (error) {
throw RewindNotAppliedError.from(error);
}
// A failed navigation may still have moved the session, so its error is left unknown.
await revertPiConversation({
messageId: input.messageId,
navigator: {
@@ -1623,6 +1634,7 @@ export class PiRpcAgentSession implements AgentSession {
});
this.currentLeafOverrideId = targetEntry.parentId;
this.activeToolCalls.clear();
return { kind: "at_target" };
}
private async runPiTreeExtensionCommand(targetId: string): Promise<unknown> {
@@ -8,6 +8,7 @@ import { join } from "node:path";
import { edxethPiSubagents } from "./index.js";
import { streamPiHistory } from "../../history-mapper.js";
import { ProviderSubagentStore } from "../../../../provider-subagents/store.js";
import { AgentTimeline } from "../../../../timeline/index.js";
const args = {
agent: "scout",
@@ -274,13 +275,13 @@ describe("edxeth/pi-subagents adapter", () => {
from: `/tmp/paseo-edxeth-fixture/${mode}/child-session.jsonl`,
to: fileURLToPath(new URL(`./fixtures/${mode}-child-session.jsonl`, import.meta.url)),
});
const store = new ProviderSubagentStore();
const store = new ProviderSubagentStore(new AgentTimeline());
for await (const event of streamPiHistory("pi", fixture.messages)) {
if (event.type === "provider_subagent") store.apply("parent", "pi", event.event);
if (event.type === "provider_subagent") await store.apply("parent", "pi", event.event);
}
const children = store.list("parent");
const children = await store.list("parent");
expect(children).toEqual([expect.objectContaining({ status: "completed", title: "marker" })]);
const transcript = store.fetchTimeline("parent", children[0]!.id);
const transcript = await store.fetchTimeline("parent", children[0]!.id);
expect(transcript.rows).toContainEqual(
expect.objectContaining({
item: expect.objectContaining({
@@ -1,6 +1,7 @@
import { describe, expect, test } from "vitest";
import { fileURLToPath } from "node:url";
import { ProviderSubagentStore } from "../../../../provider-subagents/store.js";
import { AgentTimeline } from "../../../../timeline/index.js";
import { createPiExtensionHost } from "../index.js";
import { readSubagentFixture, verifySubagentFixture } from "../subagent-fixture-test.js";
import { streamPiHistory } from "../../history-mapper.js";
@@ -43,11 +44,17 @@ describe("@gotgenes/pi-subagents adapter", () => {
if (block.type === "toolCall") block.arguments = start.args;
}
const events = await verifySubagentFixture(fixture);
const store = new ProviderSubagentStore();
store.apply("parent", "pi", { type: "upsert", id: "previous-child", status: "completed" });
for (const event of events) store.apply("parent", "pi", event.event);
const store = new ProviderSubagentStore(new AgentTimeline());
await store.apply("parent", "pi", {
type: "upsert",
id: "previous-child",
status: "completed",
});
for (const event of events) await store.apply("parent", "pi", event.event);
expect(events.map(({ event }) => event.type)).toEqual(["upsert", "remove"]);
expect(store.list("parent")).toMatchObject([{ id: "previous-child", status: "completed" }]);
expect(await store.list("parent")).toMatchObject([
{ id: "previous-child", status: "completed" },
]);
},
);
@@ -1,21 +1,38 @@
import { describe, expect, test } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { AgentManager } from "../agent-manager.js";
import type { AgentClient, AgentSession, AgentSessionConfig } from "../agent-sdk-types.js";
import { ensureAgentLoaded, loadAgentHistory } from "../agent-loading.js";
import { AgentManager, type AgentManagerEvent } from "../agent-manager.js";
import {
RewindNotAppliedError,
type AgentClient,
type AgentPersistenceHandle,
type AgentSession,
type AgentSessionConfig,
type FetchCatalogOptions,
} from "../agent-sdk-types.js";
import { AgentStorage } from "../agent-storage.js";
import { AgentTimeline } from "../timeline/index.js";
import { DeferredTimelineBackend } from "../timeline/testing.js";
import { FakeRewindSession, REWIND_TEST_CAPABILITIES } from "./test-rewind-session.js";
class FakeRewindClient implements AgentClient {
readonly provider = "claude";
readonly capabilities = REWIND_TEST_CAPABILITIES;
readonly resumedSessionIds: string[] = [];
constructor(readonly session: FakeRewindSession) {}
async createSession(_config: AgentSessionConfig): Promise<AgentSession> {
return this.session;
}
async resumeSession(): Promise<AgentSession> {
async resumeSession(handle: AgentPersistenceHandle): Promise<AgentSession> {
this.resumedSessionIds.push(handle.sessionId);
return this.session;
}
@@ -28,100 +45,647 @@ class FakeRewindClient implements AgentClient {
}
}
class RewindHistoryGate {
private gate: Promise<void> | null = null;
private releaseGate: (() => void) | null = null;
/** Agent storage on a real directory whose next save can be made to fail. */
class FlakyAgentStorage extends AgentStorage {
failNextSave = false;
hold(): void {
this.gate = new Promise<void>((resolve) => {
this.releaseGate = resolve;
});
}
release(): void {
this.releaseGate?.();
this.releaseGate = null;
this.gate = null;
}
async wait(): Promise<void> {
await this.gate;
override async applySnapshot(...args: Parameters<AgentStorage["applySnapshot"]>) {
if (this.failNextSave) {
this.failNextSave = false;
throw new Error("No space left on device");
}
return super.applySnapshot(...args);
}
}
async function createRewindHarness(options: { historyGate?: RewindHistoryGate } = {}) {
const session = new FakeRewindSession(options.historyGate?.wait.bind(options.historyGate));
const storageDirs: string[] = [];
afterEach(async () => {
await Promise.all(storageDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
async function createFlakyStorage(): Promise<FlakyAgentStorage> {
const dir = await mkdtemp(join(tmpdir(), "paseo-rewind-storage-"));
storageDirs.push(dir);
return new FlakyAgentStorage(dir, createTestLogger());
}
async function createRewindHarness(
session = new FakeRewindSession(),
options: { registry?: AgentStorage } = {},
) {
const backend = new DeferredTimelineBackend();
const client = new FakeRewindClient(session);
const manager = new AgentManager({
clients: { claude: new FakeRewindClient(session) },
clients: { claude: client },
logger: createTestLogger(),
idFactory: () => "00000000-0000-4000-8000-000000000901",
timeline: new AgentTimeline(backend),
registry: options.registry,
});
const agent = await manager.createAgent(
{
provider: "claude",
cwd: process.cwd(),
},
undefined,
{ workspaceId: undefined },
);
return { manager, session, agentId: agent.id };
const agent = await manager.createAgent({ provider: "claude", cwd: process.cwd() }, undefined, {
workspaceId: undefined,
});
return { manager, session, client, backend, agentId: agent.id };
}
describe("AgentManager rewind", () => {
test("rewinds the conversation and rehydrates the timeline", async () => {
const { manager, session, agentId } = await createRewindHarness();
/** What the Reload agent action does for a loaded agent. */
async function reloadAgent(manager: AgentManager, agentId: string) {
await manager.reloadAgentSession(agentId);
return manager.reconcileProviderHistory(agentId);
}
await manager.rewind(agentId, "message-1", "conversation");
expect(session.recordedRewinds).toEqual([{ mode: "conversation", messageId: "message-1" }]);
expect(session.historyReadCount).toBe(1);
expect(manager.fetchTimeline(agentId, { limit: 0 }).rows.map((row) => row.item)).toEqual([
{ type: "user_message", text: "before", messageId: "message-1" },
]);
/** A conversation the provider answers, with no history before Paseo's first prompt. */
async function createConversationHarness(options: { registry?: AgentStorage } = {}) {
const session = new FakeRewindSession();
session.holdTurns = false;
session.history = [];
const harness = await createRewindHarness(session, options);
const events: AgentManagerEvent[] = [];
harness.manager.subscribe((event) => events.push(event), {
agentId: harness.agentId,
replayState: false,
});
async function prompt(text: string): Promise<void> {
for await (const _event of harness.manager.streamAgent(harness.agentId, text, {
clientMessageId: text,
})) {
// Drain the turn.
}
await harness.manager.flush();
}
async function activeTexts(): Promise<string[]> {
const page = await harness.manager.fetchTimeline(harness.agentId, { limit: 0 });
return page.rows.map((row) =>
row.item.type === "user_message" || row.item.type === "assistant_message"
? row.item.text
: row.item.type,
);
}
function retirements() {
return events.filter((event) => event.type === "timeline_retirement");
}
return { ...harness, events, prompt, activeTexts, retirements };
}
test("replaces the canonical epoch without replaying reconstructed parent rows", async () => {
const { manager, session, agentId } = await createRewindHarness();
const history = Array.from({ length: 250 }, (_, index) => ({
type: "assistant_message" as const,
text: `rewound ${index}`,
}));
session.history = history;
const epochBefore = manager.fetchTimeline(agentId, { limit: 0 }).epoch;
const events: string[] = [];
const unsubscribe = manager.subscribe((event) => events.push(event.type), {
replayState: false,
});
describe("AgentManager conversation rewind", () => {
test("A, B, rewind B, C keeps A, stores B outside the conversation, and shows C once", async () => {
const harness = await createConversationHarness();
const { manager, agentId, session, backend } = harness;
await harness.prompt("A");
await harness.prompt("B");
const before = await manager.fetchTimeline(agentId, { limit: 0 });
await manager.rewind(agentId, "message-1", "conversation");
unsubscribe();
await manager.rewind(agentId, "B", "conversation");
await harness.prompt("C");
const replacement = manager.fetchTimeline(agentId, { limit: 0 });
expect(replacement.epoch).not.toBe(epochBefore);
expect(replacement.rows).toEqual([
expect.objectContaining({
seq: 250,
seqStart: 1,
seqEnd: 250,
sourceSeqRanges: [{ startSeq: 1, endSeq: 250 }],
item: {
type: "assistant_message",
text: history.map((item) => item.text).join(""),
},
}),
expect(session.recordedRewinds).toEqual([{ mode: "conversation", messageId: "provider-2" }]);
expect(await harness.activeTexts()).toEqual(["A", "reply to A", "C", "reply to C"]);
expect(backend.retiredRows({ agentId }).map((row) => row.item)).toEqual([
expect.objectContaining({ type: "user_message", text: "B" }),
expect.objectContaining({ type: "assistant_message", text: "reply to B" }),
]);
expect(events.filter((type) => type === "agent_stream")).toEqual([]);
});
test("rewinds files without rehydrating the conversation timeline", async () => {
const { manager, session, agentId } = await createRewindHarness();
await manager.rewind(agentId, "message-1", "files");
expect(session.recordedRewinds).toEqual([{ mode: "files", messageId: "message-1" }]);
const after = await manager.fetchTimeline(agentId, { limit: 0 });
expect(after.epoch).toBe(before.epoch);
expect(after.retirementCount).toBe(1);
expect(harness.retirements()).toEqual([
{
type: "timeline_retirement",
agentId,
epoch: before.epoch,
seq: before.window.nextSeq,
retireFromSeq: before.rows[2]!.seqStart,
retirementCount: 1,
},
]);
// The provider history is not read back; the retirement is the only change.
expect(session.historyReadCount).toBe(0);
});
test("a reader at the end of B catches up through the retirement to C", async () => {
const harness = await createConversationHarness();
const { manager, agentId } = harness;
await harness.prompt("A");
await harness.prompt("B");
const loaded = await manager.fetchTimeline(agentId, { limit: 0 });
await manager.rewind(agentId, "B", "conversation");
await harness.prompt("C");
const page = await manager.fetchTimeline(agentId, {
direction: "after",
cursor: { epoch: loaded.epoch, seq: loaded.endSeq! },
limit: 50,
});
expect(page.reset).toBe(false);
expect(page.retirements).toEqual([
{ seq: loaded.window.nextSeq, retireFromSeq: loaded.rows[2]!.seqStart },
]);
expect(page.rows.map((row) => row.item)).toEqual([
expect.objectContaining({ type: "user_message", text: "C" }),
expect.objectContaining({ type: "assistant_message", text: "reply to C" }),
]);
});
test("repeating the rewind of B after C fails without calling the provider or touching C", async () => {
const harness = await createConversationHarness();
const { manager, agentId, session } = harness;
await harness.prompt("A");
await harness.prompt("B");
await manager.rewind(agentId, "B", "conversation");
await harness.prompt("C");
await expect(manager.rewind(agentId, "B", "conversation")).rejects.toThrow(
"The message to rewind to is no longer in the conversation",
);
expect(session.recordedRewinds).toHaveLength(1);
expect(harness.retirements()).toHaveLength(1);
expect(await harness.activeTexts()).toEqual(["A", "reply to A", "C", "reply to C"]);
});
test("repeating a rewind does not cancel the turn that is running", async () => {
const harness = await createConversationHarness();
const { manager, agentId, session } = harness;
await harness.prompt("A");
await harness.prompt("B");
await manager.rewind(agentId, "B", "conversation");
session.holdTurns = true;
const run = manager.streamAgent(agentId, "C", { clientMessageId: "C" });
await run.next();
await expect(manager.rewind(agentId, "B", "conversation")).rejects.toThrow(
"The message to rewind to is no longer in the conversation",
);
expect(session.aborted).toBe(false);
expect(manager.getAgent(agentId)?.lifecycle).toBe("running");
});
test("the provider's retained boundary decides what retires", async () => {
const harness = await createConversationHarness();
const { manager, agentId, session } = harness;
await harness.prompt("A");
await harness.prompt("B");
await harness.prompt("C");
// The provider can only fork after A, so B goes with C.
session.nextRewind = {
kind: "applied",
cut: { kind: "after", retainedMessageId: "provider-1" },
};
await manager.rewind(agentId, "C", "conversation");
expect(await harness.activeTexts()).toEqual(["A", "reply to A"]);
});
test("a failure the provider reports as not applied keeps the conversation", async () => {
const harness = await createConversationHarness();
const { manager, agentId, session } = harness;
await harness.prompt("A");
await harness.prompt("B");
session.nextRewind = { kind: "not_applied", message: "No checkpoint for this message" };
await expect(manager.rewind(agentId, "B", "conversation")).rejects.toThrow(
"No checkpoint for this message",
);
await harness.prompt("C");
expect(harness.retirements()).toEqual([]);
expect(await harness.activeTexts()).toEqual([
"A",
"reply to A",
"B",
"reply to B",
"C",
"reply to C",
]);
});
test("an unknown outcome refuses prompts and further rewinds without calling the provider again", async () => {
const harness = await createConversationHarness();
const { manager, agentId, session } = harness;
await harness.prompt("A");
await harness.prompt("B");
session.nextRewind = { kind: "unknown", applied: true };
const unknown = "Paseo could not confirm whether the conversation was rewound.";
await expect(manager.rewind(agentId, "B", "conversation")).rejects.toThrow(unknown);
await expect(harness.prompt("C")).rejects.toThrow(unknown);
await expect(manager.rewind(agentId, "A", "conversation")).rejects.toThrow(unknown);
expect(session.recordedRewinds).toHaveLength(1);
expect(harness.retirements()).toEqual([]);
// The refused prompt stays in the conversation with the reason, like any refused prompt.
expect(await harness.activeTexts()).toEqual([
"A",
"reply to A",
"B",
"reply to B",
"C",
`[System Error] ${unknown} Reload the agent to check, or fork the chat from an earlier message.`,
]);
});
test("archiving and unarchiving after A, B, rewind B, C keeps C and leaves B retired", async () => {
const registry = await createFlakyStorage();
const harness = await createConversationHarness({ registry });
const { manager, agentId, session, client } = harness;
const loader = { agentManager: manager, agentStorage: registry, logger: createTestLogger() };
await harness.prompt("A");
await harness.prompt("B");
await manager.rewind(agentId, "B", "conversation");
await harness.prompt("C");
const before = await manager.fetchTimeline(agentId, { limit: 0 });
await manager.archiveAgent(agentId);
// The archived chat reads from its stored history, without starting the provider.
expect(manager.getAgent(agentId)).toBeNull();
expect(await loadAgentHistory(agentId, loader)).toMatchObject({ kind: "stored" });
expect(await manager.fetchTimeline(agentId, { limit: 0 })).toEqual(before);
expect(client.resumedSessionIds).toEqual([]);
// Unarchive, as the refresh request runs it: load the agent, then reconcile its history.
await manager.unarchiveSnapshot(agentId);
await ensureAgentLoaded(agentId, loader);
expect(await manager.reconcileProviderHistory(agentId)).toEqual({ kind: "unchanged" });
await harness.prompt("D");
expect(client.resumedSessionIds).toHaveLength(1);
expect(await harness.activeTexts()).toEqual([
"A",
"reply to A",
"C",
"reply to C",
"D",
"reply to D",
]);
expect(await manager.fetchTimeline(agentId, { limit: 0 })).toMatchObject({
epoch: before.epoch,
retirementCount: 1,
});
expect(harness.retirements()).toHaveLength(1);
expect(session.recordedRewinds).toHaveLength(1);
});
test("reloading settles an unknown outcome the provider history proves applied", async () => {
const harness = await createConversationHarness();
const { manager, agentId, session } = harness;
await harness.prompt("A");
await harness.prompt("B");
session.nextRewind = { kind: "unknown", applied: true };
await expect(manager.rewind(agentId, "B", "conversation")).rejects.toThrow();
await reloadAgent(manager, agentId);
await harness.prompt("C");
expect(session.recordedRewinds).toHaveLength(1);
expect(harness.retirements()).toHaveLength(1);
expect(await harness.activeTexts()).toEqual(["A", "reply to A", "C", "reply to C"]);
});
test("a restarted daemon whose stored history holds an unknown outcome settles it on resume", async () => {
const registry = await createFlakyStorage();
const harness = await createConversationHarness({ registry });
const { manager, agentId, session, client, backend } = harness;
await harness.prompt("A");
await harness.prompt("B");
session.nextRewind = { kind: "unknown", applied: true };
await expect(manager.rewind(agentId, "B", "conversation")).rejects.toThrow();
await manager.closeAgent(agentId);
await manager.flush();
// The next daemon reads the same stored history; only construction differs.
const restarted = new AgentManager({
clients: { claude: client },
logger: createTestLogger(),
timeline: new AgentTimeline(backend),
registry,
});
await ensureAgentLoaded(agentId, {
agentManager: restarted,
agentStorage: registry,
logger: createTestLogger(),
});
for await (const _event of restarted.streamAgent(agentId, "C", { clientMessageId: "C" })) {
// Drain the turn.
}
await restarted.flush();
expect(session.recordedRewinds).toHaveLength(1);
const page = await restarted.fetchTimeline(agentId, { limit: 0 });
expect(page.rows.map((row) => ("text" in row.item ? row.item.text : row.item.type))).toEqual([
"A",
"reply to A",
"C",
"reply to C",
]);
expect(page.retirementCount).toBe(1);
await restarted.closeAgent(agentId);
await restarted.flush();
await registry.flush();
});
test("a restarted daemon keeps the provider children it listed and the ones a rewind retired", async () => {
const registry = await createFlakyStorage();
const harness = await createConversationHarness({ registry });
const { manager, agentId, session, client, backend } = harness;
session.subagentPrompts.add("A").add("B");
await harness.prompt("A");
await harness.prompt("B");
await manager.rewind(agentId, "B", "conversation");
await manager.closeAgent(agentId);
await manager.flush();
// The next daemon reads the same stored history; only construction differs.
const restarted = new AgentManager({
clients: { claude: client },
logger: createTestLogger(),
timeline: new AgentTimeline(backend),
registry,
});
await ensureAgentLoaded(agentId, {
agentManager: restarted,
agentStorage: registry,
logger: createTestLogger(),
});
const listed = async () =>
(await restarted.listProviderSubagents(agentId)).map((child) => child.id);
const childTexts = async (subagentId: string) =>
(await restarted.fetchProviderSubagentTimeline(agentId, subagentId)).rows.map((row) =>
"text" in row.item ? row.item.text : row.item.type,
);
expect(await listed()).toEqual(["child-of-A"]);
expect(await childTexts("child-of-A")).toEqual(["working on A"]);
expect(await childTexts("child-of-B")).toEqual(["working on B"]);
// The provider's history still holds the rewound child; importing it must not bring it back.
session.history.push({ type: "assistant_message", text: "continued outside Paseo" });
session.historySubagentEvents = [
{
type: "provider_subagent",
provider: "claude",
event: { type: "upsert", id: "child-of-B", toolCallId: "call-2", status: "completed" },
},
{
type: "provider_subagent",
provider: "claude",
event: {
type: "timeline",
id: "child-of-B",
item: { type: "assistant_message", text: "late output" },
},
},
];
expect(await reloadAgent(restarted, agentId)).toEqual({ kind: "appended" });
expect(await listed()).toEqual(["child-of-A"]);
expect(await childTexts("child-of-B")).toEqual(["working on B"]);
await restarted.closeAgent(agentId);
await restarted.deleteAgentState(agentId);
await restarted.flush();
const stored = new AgentTimeline(backend);
expect(await stored.describe({ agentId })).toBeNull();
expect(await stored.describe({ agentId, subagentId: "child-of-A" })).toBeNull();
expect(await stored.describe({ agentId, subagentId: "child-of-B" })).toBeNull();
await registry.flush();
});
test("reloading settles an unknown outcome the provider history proves unchanged", async () => {
const harness = await createConversationHarness();
const { manager, agentId, session } = harness;
await harness.prompt("A");
await harness.prompt("B");
session.nextRewind = { kind: "unknown", applied: false };
await expect(manager.rewind(agentId, "B", "conversation")).rejects.toThrow();
await reloadAgent(manager, agentId);
await harness.prompt("C");
expect(harness.retirements()).toEqual([]);
expect(await harness.activeTexts()).toEqual([
"A",
"reply to A",
"B",
"reply to B",
"C",
"reply to C",
]);
});
test("reloading keeps the rewind unresolved when the provider history proves nothing", async () => {
const harness = await createConversationHarness();
const { manager, agentId, session } = harness;
await harness.prompt("A");
await harness.prompt("B");
session.nextRewind = { kind: "unknown", applied: true };
await expect(manager.rewind(agentId, "B", "conversation")).rejects.toThrow();
// A provider that replays no history leaves nothing to compare.
session.history = [];
await reloadAgent(manager, agentId);
await expect(harness.prompt("C")).rejects.toThrow(
"Paseo could not confirm whether the conversation was rewound.",
);
// The reload did not wipe the conversation.
expect((await harness.activeTexts()).slice(0, 5)).toEqual([
"A",
"reply to A",
"B",
"reply to B",
"C",
]);
expect(harness.retirements()).toEqual([]);
});
test("a prompt refused while the rewind is unresolved does not change what the reload judges", async () => {
const harness = await createConversationHarness();
const { manager, agentId, session } = harness;
await harness.prompt("A");
await harness.prompt("B");
session.nextRewind = { kind: "unknown", applied: true };
await expect(manager.rewind(agentId, "B", "conversation")).rejects.toThrow();
// Refused before reaching the provider, so the provider history has no C.
await expect(harness.prompt("C")).rejects.toThrow();
await reloadAgent(manager, agentId);
await harness.prompt("D");
expect(harness.retirements()).toHaveLength(1);
expect(await harness.activeTexts()).toEqual(["A", "reply to A", "D", "reply to D"]);
});
test("a prompt refused while the rewind is unresolved stays when the reload proves no change", async () => {
const harness = await createConversationHarness();
const { manager, agentId, session } = harness;
await harness.prompt("A");
await harness.prompt("B");
session.nextRewind = { kind: "unknown", applied: false };
await expect(manager.rewind(agentId, "B", "conversation")).rejects.toThrow();
await expect(harness.prompt("C")).rejects.toThrow();
await reloadAgent(manager, agentId);
await harness.prompt("D");
expect(harness.retirements()).toEqual([]);
expect(await harness.activeTexts()).toEqual([
"A",
"reply to A",
"B",
"reply to B",
"C",
expect.stringContaining("[System Error] Paseo could not confirm"),
"D",
"reply to D",
]);
});
test("a rewind whose save failed is committed by a reload that resumes the rewound session", async () => {
const registry = await createFlakyStorage();
const harness = await createConversationHarness({ registry });
const { manager, agentId, session, client } = harness;
await harness.prompt("A");
await harness.prompt("B");
const before = await manager.fetchTimeline(agentId, { limit: 0 });
// The provider continues the rewound conversation in a new session, then the save fails.
session.sessionIdAfterRewind = "rewound-session";
registry.failNextSave = true;
await expect(manager.rewind(agentId, "B", "conversation")).rejects.toThrow(
"No space left on device",
);
expect(harness.retirements()).toEqual([]);
await reloadAgent(manager, agentId);
await harness.prompt("C");
expect(client.resumedSessionIds).toEqual(["rewound-session"]);
expect((await registry.get(agentId))?.persistence?.sessionId).toBe("rewound-session");
// The reload committed the recorded cut on the same timeline, without judging the replay:
// its one history read is the reconcile after the rewind settled.
expect(session.recordedRewinds).toHaveLength(1);
expect(session.historyReadCount).toBe(1);
expect(harness.retirements()).toEqual([
expect.objectContaining({ epoch: before.epoch, retireFromSeq: before.rows[2]!.seqStart }),
]);
expect(await harness.activeTexts()).toEqual(["A", "reply to A", "C", "reply to C"]);
});
test("a rewind whose save failed is committed by the next prompt without a reload", async () => {
const registry = await createFlakyStorage();
const harness = await createConversationHarness({ registry });
const { manager, agentId, session } = harness;
await harness.prompt("A");
await harness.prompt("B");
session.sessionIdAfterRewind = "rewound-session";
registry.failNextSave = true;
await expect(manager.rewind(agentId, "B", "conversation")).rejects.toThrow();
await harness.prompt("C");
expect(session.recordedRewinds).toHaveLength(1);
expect((await registry.get(agentId))?.persistence?.sessionId).toBe("rewound-session");
expect(await harness.activeTexts()).toEqual(["A", "reply to A", "C", "reply to C"]);
});
test("a rewind the provider finishes without reporting its cut settles from the resumed history", async () => {
const harness = await createConversationHarness();
const { manager, agentId, session, client } = harness;
await harness.prompt("A");
await harness.prompt("B");
const before = await manager.fetchTimeline(agentId, { limit: 0 });
session.nextRewind = { kind: "unreported", applied: true };
await manager.rewind(agentId, "B", "conversation");
await harness.prompt("C");
expect(session.recordedRewinds).toHaveLength(1);
expect(client.resumedSessionIds).toEqual(["fake-rewind-session"]);
expect(harness.retirements()).toEqual([
expect.objectContaining({ epoch: before.epoch, retireFromSeq: before.rows[2]!.seqStart }),
]);
expect(await harness.activeTexts()).toEqual(["A", "reply to A", "C", "reply to C"]);
});
test("a rewind the provider reports finished but did not apply keeps the conversation", async () => {
const harness = await createConversationHarness();
const { manager, agentId, session } = harness;
await harness.prompt("A");
await harness.prompt("B");
session.nextRewind = { kind: "unreported", applied: false };
await expect(manager.rewind(agentId, "B", "conversation")).rejects.toBeInstanceOf(
RewindNotAppliedError,
);
await harness.prompt("C");
expect(harness.retirements()).toEqual([]);
expect(await harness.activeTexts()).toEqual([
"A",
"reply to A",
"B",
"reply to B",
"C",
"reply to C",
]);
});
test("a rewind the provider finishes without reporting stays unresolved when its history proves nothing", async () => {
const harness = await createConversationHarness();
const { manager, agentId, session } = harness;
await harness.prompt("A");
session.nextRewind = { kind: "unreported", applied: true };
// Rewinding the only prompt leaves an empty history, which may also mean no replay.
const unknown = "Paseo could not confirm whether the conversation was rewound.";
await expect(manager.rewind(agentId, "A", "conversation")).rejects.toThrow(unknown);
await expect(harness.prompt("B")).rejects.toThrow(unknown);
expect(harness.retirements()).toEqual([]);
});
test("retires the provider subagent a rewound turn spawned, and keeps the others", async () => {
const harness = await createConversationHarness();
const { manager, agentId, session } = harness;
session.subagentPrompts.add("A").add("B");
await harness.prompt("A");
await harness.prompt("B");
await manager.flush();
expect((await manager.listProviderSubagents(agentId)).map((child) => child.id)).toEqual([
"child-of-A",
"child-of-B",
]);
await manager.rewind(agentId, "B", "conversation");
expect((await manager.listProviderSubagents(agentId)).map((child) => child.id)).toEqual([
"child-of-A",
]);
expect(
harness.events.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "remove" ? [event.event] : [],
),
).toEqual([{ type: "remove", parentAgentId: agentId, subagentId: "child-of-B" }]);
});
test("rewinding files leaves the conversation as it is", async () => {
const harness = await createConversationHarness();
const { manager, agentId, session } = harness;
await harness.prompt("A");
await harness.prompt("B");
await manager.rewind(agentId, "B", "files");
expect(session.recordedRewinds).toEqual([{ mode: "files", messageId: "provider-2" }]);
expect(harness.retirements()).toEqual([]);
expect(await harness.activeTexts()).toEqual(["A", "reply to A", "B", "reply to B"]);
expect(session.historyReadCount).toBe(0);
});
});
describe("AgentManager rewind run ownership", () => {
test("aborts an in-flight turn before rewinding", async () => {
const { manager, session, agentId } = await createRewindHarness();
const run = manager.streamAgent(agentId, "keep working");
@@ -140,40 +704,42 @@ describe("AgentManager rewind", () => {
}
}
const session = new RejectingInterruptSession();
const manager = new AgentManager({
clients: { claude: new FakeRewindClient(session) },
logger: createTestLogger(),
idFactory: () => "00000000-0000-4000-8000-000000000902",
});
const agent = await manager.createAgent({ provider: "claude", cwd: process.cwd() }, undefined, {
workspaceId: undefined,
});
const run = manager.streamAgent(agent.id, "keep working");
const { manager, session, agentId } = await createRewindHarness(
new RejectingInterruptSession(),
);
const run = manager.streamAgent(agentId, "keep working");
await run.next();
await expect(manager.rewind(agent.id, "message-1", "files")).rejects.toThrow(
`Cannot rewind agent ${agent.id} because its active run cancellation was not acknowledged`,
await expect(manager.rewind(agentId, "message-1", "files")).rejects.toThrow(
`Cannot rewind agent ${agentId} because its active run cancellation was not acknowledged`,
);
expect(session.recordedRewinds).toEqual([]);
expect(manager.getAgent(agent.id)).toMatchObject({
expect(manager.getAgent(agentId)).toMatchObject({
lifecycle: "running",
activeForegroundTurnId: "turn-1",
});
});
test("blocks new prompts immediately and until the rehydrate epoch broadcasts", async () => {
const historyGate = new RewindHistoryGate();
historyGate.hold();
const { manager, agentId } = await createRewindHarness({ historyGate });
test("blocks new prompts immediately and until the rewind settles", async () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
class GatedRewindSession extends FakeRewindSession {
override async revertFiles(input: { messageId: string }): Promise<void> {
await gate;
await super.revertFiles(input);
}
}
const { manager, agentId } = await createRewindHarness(new GatedRewindSession());
const rewind = manager.rewind(agentId, "message-1", "both");
const rewind = manager.rewind(agentId, "message-1", "files");
expect(() => manager.streamAgent(agentId, "too early")).toThrow(
"Agent 00000000-0000-4000-8000-000000000901 already has an active run",
`Agent ${agentId} already has an active run`,
);
historyGate.release();
release();
await rewind;
});
});
@@ -1,8 +1,13 @@
import type { AgentSession } from "../agent-sdk-types.js";
import {
RewindNotAppliedError,
type AgentSession,
type ConversationRewindResult,
} from "../agent-sdk-types.js";
export type RewindMode = "conversation" | "files" | "both";
export class RewindCapabilityError extends Error {
/** Thrown before the provider is called, so nothing changed. */
export class RewindCapabilityError extends RewindNotAppliedError {
constructor(mode: RewindMode) {
super(`Provider does not support rewinding ${mode}`);
this.name = "RewindCapabilityError";
@@ -12,14 +17,13 @@ export class RewindCapabilityError extends Error {
export async function invokeRewindCapability(
session: AgentSession,
input: { messageId: string; mode: RewindMode },
): Promise<void> {
): Promise<ConversationRewindResult | void> {
switch (input.mode) {
case "conversation":
if (!session.capabilities.supportsRewindConversation || !session.revertConversation) {
throw new RewindCapabilityError(input.mode);
}
await session.revertConversation({ messageId: input.messageId });
return;
return session.revertConversation({ messageId: input.messageId });
case "files":
if (!session.capabilities.supportsRewindFiles || !session.revertFiles) {
throw new RewindCapabilityError(input.mode);
@@ -30,7 +34,20 @@ export async function invokeRewindCapability(
if (!session.capabilities.supportsRewindBoth || !session.revertBoth) {
throw new RewindCapabilityError(input.mode);
}
await session.revertBoth({ messageId: input.messageId });
return;
return session.revertBoth({ messageId: input.messageId });
}
}
/**
* The provider may or may not have rewound the conversation. The rewind stays open and prompts are
* refused until reloading the agent lets its history settle the outcome.
*/
export class RewindOutcomeUnknownError extends Error {
constructor(options?: { cause?: unknown }) {
super(
"Paseo could not confirm whether the conversation was rewound. Reload the agent to check, or fork the chat from an earlier message.",
options,
);
this.name = "RewindOutcomeUnknownError";
}
}

Some files were not shown because too many files have changed in this diff Show More