mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 19:05:16 +00:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53fa9524cf | ||
|
|
5a8383d237 | ||
|
|
cf3caefa14 | ||
|
|
c747a58871 | ||
|
|
c36d398829 | ||
|
|
183bc736ec | ||
|
|
c4ce21a237 | ||
|
|
9a2236dac3 | ||
|
|
ecd0dd083e | ||
|
|
10c443a32b | ||
|
|
457d8d6ec5 | ||
|
|
80879eaf7c | ||
|
|
dc85c2bfbc | ||
|
|
5ed1f8d108 | ||
|
|
4178497d3d |
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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"));
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -722,6 +722,8 @@ export const ar: TranslationResources = {
|
||||
resumeCommandUnavailable: "أمر الاستئناف غير متوفر",
|
||||
reloadingAgent: "وكيل إعادة التحميل...",
|
||||
reloadedAgent: "وكيل إعادة تحميل",
|
||||
reloadedAgentHistoryDiverged:
|
||||
"تمت إعادة تحميل الوكيل، لكن سجل المزوّد لا يطابق هذه المحادثة، لذا لم تتم مزامنته",
|
||||
failedToReloadAgent: "فشل في إعادة تحميل الوكيل",
|
||||
failedToCloseAgent: "فشل في إغلاق الوكيل",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -729,6 +729,8 @@ export const ja: TranslationResources = {
|
||||
resumeCommandUnavailable: "再開コマンドが利用できません",
|
||||
reloadingAgent: "エージェントを再読み込み中...",
|
||||
reloadedAgent: "エージェントを再読み込みしました",
|
||||
reloadedAgentHistoryDiverged:
|
||||
"エージェントを再読み込みしましたが、プロバイダーの履歴がこのチャットと一致しないため同期しませんでした",
|
||||
failedToReloadAgent: "エージェントの再読み込みに失敗しました",
|
||||
failedToCloseAgent: "エージェントを閉じられませんでした",
|
||||
},
|
||||
|
||||
@@ -724,6 +724,8 @@ export const ko: TranslationResources = {
|
||||
resumeCommandUnavailable: "재개 명령을 사용할 수 없습니다",
|
||||
reloadingAgent: "에이전트 다시 로드 중...",
|
||||
reloadedAgent: "에이전트를 다시 로드했습니다",
|
||||
reloadedAgentHistoryDiverged:
|
||||
"에이전트를 다시 로드했지만 제공자 기록이 이 채팅과 일치하지 않아 동기화하지 않았습니다",
|
||||
failedToReloadAgent: "에이전트를 다시 로드하지 못했습니다",
|
||||
failedToCloseAgent: "에이전트를 닫지 못했습니다",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -729,6 +729,8 @@ export const ru: TranslationResources = {
|
||||
resumeCommandUnavailable: "Команда возобновления недоступна",
|
||||
reloadingAgent: "Перезагрузка агента...",
|
||||
reloadedAgent: "Агент перезагружен",
|
||||
reloadedAgentHistoryDiverged:
|
||||
"Агент перезагружен, но история провайдера не совпадает с этим чатом, поэтому она не синхронизирована",
|
||||
failedToReloadAgent: "Не удалось перезагрузить агента",
|
||||
failedToCloseAgent: "Не удалось закрыть агента",
|
||||
},
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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> {
|
||||
|
||||
+5
-4
@@ -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({
|
||||
|
||||
+11
-4
@@ -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
Reference in New Issue
Block a user