mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 18:35:17 +00:00
feat(timeline): apply conversation rewinds without replacing history
Rewinding a prompt used to rotate the timeline epoch, so every open client refetched and remounted the whole chat. A rewind is now a retirement: the daemon keeps the rewound turn stored but outside the conversation, and capable clients remove the rows that started in [retireFromSeq, seq) in place. Catch-up pages carry the retirements in their span. Clients without the timeline_rewind_operations capability get the old replacement shape. The daemon retires only what the provider actually removed. Providers report the boundary they kept. A failure known to change nothing keeps the conversation. Any other failure leaves the rewind open and refuses prompts until reloading the agent proves the outcome from the provider's replayed prompts. A recorded cut whose save failed is committed by the next prompt or the reload, which resumes the rewound session. Plugin providers complete session.revert without saying what they removed. Paseo then reloads the session itself and judges the outcome from the replay, so a no-op completion is not taken as a cut. The app anchors each row at the seq it started at, because a late update moves a row's latest seq into the rewound range. The app also refuses pages served before the newest retirement it has seen, then refetches them. Known limits: - The automatic plugin settle shows the agent as restoring briefly. - An empty or mismatched plugin replay leaves the rewind unresolved. Fork the chat to recover. - A plugin's failed revert still needs a manual reload. - An open rewind lives in memory, so a daemon restart drops it.
This commit is contained in:
@@ -11,6 +11,7 @@ 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 { primeAdditionalPage, resetSeededPageState } from "../support/helpers/archive-tab";
|
||||
|
||||
async function completeSubmittedTurn(
|
||||
page: Page,
|
||||
@@ -46,26 +47,61 @@ 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);
|
||||
}
|
||||
|
||||
// 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,19 +109,36 @@ 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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -317,7 +317,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(
|
||||
@@ -327,7 +327,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 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -514,7 +514,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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -399,6 +399,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.enqueueStreamEvent(agentId, {
|
||||
event: { type: "rewind", retireFromSeq },
|
||||
seq,
|
||||
epoch,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
};
|
||||
const sync = getHostRuntimeStore().createViewedTimelineOwner(serverId, {
|
||||
observe: (agentIds) => {
|
||||
const observation = client.observeTimeline(agentIds);
|
||||
@@ -407,6 +417,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"),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("AgentDirectoryReplica", () => {
|
||||
id: "reply",
|
||||
text: "server history",
|
||||
timestamp: new Date(),
|
||||
timelineCursor: { epoch: "e", seq: 1 },
|
||||
timelineCursor: { epoch: "e", seq: 1, anchorSeq: 1 },
|
||||
},
|
||||
],
|
||||
head: [],
|
||||
|
||||
@@ -243,6 +243,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;
|
||||
|
||||
@@ -2807,12 +2808,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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -739,7 +739,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",
|
||||
@@ -784,6 +784,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);
|
||||
@@ -1147,7 +1173,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 { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { clearLegacyReplicaCache } from "./legacy-cleanup";
|
||||
@@ -76,7 +77,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(),
|
||||
@@ -403,6 +412,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),
|
||||
@@ -504,11 +517,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,
|
||||
@@ -527,7 +549,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),
|
||||
};
|
||||
|
||||
@@ -27,7 +27,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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -543,6 +543,229 @@ 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) ?? [])]);
|
||||
}
|
||||
|
||||
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.enqueueStreamEvent(AGENT_ID, {
|
||||
event: { type: "rewind", retireFromSeq: 3 },
|
||||
seq: 5,
|
||||
epoch: "epoch-1",
|
||||
timestamp: new Date("2026-08-26T10:00:05.000Z"),
|
||||
});
|
||||
owner.enqueueStreamEvent(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 });
|
||||
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"]);
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
function liveRewindOfB(owner: ViewedTimelineOwner, retirement: { seq: number }): void {
|
||||
owner.enqueueStreamEvent(AGENT_ID, {
|
||||
event: { type: "rewind", retireFromSeq: 3 },
|
||||
seq: retirement.seq,
|
||||
epoch: "epoch-1",
|
||||
timestamp: new Date("2026-08-26T10:00:06.000Z"),
|
||||
});
|
||||
owner.enqueueStreamEvent(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("create handoff lifetime", () => {
|
||||
const DRAFT_ID = "draft-1";
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -359,12 +359,49 @@ export type ViewedTimelineOwnerPorts = Omit<
|
||||
"prepare" | "replaceDemandedAgentIds" | "onCatchUpEnded"
|
||||
>;
|
||||
|
||||
/**
|
||||
* `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;
|
||||
enqueueStreamEvent(agentId: string, event: AgentStreamReducerEvent): void;
|
||||
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;
|
||||
@@ -373,6 +410,7 @@ export function createViewedTimelineOwner(input: {
|
||||
ports: ViewedTimelineOwnerPorts;
|
||||
}): ViewedTimelineOwner {
|
||||
const forcedTailReplacements = new Set<string>();
|
||||
const retirements = createRetirementHighWater();
|
||||
const sync = createViewedTimelineSync({
|
||||
...input.ports,
|
||||
fetchLatestTail: async (agentId) => {
|
||||
@@ -399,6 +437,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,
|
||||
@@ -408,8 +448,12 @@ export function createViewedTimelineOwner(input: {
|
||||
drainQueuedAgentMessage: input.drainQueuedAgentMessage,
|
||||
});
|
||||
if (accepted) input.replica.timelineUpdated(payload.agentId);
|
||||
return "applied";
|
||||
},
|
||||
enqueueStreamEvent(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);
|
||||
},
|
||||
flushStreamAgent(agentId) {
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
@@ -262,7 +262,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 &&
|
||||
@@ -478,7 +478,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,
|
||||
};
|
||||
@@ -495,7 +495,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,
|
||||
@@ -515,7 +515,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;
|
||||
}
|
||||
|
||||
@@ -606,7 +609,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 {
|
||||
@@ -621,7 +628,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 {
|
||||
@@ -718,7 +731,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";
|
||||
@@ -943,7 +976,7 @@ function appendAssistantMessage(
|
||||
...last,
|
||||
text: `${last.text}${chunk}`,
|
||||
timestamp,
|
||||
...(timelineCursor ? { timelineCursor } : {}),
|
||||
...advanceTimelinePosition(last.timelineCursor, timelineCursor),
|
||||
};
|
||||
return [...state.slice(0, -1), updated];
|
||||
}
|
||||
@@ -961,7 +994,7 @@ function appendAssistantMessage(
|
||||
...secondLast,
|
||||
text: `${secondLast.text}${chunk}`,
|
||||
timestamp,
|
||||
...(timelineCursor ? { timelineCursor } : {}),
|
||||
...advanceTimelinePosition(secondLast.timelineCursor, timelineCursor),
|
||||
};
|
||||
return [...state.slice(0, -2), updated, last];
|
||||
}
|
||||
@@ -999,7 +1032,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",
|
||||
@@ -1179,7 +1212,7 @@ export function mergeAgentToolCallItem(
|
||||
|
||||
return {
|
||||
...existing,
|
||||
...(timelineCursor ? { timelineCursor } : {}),
|
||||
...advanceTimelinePosition(existing.timelineCursor, timelineCursor),
|
||||
timestamp,
|
||||
payload: {
|
||||
source: "agent",
|
||||
@@ -1285,7 +1318,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;
|
||||
}
|
||||
|
||||
@@ -1316,7 +1353,7 @@ function appendTodoList(
|
||||
const next = [...state];
|
||||
next[previousIndex] = {
|
||||
...previous,
|
||||
...(timelineCursor ? { timelineCursor } : {}),
|
||||
...advanceTimelinePosition(previous.timelineCursor, timelineCursor),
|
||||
items: normalizedItems,
|
||||
timestamp,
|
||||
};
|
||||
@@ -1335,7 +1372,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,
|
||||
@@ -1480,7 +1517,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") {
|
||||
|
||||
@@ -370,6 +370,13 @@ async function runFollowMode({
|
||||
console.log("\n[Timeline replaced; earlier output is no longer current]");
|
||||
return;
|
||||
}
|
||||
if (message.type === "agent.timeline.rewind") {
|
||||
writer.end();
|
||||
console.log(
|
||||
"\n[Conversation rewound; output since the rewound message is no longer current]",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "agent.timeline.error") {
|
||||
writer.end();
|
||||
|
||||
@@ -209,6 +209,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":
|
||||
|
||||
@@ -317,6 +317,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;
|
||||
@@ -338,7 +346,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.
|
||||
*/
|
||||
@@ -942,6 +952,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.
|
||||
|
||||
@@ -3753,6 +3753,9 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
"terminal-size-ownership": 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.
|
||||
@@ -4752,6 +4755,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({
|
||||
@@ -4775,6 +4784,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(),
|
||||
}),
|
||||
});
|
||||
@@ -4788,6 +4800,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({
|
||||
@@ -7090,6 +7114,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
FetchAgentResponseMessageSchema,
|
||||
FetchAgentTimelineResponseMessageSchema,
|
||||
AgentTimelineReplacementMessageSchema,
|
||||
AgentTimelineRewindMessageSchema,
|
||||
AgentTimelineSearchResponseMessageSchema,
|
||||
AgentTimelineListPromptsResponseMessageSchema,
|
||||
ProviderSubagentListResponseMessageSchema,
|
||||
@@ -7735,6 +7760,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.browserHost]: BrowserAutomationHostCapabilitySchema.optional(),
|
||||
})
|
||||
|
||||
@@ -124,6 +124,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",
|
||||
|
||||
@@ -984,6 +984,7 @@ test("unavailable steer interrupts once and starts one replacement turn", async
|
||||
session.steerResult = "unavailable";
|
||||
const { manager, agentId, workdir } = await startAndSteerThroughManager(session);
|
||||
try {
|
||||
await manager.waitForAgentRunStart(agentId);
|
||||
expect(session.interruptCount).toBe(1);
|
||||
expect(session.startCount).toBe(2);
|
||||
expect(await manager.getTimeline(agentId)).toContainEqual(
|
||||
@@ -1470,6 +1471,7 @@ test("missing steer operation interrupts once and starts one replacement turn",
|
||||
const session = new UnsupportedSteeringSession({ provider: "codex", cwd: process.cwd() });
|
||||
const { manager, agentId, workdir } = await startAndSteerThroughManager(session);
|
||||
try {
|
||||
await manager.waitForAgentRunStart(agentId);
|
||||
expect(session.interruptCount).toBe(1);
|
||||
expect(session.startCount).toBe(2);
|
||||
expect(await manager.getTimeline(agentId)).toContainEqual(
|
||||
|
||||
@@ -53,9 +53,11 @@ import {
|
||||
type AgentTimelineItem,
|
||||
type AgentUsage,
|
||||
type AgentRuntimeInfo,
|
||||
type ConversationRewindResult,
|
||||
type ImportedTimelineEntry,
|
||||
type ImportableProviderSession,
|
||||
type ListImportableSessionsOptions,
|
||||
RewindNotAppliedError,
|
||||
} from "./agent-sdk-types.js";
|
||||
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
|
||||
import { toStoredAgentRecord } from "./agent-projections.js";
|
||||
@@ -72,6 +74,8 @@ import {
|
||||
type TimelineCommit,
|
||||
type TimelineEntry,
|
||||
type TimelineIngestResult,
|
||||
type TimelineRetirementCommit,
|
||||
type TimelineRewindIntent,
|
||||
type TimelineScope,
|
||||
type TimelineSeed,
|
||||
} from "./timeline/index.js";
|
||||
@@ -85,7 +89,11 @@ import {
|
||||
type ForegroundTurnWaiter,
|
||||
type PendingForegroundRun,
|
||||
} from "./agent-run-state.js";
|
||||
import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
|
||||
import {
|
||||
invokeRewindCapability,
|
||||
RewindOutcomeUnknownError,
|
||||
type RewindMode,
|
||||
} from "./rewind/rewind.js";
|
||||
import { parseAgentMessage, projectAgentMessages } from "./agent-messages/index.js";
|
||||
import { isStaleProviderSessionError } from "./stale-provider-session-error.js";
|
||||
import {
|
||||
@@ -121,6 +129,9 @@ const STORED_AGENT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
|
||||
type TimeoutResult = "completed" | "timed_out";
|
||||
|
||||
/** How settling an open rewind ended: nothing open, retired, abandoned unchanged, or still open. */
|
||||
type RewindSettlement = "none" | "retired" | "abandoned" | "open";
|
||||
|
||||
function submittedPromptText(prompt: AgentPromptInput): string {
|
||||
if (typeof prompt === "string") {
|
||||
return prompt;
|
||||
@@ -242,11 +253,14 @@ export type AgentManagerEvent =
|
||||
| { type: "agent_removed"; agentId: string; workspaceId?: string; internal: boolean }
|
||||
| { type: "provider_subagent"; event: ProviderSubagentStoreEvent }
|
||||
| {
|
||||
type: "timeline_replacement";
|
||||
/** Active rows anchored in `[retireFromSeq, seq)` left the conversation; they stay stored. */
|
||||
type: "timeline_retirement";
|
||||
agentId: string;
|
||||
epoch: string;
|
||||
/** The whole replacement history, for clients that cannot refetch it. */
|
||||
rows: AgentTimelineFetchResult["rows"];
|
||||
seq: number;
|
||||
retireFromSeq: number;
|
||||
/** Retirements of this history so far, including this one. */
|
||||
retirementCount: number;
|
||||
}
|
||||
| {
|
||||
type: "agent_stream";
|
||||
@@ -254,6 +268,8 @@ export type AgentManagerEvent =
|
||||
event: AgentStreamEvent;
|
||||
seq?: number;
|
||||
epoch?: string;
|
||||
/** Retirements of the history when this row committed; present with `seq`. */
|
||||
retirementCount?: number;
|
||||
timestamp?: string;
|
||||
};
|
||||
|
||||
@@ -934,7 +950,7 @@ export class AgentManager {
|
||||
await this.trackAgentRegistrationOperation(
|
||||
this.runLifecycleMutation(agentId, async () => {
|
||||
if (!this.needsPluginSessionReload(agentId)) return;
|
||||
await this.reloadAgentSessionInternal(agentId);
|
||||
await this.reloadSession(agentId);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1631,7 +1647,7 @@ export class AgentManager {
|
||||
});
|
||||
for (const event of imported.providerSubagentEvents ?? []) {
|
||||
const update = await this.providerSubagents.apply(agent.id, event.provider, event.event);
|
||||
this.dispatch({ type: "provider_subagent", event: update });
|
||||
if (update) this.dispatch({ type: "provider_subagent", event: update });
|
||||
}
|
||||
return agent;
|
||||
} finally {
|
||||
@@ -1653,24 +1669,24 @@ export class AgentManager {
|
||||
options?: { rehydrateFromDisk?: boolean },
|
||||
): Promise<ManagedAgent> {
|
||||
return this.trackAgentRegistrationOperation(
|
||||
this.runLifecycleMutation(agentId, () =>
|
||||
this.reloadAgentSessionInternal(agentId, overrides, options),
|
||||
),
|
||||
this.runLifecycleMutation(agentId, async () => {
|
||||
return (await this.reloadSession(agentId, overrides, options)).agent;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async reloadAgentSessionInternal(
|
||||
private async reloadSession(
|
||||
agentId: string,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
options?: { rehydrateFromDisk?: boolean },
|
||||
): Promise<ManagedAgent> {
|
||||
): Promise<{ agent: ManagedAgent; rewind: RewindSettlement }> {
|
||||
this.assertAcceptingAgentRegistrations();
|
||||
let existing = this.requireSessionAgent(agentId);
|
||||
if (this.hasInFlightRun(agentId)) {
|
||||
await this.cancelAgentRunBefore(agentId, "reload");
|
||||
existing = this.requireSessionAgent(agentId);
|
||||
}
|
||||
const rehydrateFromDisk = options?.rehydrateFromDisk ?? false;
|
||||
const { rehydrateFromDisk, settleRewind } = await this.planReloadHistory(agentId, options);
|
||||
const preservedHistoryPrimed = existing.historyPrimed;
|
||||
const preservedLastUsage = existing.lastUsage;
|
||||
const preservedLastError = existing.lastError;
|
||||
@@ -1731,18 +1747,11 @@ export class AgentManager {
|
||||
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
|
||||
this.assertAcceptingAgentRegistrations();
|
||||
|
||||
if (rehydrateFromDisk) {
|
||||
// Wipe the in-memory timeline so registerSession mints a new epoch and
|
||||
// hydrateTimelineFromProvider re-streams the freshly read provider history.
|
||||
await this.timeline.delete({ agentId });
|
||||
for (const event of await this.providerSubagents.deleteParent(agentId)) {
|
||||
this.dispatch({ type: "provider_subagent", event });
|
||||
}
|
||||
}
|
||||
if (rehydrateFromDisk) await this.discardTimelineForRehydrate(agentId);
|
||||
|
||||
// Preserve existing labels and timeline during reload.
|
||||
handedToRegistration = true;
|
||||
return this.registerSession(session, storedConfig, agentId, {
|
||||
const registered = this.registerSession(session, storedConfig, agentId, {
|
||||
labels: existing.labels,
|
||||
workspaceId: existing.workspaceId,
|
||||
owner: existing.owner,
|
||||
@@ -1756,6 +1765,9 @@ export class AgentManager {
|
||||
attention: preservedAttention,
|
||||
restoring: true,
|
||||
});
|
||||
return settleRewind
|
||||
? registered.then((snapshot) => this.settleRewindAfterReload(snapshot))
|
||||
: registered.then((agent) => ({ agent, rewind: "none" as const }));
|
||||
} catch (error) {
|
||||
if (closedExisting) {
|
||||
this.emitClosedAgent(closedExisting, { persist: false });
|
||||
@@ -1780,6 +1792,30 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An open rewind is judged from the resumed session's history, so that history must not replace
|
||||
* the timeline first.
|
||||
*/
|
||||
private async planReloadHistory(
|
||||
agentId: string,
|
||||
options: { rehydrateFromDisk?: boolean } | undefined,
|
||||
): Promise<{ rehydrateFromDisk: boolean; settleRewind: boolean }> {
|
||||
const openRewind = (await this.timeline.describe({ agentId }))?.rewind ?? null;
|
||||
return {
|
||||
rehydrateFromDisk: (options?.rehydrateFromDisk ?? false) && openRewind === null,
|
||||
settleRewind: openRewind !== null,
|
||||
};
|
||||
}
|
||||
|
||||
// Wipes the in-memory timeline so registerSession mints a new epoch and
|
||||
// hydrateTimelineFromProvider re-streams the freshly read provider history.
|
||||
private async discardTimelineForRehydrate(agentId: string): Promise<void> {
|
||||
await this.timeline.delete({ agentId });
|
||||
for (const event of await this.providerSubagents.deleteParent(agentId)) {
|
||||
this.dispatch({ type: "provider_subagent", event });
|
||||
}
|
||||
}
|
||||
|
||||
private async closeReloadedSession(session: AgentSession, agentId: string): Promise<void> {
|
||||
let operation = this.reloadedSessionCloses.get(session);
|
||||
if (!operation) {
|
||||
@@ -2002,7 +2038,7 @@ export class AgentManager {
|
||||
id: subagent.id,
|
||||
status: "canceled",
|
||||
});
|
||||
this.dispatch({ type: "provider_subagent", event });
|
||||
if (event) this.dispatch({ type: "provider_subagent", event });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2712,7 +2748,7 @@ export class AgentManager {
|
||||
async appendTimelineItem(
|
||||
agentId: string,
|
||||
item: AgentTimelineItem,
|
||||
): Promise<{ seq: number; epoch: string }> {
|
||||
): Promise<{ seq: number; epoch: string; retirementCount: number }> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
item = limitAgentTimelineItemContent(item);
|
||||
this.touchUpdatedAt(agent);
|
||||
@@ -2722,9 +2758,12 @@ export class AgentManager {
|
||||
{ item },
|
||||
{ publish: (commit) => this.publishTimelineEvent(agentId, event, commit) },
|
||||
);
|
||||
if (result.kind === "dropped") {
|
||||
throw new Error("Timeline item belongs to a rewound turn");
|
||||
}
|
||||
this.reportUnpublished(agentId, result);
|
||||
await this.persistSnapshot(agent);
|
||||
return { seq: result.row.seq, epoch: result.epoch };
|
||||
return { seq: result.row.seq, epoch: result.epoch, retirementCount: result.retirementCount };
|
||||
}
|
||||
|
||||
async emitLiveTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
|
||||
@@ -2746,6 +2785,8 @@ export class AgentManager {
|
||||
}): Promise<string> {
|
||||
const { agent, agentId, pendingRun, prompt, options } = params;
|
||||
try {
|
||||
// A rewind still open from an earlier failure must settle before the conversation moves on.
|
||||
await this.settleOpenRewind(agent, { judge: false });
|
||||
const result = await agent.session.startTurn(prompt, options);
|
||||
if (pendingRun.settled) {
|
||||
throw new Error(`Agent ${agentId} run was canceled before its turn started`);
|
||||
@@ -3500,11 +3541,17 @@ export class AgentManager {
|
||||
// Without an active run, the rewind holds the run from its call, so no prompt starts
|
||||
// while it reads the history.
|
||||
const heldFromCall = this.hasInFlightRun(agentId) ? null : this.runs.createPendingRun(agentId);
|
||||
const scope: TimelineScope = { agentId };
|
||||
let providerMessageId: string;
|
||||
// Opening the rewind checks its target is active, before a running turn is canceled for it.
|
||||
let intent: TimelineRewindIntent | null = null;
|
||||
try {
|
||||
if (mode !== "files") await this.settleOpenRewind(agent, { judge: false });
|
||||
providerMessageId = await this.resolveRewindTarget(agentId, messageId);
|
||||
if (mode !== "files") intent = await this.timeline.beginRewind(scope, messageId);
|
||||
if (!heldFromCall) await this.cancelAgentRunBefore(agentId, "rewind");
|
||||
} catch (error) {
|
||||
if (intent) await this.timeline.abandonRewind(scope, intent.id);
|
||||
if (heldFromCall) this.runs.settleForegroundRun(agentId, heldFromCall.token);
|
||||
throw error;
|
||||
}
|
||||
@@ -3515,26 +3562,19 @@ export class AgentManager {
|
||||
{ agentId, provider: agent.provider, messageId, mode },
|
||||
"agent.rewind.start",
|
||||
);
|
||||
await invokeRewindCapability(agent.session, { messageId: providerMessageId, mode });
|
||||
if (mode !== "files") {
|
||||
await this.hydrateTimelineFromProvider(agentId, {
|
||||
force: true,
|
||||
broadcast: true,
|
||||
broadcastTimeline: false,
|
||||
});
|
||||
const replacement = await this.timeline.fetch({ agentId }, { limit: 0 });
|
||||
this.dispatch({
|
||||
type: "timeline_replacement",
|
||||
agentId,
|
||||
epoch: replacement.epoch,
|
||||
rows: replacement.rows,
|
||||
});
|
||||
let outcome: "retired" | "unreported" = "retired";
|
||||
try {
|
||||
if (intent) {
|
||||
outcome = await this.rewindConversation(agent, intent, { providerMessageId, mode });
|
||||
} else {
|
||||
await invokeRewindCapability(agent.session, { messageId: providerMessageId, mode });
|
||||
await this.persistRewoundSession(agent);
|
||||
}
|
||||
} finally {
|
||||
this.runs.settleForegroundRun(agentId, lock.token);
|
||||
}
|
||||
// Rewind stages provider events under the run lock; publish its final state directly.
|
||||
this.refreshSessionPersistence(agent);
|
||||
await this.refreshSessionState(agent, { emit: false });
|
||||
await this.persistSnapshot(agent);
|
||||
this.emitState(agent, { persist: false });
|
||||
// Reloading takes the agent's lifecycle lane, so it runs after the rewind releases the run.
|
||||
if (outcome === "unreported") await this.settleUnreportedRewind(agent);
|
||||
this.logger.info(
|
||||
{ agentId, provider: agent.provider, messageId, mode },
|
||||
"agent.rewind.complete",
|
||||
@@ -3545,11 +3585,141 @@ export class AgentManager {
|
||||
"agent.rewind.failed",
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
this.runs.settleForegroundRun(agentId, lock.token);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider outcome, then the persisted handle, then the retirement. A known failure abandons the
|
||||
* rewind; an unknown one leaves it open. An unreported one stays open for the resumed session's
|
||||
* history to settle. The provider call is never repeated for one rewind.
|
||||
*/
|
||||
private async rewindConversation(
|
||||
agent: ActiveManagedAgent,
|
||||
intent: TimelineRewindIntent,
|
||||
target: { providerMessageId: string; mode: RewindMode },
|
||||
): Promise<"retired" | "unreported"> {
|
||||
const scope: TimelineScope = { agentId: agent.id };
|
||||
let result: ConversationRewindResult;
|
||||
try {
|
||||
result = (await invokeRewindCapability(agent.session, {
|
||||
messageId: target.providerMessageId,
|
||||
mode: target.mode,
|
||||
})) ?? { kind: "at_target" };
|
||||
} catch (error) {
|
||||
if (!(error instanceof RewindNotAppliedError))
|
||||
throw new RewindOutcomeUnknownError({ cause: error });
|
||||
await this.timeline.abandonRewind(scope, intent.id);
|
||||
throw error;
|
||||
}
|
||||
if (result.kind === "unreported") {
|
||||
await this.persistRewoundSession(agent);
|
||||
return "unreported";
|
||||
}
|
||||
await this.timeline.recordRewindCut(scope, intent.id, result);
|
||||
await this.completeRewind(agent, intent.id);
|
||||
return "retired";
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider finished the rewind without saying what it removed. Reloading resumes its
|
||||
* persisted session, whose replayed prompts settle the open rewind. A session without a
|
||||
* persisted handle would resume as a new, empty conversation, which proves nothing.
|
||||
*/
|
||||
private async settleUnreportedRewind(agent: ActiveManagedAgent): Promise<void> {
|
||||
if (!agent.persistence) throw new RewindOutcomeUnknownError();
|
||||
const { rewind } = await this.trackAgentRegistrationOperation(
|
||||
this.runLifecycleMutation(agent.id, () => this.reloadSession(agent.id)),
|
||||
);
|
||||
if (rewind === "retired") return;
|
||||
if (rewind === "abandoned") {
|
||||
throw new RewindNotAppliedError(
|
||||
"The provider finished the rewind, but its history still contains the message",
|
||||
);
|
||||
}
|
||||
throw new RewindOutcomeUnknownError();
|
||||
}
|
||||
|
||||
/** Persists the session the provider now continues, then commits the retirement. */
|
||||
private async completeRewind(agent: ActiveManagedAgent, intentId: string): Promise<void> {
|
||||
await this.persistRewoundSession(agent);
|
||||
const completion = await this.timeline.completeRewind({ agentId: agent.id }, intentId, {
|
||||
publish: (commit) => this.publishRetirement(agent.id, commit),
|
||||
});
|
||||
if (completion.kind === "unpublished") {
|
||||
this.logger.error(
|
||||
{ err: completion.error, agentId: agent.id },
|
||||
"Failed to publish committed rewind",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async persistRewoundSession(agent: ActiveManagedAgent): Promise<void> {
|
||||
// Rewind stages provider events under the run lock; publish its final state directly.
|
||||
this.refreshSessionPersistence(agent);
|
||||
await this.refreshSessionState(agent, { emit: false });
|
||||
await this.persistSnapshot(agent);
|
||||
this.emitState(agent, { persist: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Settles a rewind left open by an earlier failure. A recorded provider outcome is committed
|
||||
* without calling the provider again. An unknown one is judged from the prompts the provider's
|
||||
* history replays when `judge` is set (a freshly resumed session); otherwise it stays open.
|
||||
*/
|
||||
private async settleOpenRewind(
|
||||
agent: ActiveManagedAgent,
|
||||
options: { judge: boolean },
|
||||
): Promise<RewindSettlement> {
|
||||
const scope: TimelineScope = { agentId: agent.id };
|
||||
const intent = (await this.timeline.describe(scope))?.rewind;
|
||||
if (!intent) return "none";
|
||||
if (intent.cut) {
|
||||
await this.completeRewind(agent, intent.id);
|
||||
return "retired";
|
||||
}
|
||||
if (!options.judge) throw new RewindOutcomeUnknownError();
|
||||
const verdict = await this.timeline.judgeRewind(
|
||||
scope,
|
||||
intent.id,
|
||||
await this.readProviderPromptIds(agent.session),
|
||||
);
|
||||
this.logger.info({ agentId: agent.id, intentId: intent.id, verdict }, "agent.rewind.judged");
|
||||
if (verdict.kind === "unchanged") {
|
||||
await this.timeline.abandonRewind(scope, intent.id);
|
||||
return "abandoned";
|
||||
}
|
||||
if (verdict.kind === "applied") {
|
||||
await this.timeline.recordRewindCut(scope, intent.id, verdict.cut);
|
||||
await this.completeRewind(agent, intent.id);
|
||||
return "retired";
|
||||
}
|
||||
return "open";
|
||||
}
|
||||
|
||||
/** The reload itself succeeded; a rewind that still cannot settle stays open and is reported. */
|
||||
private async settleRewindAfterReload(
|
||||
snapshot: ManagedAgent,
|
||||
): Promise<{ agent: ManagedAgent; rewind: RewindSettlement }> {
|
||||
let rewind: RewindSettlement = "open";
|
||||
try {
|
||||
rewind = await this.settleOpenRewind(this.requireSessionAgent(snapshot.id), { judge: true });
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, agentId: snapshot.id }, "agent.rewind.settle_failed");
|
||||
}
|
||||
return { agent: this.getAgent(snapshot.id) ?? snapshot, rewind };
|
||||
}
|
||||
|
||||
/** The prompt ids of the provider's current history, in order. Reads; records nothing. */
|
||||
private async readProviderPromptIds(session: AgentSession): Promise<string[]> {
|
||||
const ids: string[] = [];
|
||||
for await (const event of session.streamHistory()) {
|
||||
if (event.type !== "timeline" || event.item.type !== "user_message") continue;
|
||||
if (event.item.messageId && ids.at(-1) !== event.item.messageId)
|
||||
ids.push(event.item.messageId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** The provider's id for the rewound prompt. Paseo-submitted prompts need its acknowledgement. */
|
||||
private async resolveRewindTarget(agentId: string, messageId: string): Promise<string> {
|
||||
const submittedRow = await this.timeline.findSubmittedPrompt({ agentId }, messageId);
|
||||
@@ -4173,7 +4343,7 @@ export class AgentManager {
|
||||
): Promise<void> {
|
||||
if (event.type === "provider_subagent") {
|
||||
const update = await this.providerSubagents.apply(agent.id, event.provider, event.event);
|
||||
this.dispatch({ type: "provider_subagent", event: update });
|
||||
if (update) this.dispatch({ type: "provider_subagent", event: update });
|
||||
return;
|
||||
}
|
||||
const turnId = getAgentStreamEventTurnId(event);
|
||||
@@ -4372,7 +4542,7 @@ export class AgentManager {
|
||||
}
|
||||
for (const event of providerSubagentEvents) {
|
||||
const update = await this.providerSubagents.apply(agent.id, event.provider, event.event);
|
||||
if (broadcast) {
|
||||
if (update && broadcast) {
|
||||
this.dispatch({ type: "provider_subagent", event: update });
|
||||
}
|
||||
}
|
||||
@@ -4425,6 +4595,7 @@ export class AgentManager {
|
||||
const providerSubagentEvents: AgentManagerEvent[] = [];
|
||||
for (const event of historySubagentEvents) {
|
||||
const update = await this.providerSubagents.apply(agent.id, event.provider, event.event);
|
||||
if (!update) continue;
|
||||
const managerEvent: AgentManagerEvent = { type: "provider_subagent", event: update };
|
||||
if (deferredBroadcast) {
|
||||
providerSubagentEvents.push(managerEvent);
|
||||
@@ -5009,10 +5180,29 @@ export class AgentManager {
|
||||
this.dispatchStream(agentId, event, {
|
||||
seq: commit.row.seq,
|
||||
epoch: commit.epoch,
|
||||
retirementCount: commit.retirementCount,
|
||||
timestamp: commit.row.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
/** Children spawned by retired tool calls leave with them, before clients learn of the retirement. */
|
||||
private publishRetirement(agentId: string, commit: TimelineRetirementCommit): void {
|
||||
const toolCallIds = new Set(
|
||||
commit.retiredRows.flatMap((row) => (row.item.type === "tool_call" ? [row.item.callId] : [])),
|
||||
);
|
||||
for (const event of this.providerSubagents.retireSpawnedBy(agentId, toolCallIds)) {
|
||||
this.dispatch({ type: "provider_subagent", event });
|
||||
}
|
||||
this.dispatch({
|
||||
type: "timeline_retirement",
|
||||
agentId,
|
||||
epoch: commit.epoch,
|
||||
seq: commit.retirement.seq,
|
||||
retireFromSeq: commit.retirement.retireFromSeq,
|
||||
retirementCount: commit.retirementCount,
|
||||
});
|
||||
}
|
||||
|
||||
/** A commit whose publication threw stays committed; the failure is logged, not retried. */
|
||||
private reportUnpublished(
|
||||
agentId: string,
|
||||
@@ -5314,6 +5504,7 @@ export class AgentManager {
|
||||
metadata?: {
|
||||
seq?: number;
|
||||
epoch?: string;
|
||||
retirementCount?: number;
|
||||
timestamp?: string;
|
||||
},
|
||||
): void {
|
||||
|
||||
@@ -590,6 +590,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;
|
||||
@@ -710,9 +742,17 @@ export interface AgentSession {
|
||||
setModel?(modelId: string | null): Promise<void>;
|
||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
|
||||
setFeature?(featureId: string, value: unknown): Promise<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
|
||||
|
||||
@@ -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,4 +51,8 @@ export interface AgentTimelineFetchResult {
|
||||
startSeq: number | null;
|
||||
endSeq: number | null;
|
||||
rows: ProjectedTimelineRow[];
|
||||
/** 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;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,11 @@ 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 {
|
||||
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";
|
||||
@@ -1075,6 +1079,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> {
|
||||
|
||||
@@ -56,7 +56,9 @@ import type {
|
||||
ProviderRefreshContext,
|
||||
SteerActiveTurnOptions,
|
||||
SteerResult,
|
||||
ConversationRewindResult,
|
||||
} from "./agent-sdk-types.js";
|
||||
import { RewindNotAppliedError } from "./agent-sdk-types.js";
|
||||
import {
|
||||
resolveProviderLaunch,
|
||||
checkProviderLaunchAvailable,
|
||||
@@ -1357,16 +1359,21 @@ class PluginAgentSession implements AgentSession {
|
||||
});
|
||||
}
|
||||
|
||||
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"] {
|
||||
@@ -1638,7 +1645,9 @@ class PluginAgentSession implements AgentSession {
|
||||
|
||||
private async revert(messageId: string, scope: "conversation" | "files" | "both") {
|
||||
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(),
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("ProviderSubagentStore", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(update.type).toBe("timeline");
|
||||
expect(update?.type).toBe("timeline");
|
||||
const [row] = (await subagents.fetchTimeline("parent-a", "child-1")).rows;
|
||||
expect(row?.item).toMatchObject({
|
||||
type: "tool_call",
|
||||
@@ -85,6 +85,46 @@ describe("ProviderSubagentStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("retires the children of rewound tool calls and drops their late events", async () => {
|
||||
const subagents = new ProviderSubagentStore(new AgentTimeline());
|
||||
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." },
|
||||
});
|
||||
|
||||
expect(subagents.retireSpawnedBy("parent-a", new Set(["call-b"]))).toEqual([
|
||||
{ type: "remove", parentAgentId: "parent-a", subagentId: "rewound" },
|
||||
{ type: "remove", parentAgentId: "parent-a", subagentId: "grandchild" },
|
||||
]);
|
||||
expect(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(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) {
|
||||
|
||||
@@ -80,16 +80,20 @@ function stickyField<T>(next: T | undefined, previous: T | null | undefined): T
|
||||
|
||||
export class ProviderSubagentStore {
|
||||
private readonly descriptors = new Map<string, ProviderSubagentDescriptor>();
|
||||
/** Children of rewound turns. Their history stays stored; late provider events are dropped. */
|
||||
private readonly retired = new Set<string>();
|
||||
|
||||
constructor(private readonly timeline: AgentTimeline) {}
|
||||
|
||||
/** Resolves null when the event belongs to a retired child; nothing is recorded. */
|
||||
async apply(
|
||||
parentAgentId: string,
|
||||
provider: AgentProvider,
|
||||
event: ProviderSubagentInputEvent,
|
||||
): Promise<ProviderSubagentStoreEvent> {
|
||||
): Promise<ProviderSubagentStoreEvent | null> {
|
||||
const key = storeKey(parentAgentId, event.id);
|
||||
const scope = childScope(parentAgentId, event.id);
|
||||
if (this.retired.has(key)) return null;
|
||||
if (event.type === "remove") {
|
||||
this.descriptors.delete(key);
|
||||
await this.timeline.delete(scope);
|
||||
@@ -154,13 +158,52 @@ export class ProviderSubagentStore {
|
||||
return this.timeline.fetch(childScope(parentAgentId, subagentId), options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retires the children spawned by retired tool calls of the parent, and their descendants. They
|
||||
* leave the listing; their history stays stored.
|
||||
*/
|
||||
retireSpawnedBy(
|
||||
parentAgentId: string,
|
||||
toolCallIds: ReadonlySet<string>,
|
||||
): Extract<ProviderSubagentStoreEvent, { type: "remove" }>[] {
|
||||
const children = this.list(parentAgentId);
|
||||
const retiredIds = new Set(
|
||||
children
|
||||
.filter((subagent) => subagent.toolCallId !== null && toolCallIds.has(subagent.toolCallId))
|
||||
.map((subagent) => subagent.id),
|
||||
);
|
||||
let grew = retiredIds.size > 0;
|
||||
while (grew) {
|
||||
grew = false;
|
||||
for (const subagent of children) {
|
||||
if (retiredIds.has(subagent.id) || subagent.parentSubagentId === null) continue;
|
||||
if (!retiredIds.has(subagent.parentSubagentId)) continue;
|
||||
retiredIds.add(subagent.id);
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
return [...retiredIds].map((subagentId) => {
|
||||
const key = storeKey(parentAgentId, subagentId);
|
||||
this.descriptors.delete(key);
|
||||
this.retired.add(key);
|
||||
return { type: "remove", parentAgentId, subagentId };
|
||||
});
|
||||
}
|
||||
|
||||
async deleteParent(parentAgentId: string): Promise<ProviderSubagentStoreEvent[]> {
|
||||
const prefix = storeKey(parentAgentId, "");
|
||||
const retired = [...this.retired]
|
||||
.filter((key) => key.startsWith(prefix))
|
||||
.map((key) => key.slice(prefix.length));
|
||||
for (const subagentId of retired) this.retired.delete(storeKey(parentAgentId, subagentId));
|
||||
const removed = this.list(parentAgentId).map((subagent) => subagent.id);
|
||||
for (const subagentId of removed) {
|
||||
this.descriptors.delete(storeKey(parentAgentId, subagentId));
|
||||
}
|
||||
await Promise.all(
|
||||
removed.map((subagentId) => this.timeline.delete(childScope(parentAgentId, subagentId))),
|
||||
[...removed, ...retired].map((subagentId) =>
|
||||
this.timeline.delete(childScope(parentAgentId, subagentId)),
|
||||
),
|
||||
);
|
||||
return removed.map((subagentId) => ({ type: "remove", parentAgentId, subagentId }));
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@ import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import { ClaudeAgentClient } from "./agent.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";
|
||||
|
||||
@@ -204,14 +208,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 () => {
|
||||
@@ -219,14 +226,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 () => {
|
||||
@@ -237,14 +246,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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -126,6 +126,7 @@ import {
|
||||
type AgentTimelineItem,
|
||||
type AgentUsage,
|
||||
type AgentRuntimeInfo,
|
||||
type ConversationRewindCut,
|
||||
type FetchCatalogOptions,
|
||||
type ImportableProviderSession,
|
||||
type ImportProviderSessionContext,
|
||||
@@ -136,6 +137,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";
|
||||
@@ -364,9 +366,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,
|
||||
@@ -3092,22 +3095,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 = this.resolveConversationRewindTarget(input.messageId);
|
||||
async revertConversation(input: { messageId: string }): Promise<ConversationRewindCut> {
|
||||
let target: ClaudeConversationRewindTarget;
|
||||
try {
|
||||
await this.ensureHistoryReady();
|
||||
target = 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> {
|
||||
@@ -3119,9 +3143,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 {
|
||||
@@ -3444,9 +3472,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" };
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ import {
|
||||
type ProviderCatalog,
|
||||
type ProviderRefreshContext,
|
||||
type ResolveAgentDefaultModeInput,
|
||||
type ConversationRewindCut,
|
||||
RewindNotAppliedError,
|
||||
} from "../agent-sdk-types.js";
|
||||
import { importSessionFromPersistence } from "../provider-session-import.js";
|
||||
import { runProviderRefreshActivity } from "../provider-refresh-deadline.js";
|
||||
@@ -4958,35 +4960,59 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
};
|
||||
}
|
||||
|
||||
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<ConversationRewindCut> {
|
||||
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;
|
||||
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;
|
||||
try {
|
||||
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;
|
||||
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");
|
||||
}
|
||||
return { kind: "at_target" };
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -210,7 +211,15 @@ test("manager publishes and saves the provider state after rewind", async () =>
|
||||
const { session } = await setupRewind();
|
||||
const { manager, agent } = await manage(session);
|
||||
try {
|
||||
// A resumed agent's conversation holds the provider history it can rewind.
|
||||
await manager.hydrateTimelineFromProvider(agent.id, { force: true });
|
||||
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());
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -39,6 +39,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";
|
||||
@@ -1159,17 +1161,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> {
|
||||
|
||||
@@ -57,6 +57,8 @@ import {
|
||||
type SteerResult,
|
||||
type ToolCallDetail,
|
||||
type ToolCallTimelineItem,
|
||||
type ConversationRewindCut,
|
||||
RewindNotAppliedError,
|
||||
} from "../agent-sdk-types.js";
|
||||
import { importSessionFromPersistence } from "../provider-session-import.js";
|
||||
import {
|
||||
@@ -3742,14 +3744,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> {
|
||||
|
||||
@@ -16,9 +16,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";
|
||||
|
||||
@@ -394,15 +396,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()];
|
||||
|
||||
@@ -40,6 +40,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";
|
||||
@@ -1573,16 +1575,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: {
|
||||
@@ -1591,6 +1602,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
});
|
||||
this.currentLeafOverrideId = targetEntry.parentId;
|
||||
this.activeToolCalls.clear();
|
||||
return { kind: "at_target" };
|
||||
}
|
||||
|
||||
private async runPiTreeExtensionCommand(targetId: string): Promise<unknown> {
|
||||
|
||||
@@ -1,21 +1,37 @@
|
||||
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 { 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 +44,487 @@ 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();
|
||||
|
||||
await manager.rewind(agentId, "message-1", "conversation");
|
||||
|
||||
expect(session.recordedRewinds).toEqual([{ mode: "conversation", messageId: "message-1" }]);
|
||||
expect(session.historyReadCount).toBe(1);
|
||||
expect(
|
||||
(await 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 = (await 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 = await 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("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 manager.reloadAgentSession(agentId, undefined, { rehydrateFromDisk: true });
|
||||
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("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 manager.reloadAgentSession(agentId, undefined, { rehydrateFromDisk: true });
|
||||
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 manager.reloadAgentSession(agentId, undefined, { rehydrateFromDisk: true });
|
||||
|
||||
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 manager.reloadAgentSession(agentId, undefined, { rehydrateFromDisk: true });
|
||||
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 manager.reloadAgentSession(agentId, undefined, { rehydrateFromDisk: true });
|
||||
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 manager.reloadAgentSession(agentId, undefined, { rehydrateFromDisk: true });
|
||||
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; it did not replay history.
|
||||
expect(session.recordedRewinds).toHaveLength(1);
|
||||
expect(session.historyReadCount).toBe(0);
|
||||
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(manager.listProviderSubagents(agentId).map((child) => child.id)).toEqual([
|
||||
"child-of-A",
|
||||
"child-of-B",
|
||||
]);
|
||||
|
||||
await manager.rewind(agentId, "B", "conversation");
|
||||
|
||||
expect(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 +543,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 until the rehydrate epoch broadcasts", async () => {
|
||||
const historyGate = new RewindHistoryGate();
|
||||
historyGate.hold();
|
||||
const { manager, agentId } = await createRewindHarness({ historyGate });
|
||||
test("blocks new prompts 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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
AgentPersistenceHandle,
|
||||
AgentProvider,
|
||||
AgentRunResult,
|
||||
AgentSession,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
import {
|
||||
RewindNotAppliedError,
|
||||
type AgentCapabilityFlags,
|
||||
type AgentPermissionRequest,
|
||||
type AgentPermissionResponse,
|
||||
type AgentPersistenceHandle,
|
||||
type AgentPromptInput,
|
||||
type AgentProvider,
|
||||
type AgentRunOptions,
|
||||
type AgentRunResult,
|
||||
type AgentSession,
|
||||
type AgentStreamEvent,
|
||||
type AgentTimelineItem,
|
||||
type ConversationRewindCut,
|
||||
type ConversationRewindResult,
|
||||
} from "../agent-sdk-types.js";
|
||||
|
||||
export const REWIND_TEST_CAPABILITIES: AgentCapabilityFlags = {
|
||||
@@ -27,14 +32,33 @@ export interface RecordedRewind {
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
/** What the next conversation rewind does to the provider history, and what it reports. */
|
||||
export type FakeRewindOutcome =
|
||||
| { kind: "applied"; cut?: ConversationRewindCut }
|
||||
| { kind: "not_applied"; message: string }
|
||||
/** The call fails; `applied` says whether the history changed anyway. */
|
||||
| { kind: "unknown"; applied: boolean; cut?: ConversationRewindCut }
|
||||
/** The call finishes without saying what it removed, as a plugin provider's does. */
|
||||
| { kind: "unreported"; applied: boolean };
|
||||
|
||||
export class FakeRewindSession implements AgentSession {
|
||||
readonly provider: AgentProvider = "claude";
|
||||
readonly id = "fake-rewind-session";
|
||||
/** The session a resume continues. A rewind that changes it is a provider forking the thread. */
|
||||
persistedSessionId = "fake-rewind-session";
|
||||
/** Set to make an applied rewind continue in a new provider session, as Claude's fork does. */
|
||||
sessionIdAfterRewind: string | null = null;
|
||||
readonly capabilities = REWIND_TEST_CAPABILITIES;
|
||||
readonly recordedRewinds: RecordedRewind[] = [];
|
||||
aborted = false;
|
||||
historyReadCount = 0;
|
||||
history: AgentTimelineItem[] = [{ type: "user_message", text: "before", messageId: "message-1" }];
|
||||
/** Leaves started turns running until interrupted, instead of answering them. */
|
||||
holdTurns = true;
|
||||
nextRewind: FakeRewindOutcome = { kind: "applied" };
|
||||
/** Prompts whose turn spawns a provider subagent through a tool call. */
|
||||
readonly subagentPrompts = new Set<string>();
|
||||
private promptCount = 0;
|
||||
|
||||
private subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
private activeTurnId: string | null = null;
|
||||
@@ -45,13 +69,60 @@ export class FakeRewindSession implements AgentSession {
|
||||
return { sessionId: this.id, finalText: "", timeline: [] };
|
||||
}
|
||||
|
||||
async startTurn(): Promise<{ turnId: string }> {
|
||||
async startTurn(
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions,
|
||||
): Promise<{ turnId: string }> {
|
||||
this.aborted = false;
|
||||
this.activeTurnId = "turn-1";
|
||||
if (this.holdTurns) {
|
||||
this.activeTurnId = "turn-1";
|
||||
queueMicrotask(() => {
|
||||
this.emit({ type: "turn_started", provider: this.provider, turnId: "turn-1" });
|
||||
});
|
||||
return { turnId: "turn-1" };
|
||||
}
|
||||
this.promptCount += 1;
|
||||
const turnId = `turn-${this.promptCount}`;
|
||||
const text = typeof prompt === "string" ? prompt : "prompt";
|
||||
const items: AgentTimelineItem[] = [
|
||||
{
|
||||
type: "user_message",
|
||||
text,
|
||||
messageId: `provider-${this.promptCount}`,
|
||||
...(options?.clientMessageId ? { clientMessageId: options.clientMessageId } : {}),
|
||||
},
|
||||
{ type: "assistant_message", text: `reply to ${text}` },
|
||||
];
|
||||
const spawnsSubagent = this.subagentPrompts.has(text);
|
||||
const callId = `call-${this.promptCount}`;
|
||||
if (spawnsSubagent) {
|
||||
items.push({
|
||||
type: "tool_call",
|
||||
callId,
|
||||
name: "Task",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "unknown", input: null, output: null },
|
||||
});
|
||||
}
|
||||
this.activeTurnId = turnId;
|
||||
queueMicrotask(() => {
|
||||
this.emit({ type: "turn_started", provider: this.provider, turnId: "turn-1" });
|
||||
this.emit({ type: "turn_started", provider: this.provider, turnId });
|
||||
for (const item of items) {
|
||||
this.history.push(item);
|
||||
this.emit({ type: "timeline", provider: this.provider, item, turnId });
|
||||
}
|
||||
if (spawnsSubagent) {
|
||||
this.emit({
|
||||
type: "provider_subagent",
|
||||
provider: this.provider,
|
||||
event: { type: "upsert", id: `child-of-${text}`, toolCallId: callId, status: "running" },
|
||||
});
|
||||
}
|
||||
this.activeTurnId = null;
|
||||
this.emit({ type: "turn_completed", provider: this.provider, turnId });
|
||||
});
|
||||
return { turnId: "turn-1" };
|
||||
return { turnId };
|
||||
}
|
||||
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
||||
@@ -91,7 +162,7 @@ export class FakeRewindSession implements AgentSession {
|
||||
): Promise<void> {}
|
||||
|
||||
describePersistence(): AgentPersistenceHandle {
|
||||
return { provider: this.provider, sessionId: this.id };
|
||||
return { provider: this.provider, sessionId: this.persistedSessionId };
|
||||
}
|
||||
|
||||
async interrupt(): Promise<void> {
|
||||
@@ -105,16 +176,47 @@ export class FakeRewindSession implements AgentSession {
|
||||
|
||||
async close(): Promise<void> {}
|
||||
|
||||
async revertConversation(input: { messageId: string }): Promise<void> {
|
||||
async revertConversation(input: { messageId: string }): Promise<ConversationRewindResult> {
|
||||
this.recordedRewinds.push({ mode: "conversation", messageId: input.messageId });
|
||||
return this.applyRewind(input.messageId);
|
||||
}
|
||||
|
||||
async revertFiles(input: { messageId: string }): Promise<void> {
|
||||
this.recordedRewinds.push({ mode: "files", messageId: input.messageId });
|
||||
}
|
||||
|
||||
async revertBoth(input: { messageId: string }): Promise<void> {
|
||||
async revertBoth(input: { messageId: string }): Promise<ConversationRewindResult> {
|
||||
this.recordedRewinds.push({ mode: "both", messageId: input.messageId });
|
||||
return this.applyRewind(input.messageId);
|
||||
}
|
||||
|
||||
private applyRewind(messageId: string): ConversationRewindResult {
|
||||
const outcome = this.nextRewind;
|
||||
this.nextRewind = { kind: "applied" };
|
||||
if (outcome.kind === "not_applied") throw new RewindNotAppliedError(outcome.message);
|
||||
const cut = (outcome.kind !== "unreported" && outcome.cut) || { kind: "at_target" };
|
||||
if (outcome.kind === "applied" || outcome.applied) {
|
||||
this.cutHistory(messageId, cut);
|
||||
if (this.sessionIdAfterRewind) this.persistedSessionId = this.sessionIdAfterRewind;
|
||||
}
|
||||
if (outcome.kind === "unknown") throw new Error("connection lost during rewind");
|
||||
return outcome.kind === "unreported" ? { kind: "unreported" } : cut;
|
||||
}
|
||||
|
||||
private cutHistory(messageId: string, cut: ConversationRewindCut): void {
|
||||
const promptIndex = (id: string) =>
|
||||
this.history.findIndex((item) => item.type === "user_message" && item.messageId === id);
|
||||
let keep: number;
|
||||
if (cut.kind === "at_target") keep = promptIndex(messageId);
|
||||
else if (cut.retainedMessageId === null) keep = 0;
|
||||
else {
|
||||
const retained = promptIndex(cut.retainedMessageId);
|
||||
const next = this.history.findIndex(
|
||||
(item, index) => index > retained && item.type === "user_message",
|
||||
);
|
||||
keep = next < 0 ? this.history.length : next;
|
||||
}
|
||||
this.history.length = keep;
|
||||
}
|
||||
|
||||
private emit(event: AgentStreamEvent): void {
|
||||
|
||||
@@ -43,6 +43,17 @@ export interface ProjectedTimelinePageSelection {
|
||||
endSeq: number | null;
|
||||
hasOlder: boolean;
|
||||
hasNewer: boolean;
|
||||
/** Retirements whose seq lies in the page's span, coalesced; see `TimelineProjection.retire`. */
|
||||
retirements: TimelineRetirement[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A rewind's record: the rows anchored in `[retireFromSeq, seq)` left the active conversation.
|
||||
* `seq` is the retirement's own position in the change sequence.
|
||||
*/
|
||||
export interface TimelineRetirement {
|
||||
seq: number;
|
||||
retireFromSeq: number;
|
||||
}
|
||||
|
||||
function appendSeqToRanges(ranges: TimelineSeqRange[], seq: number): TimelineSeqRange[] {
|
||||
@@ -312,7 +323,12 @@ export class TimelineProjection {
|
||||
// span must include its first anchor to cover interleaved lifecycle updates.
|
||||
private readonly spans: { start: number; endSeq: number }[] = [];
|
||||
|
||||
// Every source seq in order. `rowIndex` -1 marks seqs covered without an active row: the rows a
|
||||
// rewind retired, and each retirement's own seq. Forward pages advance through them.
|
||||
private readonly sourceRanges: { startSeq: number; endSeq: number; rowIndex: number }[] = [];
|
||||
private readonly retirements: TimelineRetirement[] = [];
|
||||
private readonly retiredIdentities = new Set<string>();
|
||||
private readonly retiredTurnIds = new Set<string>();
|
||||
|
||||
constructor(rows: readonly AgentTimelineRow[] = []) {
|
||||
for (const row of rows) this.appendProjected(row);
|
||||
@@ -392,11 +408,126 @@ export class TimelineProjection {
|
||||
return this.rows.map((row) => ({ ...row }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a new source row belongs to retired history: late output of a turn with no active
|
||||
* rows left, or a late update to a retired item. An update to an active item is never retired.
|
||||
*/
|
||||
isRetired(row: Pick<AgentTimelineRow, "item" | "turnId">): boolean {
|
||||
if (row.turnId !== undefined && this.retiredTurnIds.has(row.turnId)) return true;
|
||||
const identity = timelineItemIdentity(row.item);
|
||||
if (identity === null || this.identities.has(identity)) return false;
|
||||
return this.retiredIdentities.has(identity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retires the active rows anchored at or after `retireFromSeq`. Rows are in anchor order, so they
|
||||
* form the display suffix. Their source seqs and the retirement's own seq stay covered.
|
||||
*/
|
||||
retire(retirement: TimelineRetirement): ProjectedTimelineRow[] {
|
||||
const cut = this.firstRowAnchoredAtOrAfter(retirement.retireFromSeq);
|
||||
const retired = this.rows.splice(cut);
|
||||
const activeTurnIds = new Set(this.rows.map((row) => row.turnId));
|
||||
for (const row of retired) {
|
||||
const identity = timelineItemIdentity(row.item);
|
||||
if (identity !== null) this.retiredIdentities.add(identity);
|
||||
if (row.turnId !== undefined && !activeTurnIds.has(row.turnId)) {
|
||||
this.retiredTurnIds.add(row.turnId);
|
||||
}
|
||||
}
|
||||
for (const [identity, index] of this.identities) {
|
||||
if (index >= cut) this.identities.delete(identity);
|
||||
}
|
||||
while (this.spans.length > 0 && this.spans[this.spans.length - 1].start >= cut)
|
||||
this.spans.pop();
|
||||
const lastSpan = this.spans.at(-1);
|
||||
if (lastSpan) {
|
||||
lastSpan.endSeq = 0;
|
||||
for (let index = lastSpan.start; index < cut; index++) {
|
||||
lastSpan.endSeq = Math.max(lastSpan.endSeq, this.rows[index].seqEnd);
|
||||
}
|
||||
}
|
||||
for (const range of this.sourceRanges) {
|
||||
if (range.rowIndex >= cut) range.rowIndex = -1;
|
||||
}
|
||||
this.sourceRanges.push({ startSeq: retirement.seq, endSeq: retirement.seq, rowIndex: -1 });
|
||||
this.retirements.push({ ...retirement });
|
||||
return retired;
|
||||
}
|
||||
|
||||
private firstRowAnchoredAtOrAfter(seq: number): number {
|
||||
let low = 0;
|
||||
let high = this.rows.length;
|
||||
while (low < high) {
|
||||
const middle = (low + high) >>> 1;
|
||||
if (this.rows[middle].seqStart < seq) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retirements with seq in `[startSeq, endSeq]`, merging neighbours that no active row separates.
|
||||
* Merging keeps the meaning (drop rows anchored in `[retireFromSeq, seq)`) and bounds the count
|
||||
* by the active rows in the span, so a page never carries an unbounded list.
|
||||
*/
|
||||
private retirementsInSpan(startSeq: number, endSeq: number): TimelineRetirement[] {
|
||||
let low = 0;
|
||||
let high = this.retirements.length;
|
||||
while (low < high) {
|
||||
const middle = (low + high) >>> 1;
|
||||
if (this.retirements[middle].seq < startSeq) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
const inSpan: TimelineRetirement[] = [];
|
||||
for (let index = low; index < this.retirements.length; index++) {
|
||||
const retirement = this.retirements[index];
|
||||
if (retirement.seq > endSeq) break;
|
||||
inSpan.push(retirement);
|
||||
}
|
||||
return this.coalesceRetirements(inSpan);
|
||||
}
|
||||
|
||||
private coalesceRetirements(retirements: readonly TimelineRetirement[]): TimelineRetirement[] {
|
||||
const coalesced: TimelineRetirement[] = [];
|
||||
for (const retirement of retirements) {
|
||||
const previous = coalesced.at(-1);
|
||||
if (previous && !this.hasActiveAnchorIn(previous.seq, retirement.retireFromSeq)) {
|
||||
coalesced[coalesced.length - 1] = {
|
||||
seq: retirement.seq,
|
||||
retireFromSeq: Math.min(previous.retireFromSeq, retirement.retireFromSeq),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
coalesced.push({ ...retirement });
|
||||
}
|
||||
return coalesced;
|
||||
}
|
||||
|
||||
private hasActiveAnchorIn(startSeq: number, endSeq: number): boolean {
|
||||
const index = this.firstRowAnchoredAtOrAfter(startSeq);
|
||||
return index < this.rows.length && this.rows[index].seqStart < endSeq;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.rows.length;
|
||||
}
|
||||
|
||||
selectPage(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection {
|
||||
if (this.rows.length === 0 && this.sourceRanges.length > 0) {
|
||||
// Every row is retired. Forward pages still advance; a tail covers the whole window so a
|
||||
// client can apply the retirements to what it holds.
|
||||
if (input.direction === "after") return this.selectAfter(input);
|
||||
if (input.direction === "tail") {
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: input.bounds.minSeq,
|
||||
endSeq: input.bounds.maxSeq,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
retirements: this.retirementsInSpan(input.bounds.minSeq, input.bounds.maxSeq),
|
||||
};
|
||||
}
|
||||
}
|
||||
if (this.rows.length === 0) {
|
||||
const cursor =
|
||||
input.cursorSeq ??
|
||||
@@ -413,6 +544,7 @@ export class TimelineProjection {
|
||||
input.direction === "after"
|
||||
? cursor < input.bounds.maxSeq
|
||||
: input.direction === "before" && cursor <= input.bounds.maxSeq,
|
||||
retirements: [],
|
||||
};
|
||||
}
|
||||
switch (input.direction) {
|
||||
@@ -436,6 +568,7 @@ export class TimelineProjection {
|
||||
endSeq: input.bounds.maxSeq,
|
||||
hasOlder: start > 0,
|
||||
hasNewer: false,
|
||||
retirements: this.retirementsInSpan(this.rows[start].seqStart, input.bounds.maxSeq),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -463,29 +596,59 @@ export class TimelineProjection {
|
||||
else high = middle;
|
||||
}
|
||||
const indexes = new Set<number>();
|
||||
const covered: TimelineSeqRange[] = [];
|
||||
const limit = Math.max(0, Math.floor(input.limit ?? 0));
|
||||
let retirementIndex = this.firstRetirementAtOrAfter(startSeq);
|
||||
const retirements: TimelineRetirement[] = [];
|
||||
for (let i = low; i < this.sourceRanges.length; i++) {
|
||||
const range = this.sourceRanges[i];
|
||||
if (range.startSeq > input.bounds.maxSeq) break;
|
||||
indexes.add(range.rowIndex);
|
||||
if (limit > 0 && indexes.size === limit) break;
|
||||
if (range.rowIndex >= 0) {
|
||||
indexes.add(range.rowIndex);
|
||||
} else {
|
||||
covered.push({ startSeq: Math.max(range.startSeq, startSeq), endSeq: range.endSeq });
|
||||
const retirement = this.retirements[retirementIndex];
|
||||
if (retirement && retirement.seq === range.startSeq) {
|
||||
retirements.push(retirement);
|
||||
retirementIndex++;
|
||||
}
|
||||
}
|
||||
// Each retirement counts toward the limit, so a page stays bounded however many it crosses.
|
||||
if (limit > 0 && indexes.size + retirements.length >= limit) break;
|
||||
}
|
||||
const entries = [...indexes].sort((a, b) => a - b).map((index) => this.rows[index]);
|
||||
const selected = selectProjectedEntriesAfter({
|
||||
entries,
|
||||
covered,
|
||||
startSeq,
|
||||
maxSeq: input.bounds.maxSeq,
|
||||
limit,
|
||||
});
|
||||
const endSeq = selected.endSeq;
|
||||
return {
|
||||
entries: selected.entries,
|
||||
startSeq: selected.endSeq === null ? null : startSeq,
|
||||
endSeq: selected.endSeq,
|
||||
startSeq: endSeq === null ? null : startSeq,
|
||||
endSeq,
|
||||
hasOlder: startSeq > input.bounds.minSeq,
|
||||
hasNewer: selected.endSeq !== null && selected.endSeq < input.bounds.maxSeq,
|
||||
hasNewer: endSeq !== null && endSeq < input.bounds.maxSeq,
|
||||
retirements:
|
||||
endSeq === null
|
||||
? []
|
||||
: this.coalesceRetirements(retirements.filter((retirement) => retirement.seq <= endSeq)),
|
||||
};
|
||||
}
|
||||
|
||||
private firstRetirementAtOrAfter(seq: number): number {
|
||||
let low = 0;
|
||||
let high = this.retirements.length;
|
||||
while (low < high) {
|
||||
const middle = (low + high) >>> 1;
|
||||
if (this.retirements[middle].seq < seq) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
private selectBefore(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection {
|
||||
const endSeq = Math.min(input.bounds.maxSeq, (input.cursorSeq ?? input.bounds.maxSeq + 1) - 1);
|
||||
if (endSeq >= input.bounds.minSeq) {
|
||||
@@ -508,20 +671,29 @@ export class TimelineProjection {
|
||||
let selectedEnd = endSeq;
|
||||
if (input.wholeTurns)
|
||||
selectedEnd = low === this.rows.length ? input.bounds.maxSeq : this.rows[low].seqStart - 1;
|
||||
const pageStart = this.rows[start]?.seqStart ?? null;
|
||||
return {
|
||||
entries: this.rows.slice(start, low),
|
||||
startSeq: this.rows[start]?.seqStart ?? null,
|
||||
startSeq: pageStart,
|
||||
endSeq: selectedEnd,
|
||||
hasOlder: start > 0,
|
||||
hasNewer: selectedEnd < input.bounds.maxSeq,
|
||||
retirements: pageStart === null ? [] : this.retirementsInSpan(pageStart, selectedEnd),
|
||||
};
|
||||
}
|
||||
return { entries: [], startSeq: null, endSeq: null, hasOlder: false, hasNewer: true };
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: false,
|
||||
hasNewer: true,
|
||||
retirements: [],
|
||||
};
|
||||
}
|
||||
|
||||
getLastItem(seq: number): AgentTimelineItem | null {
|
||||
const last = this.sourceRanges.at(-1);
|
||||
return last?.endSeq === seq ? this.rows[last.rowIndex].item : null;
|
||||
return last?.endSeq === seq && last.rowIndex >= 0 ? this.rows[last.rowIndex].item : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -677,6 +849,8 @@ interface ProjectedEntryCandidate {
|
||||
|
||||
function selectProjectedEntriesAfter(input: {
|
||||
entries: readonly TimelineProjectionEntry[];
|
||||
/** Seqs covered without an active row; they advance the cursor but add no entry. */
|
||||
covered?: readonly TimelineSeqRange[];
|
||||
startSeq: number;
|
||||
maxSeq: number;
|
||||
limit: number;
|
||||
@@ -693,11 +867,13 @@ function selectProjectedEntriesAfter(input: {
|
||||
const selectedEntries = selected
|
||||
.sort((left, right) => left.index - right.index)
|
||||
.map((candidate) => candidate.entry);
|
||||
if (selectedEntries.length === 0) return { entries: [], endSeq: null };
|
||||
const covered = input.covered ?? [];
|
||||
if (selectedEntries.length === 0 && covered.length === 0) return { entries: [], endSeq: null };
|
||||
|
||||
const selectedRanges = selectedEntries
|
||||
.flatMap((entry) => entry.sourceSeqRanges)
|
||||
.sort((left, right) => left.startSeq - right.startSeq || left.endSeq - right.endSeq);
|
||||
const selectedRanges = [
|
||||
...selectedEntries.flatMap((entry) => entry.sourceSeqRanges),
|
||||
...covered,
|
||||
].sort((left, right) => left.startSeq - right.startSeq || left.endSeq - right.endSeq);
|
||||
// Wide projected entries can include future, discontiguous source ranges. The
|
||||
// page cursor advances only through source rows covered without a gap.
|
||||
let endSeq = input.startSeq - 1;
|
||||
|
||||
@@ -2,12 +2,22 @@ import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentTimelineItem } from "../agent-sdk-types.js";
|
||||
import type { AgentTimelineItem, ConversationRewindCut } from "../agent-sdk-types.js";
|
||||
import { limitAgentTimelineItemContent } from "../agent-timeline-content.js";
|
||||
import type { AgentTimelineFetchResult, AgentTimelineRow } from "../agent-timeline-store-types.js";
|
||||
import { DeferredTimelineBackend } from "./testing.js";
|
||||
import { selectReferenceProjectedTimelinePage } from "../test-utils/timeline-page-reference.js";
|
||||
import { AgentTimeline, type TimelineCommit, type TimelineScope } from "./index.js";
|
||||
import {
|
||||
AgentTimeline,
|
||||
TimelineRewindBoundaryError,
|
||||
TimelineRewindPendingError,
|
||||
TimelineRewindTargetError,
|
||||
type TimelineCommit,
|
||||
type TimelineEntry,
|
||||
type TimelineRetirementCommit,
|
||||
type TimelineRewindCompletion,
|
||||
type TimelineScope,
|
||||
} from "./index.js";
|
||||
import { UnknownTimelineError } from "./internal/backend.js";
|
||||
|
||||
const root = (agentId: string): TimelineScope => ({ agentId });
|
||||
@@ -189,7 +199,12 @@ describe.each([
|
||||
]);
|
||||
await timeline.delete(child);
|
||||
expect(await timeline.describe(child)).toBeNull();
|
||||
expect(await timeline.describe(root("a"))).toEqual({ epoch: "parent", size: 1 });
|
||||
expect(await timeline.describe(root("a"))).toEqual({
|
||||
epoch: "parent",
|
||||
size: 1,
|
||||
retirementCount: 0,
|
||||
rewind: null,
|
||||
});
|
||||
await expect(timeline.readRows(child)).rejects.toBeInstanceOf(UnknownTimelineError);
|
||||
});
|
||||
|
||||
@@ -657,3 +672,365 @@ it("continues a scroll across appends and lifecycle collapse without losing sour
|
||||
expect(last.endSeq).toBe(7);
|
||||
expect(last.hasNewer).toBe(false);
|
||||
});
|
||||
|
||||
describe("conversation rewind", () => {
|
||||
const scope = root("agent");
|
||||
const createBackend = () => new DeferredTimelineBackend();
|
||||
|
||||
function prompt(messageId: string, turnId: string): TimelineEntry {
|
||||
return { item: { type: "user_message", text: messageId, messageId }, turnId };
|
||||
}
|
||||
|
||||
function reply(text: string, turnId: string): TimelineEntry {
|
||||
return { item: { type: "assistant_message", text, messageId: `reply-${text}` }, turnId };
|
||||
}
|
||||
|
||||
function tool(callId: string, turnId: string, status: "running" | "completed"): TimelineEntry {
|
||||
return {
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId,
|
||||
name: "shell",
|
||||
status,
|
||||
error: null,
|
||||
detail: { type: "unknown", input: null, output: null },
|
||||
},
|
||||
turnId,
|
||||
};
|
||||
}
|
||||
|
||||
async function conversation(): Promise<{
|
||||
backend: DeferredTimelineBackend;
|
||||
timeline: AgentTimeline;
|
||||
}> {
|
||||
const backend = createBackend();
|
||||
const timeline = new AgentTimeline(backend);
|
||||
await timeline.create(scope, { epoch: "e" });
|
||||
await timeline.append(scope, [
|
||||
prompt("a", "t1"),
|
||||
reply("A", "t1"),
|
||||
prompt("b", "t2"),
|
||||
tool("call-b", "t2", "running"),
|
||||
reply("B", "t2"),
|
||||
]);
|
||||
return { backend, timeline };
|
||||
}
|
||||
|
||||
async function rewind(
|
||||
timeline: AgentTimeline,
|
||||
messageId: string,
|
||||
cut: ConversationRewindCut = { kind: "at_target" },
|
||||
): Promise<TimelineRewindCompletion> {
|
||||
const intent = await timeline.beginRewind(scope, messageId);
|
||||
await timeline.recordRewindCut(scope, intent.id, cut);
|
||||
return timeline.completeRewind(scope, intent.id);
|
||||
}
|
||||
|
||||
function texts(rows: readonly { item: AgentTimelineItem }[]): string[] {
|
||||
return rows.map((row) => ("text" in row.item ? row.item.text : row.item.type));
|
||||
}
|
||||
|
||||
it("retires the rewound turn from active history and keeps it stored", async () => {
|
||||
const { backend, timeline } = await conversation();
|
||||
const published: TimelineRetirementCommit[] = [];
|
||||
const intent = await timeline.beginRewind(scope, "b");
|
||||
await timeline.recordRewindCut(scope, intent.id, { kind: "at_target" });
|
||||
const completion = await timeline.completeRewind(scope, intent.id, {
|
||||
publish: (commit) => published.push(commit),
|
||||
});
|
||||
await timeline.append(scope, [prompt("c", "t3"), reply("C", "t3")]);
|
||||
|
||||
expect(completion).toMatchObject({
|
||||
kind: "retired",
|
||||
retirement: { seq: 6, retireFromSeq: 3 },
|
||||
epoch: "e",
|
||||
retirementCount: 1,
|
||||
});
|
||||
expect(published.map((commit) => texts(commit.retiredRows))).toEqual([["b", "tool_call", "B"]]);
|
||||
expect(texts(await timeline.readRows(scope))).toEqual(["a", "A", "c", "C"]);
|
||||
expect(texts(backend.retiredRows(scope))).toEqual(["b", "tool_call", "B"]);
|
||||
expect(await timeline.describe(scope)).toEqual({
|
||||
epoch: "e",
|
||||
size: 4,
|
||||
retirementCount: 1,
|
||||
rewind: null,
|
||||
});
|
||||
const tail = await timeline.fetch(scope);
|
||||
expect(texts(tail.rows)).toEqual(["a", "A", "c", "C"]);
|
||||
expect(tail).toMatchObject({
|
||||
epoch: "e",
|
||||
startSeq: 1,
|
||||
endSeq: 8,
|
||||
retirements: [{ seq: 6, retireFromSeq: 3 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("advances a caught-up cursor through the retirement to the next turn", async () => {
|
||||
const { timeline } = await conversation();
|
||||
await rewind(timeline, "b");
|
||||
await timeline.append(scope, [prompt("c", "t3"), reply("C", "t3")]);
|
||||
|
||||
const catchUp = await timeline.fetch(scope, {
|
||||
direction: "after",
|
||||
cursor: { epoch: "e", seq: 5 },
|
||||
});
|
||||
expect(texts(catchUp.rows)).toEqual(["c", "C"]);
|
||||
expect(catchUp).toMatchObject({
|
||||
reset: false,
|
||||
startSeq: 6,
|
||||
endSeq: 8,
|
||||
hasNewer: false,
|
||||
retirements: [{ seq: 6, retireFromSeq: 3 }],
|
||||
});
|
||||
|
||||
const retirementOnly = await timeline.fetch(scope, {
|
||||
direction: "after",
|
||||
cursor: { epoch: "e", seq: 5 },
|
||||
limit: 1,
|
||||
});
|
||||
expect(retirementOnly).toMatchObject({
|
||||
rows: [],
|
||||
startSeq: 6,
|
||||
endSeq: 6,
|
||||
hasNewer: true,
|
||||
retirements: [{ seq: 6, retireFromSeq: 3 }],
|
||||
});
|
||||
const next = await timeline.fetch(scope, {
|
||||
direction: "after",
|
||||
cursor: { epoch: "e", seq: 6 },
|
||||
});
|
||||
expect(texts(next.rows)).toEqual(["c", "C"]);
|
||||
expect(next.retirements).toEqual([]);
|
||||
});
|
||||
|
||||
it("pages a tail whose last turn was rewound with nothing after it", async () => {
|
||||
const { timeline } = await conversation();
|
||||
await rewind(timeline, "b");
|
||||
|
||||
const tail = await timeline.fetch(scope);
|
||||
expect(texts(tail.rows)).toEqual(["a", "A"]);
|
||||
expect(tail).toMatchObject({ endSeq: 6, retirements: [{ seq: 6, retireFromSeq: 3 }] });
|
||||
const caughtUp = await timeline.fetch(scope, {
|
||||
direction: "after",
|
||||
cursor: { epoch: "e", seq: 2 },
|
||||
});
|
||||
expect(caughtUp).toMatchObject({
|
||||
rows: [],
|
||||
endSeq: 6,
|
||||
hasNewer: false,
|
||||
retirements: [{ seq: 6, retireFromSeq: 3 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("cannot retire a later turn by completing the same rewind again", async () => {
|
||||
const { timeline } = await conversation();
|
||||
const intent = await timeline.beginRewind(scope, "b");
|
||||
await timeline.recordRewindCut(scope, intent.id, { kind: "at_target" });
|
||||
await timeline.completeRewind(scope, intent.id);
|
||||
await timeline.append(scope, [prompt("c", "t3"), reply("C", "t3")]);
|
||||
|
||||
expect(await timeline.completeRewind(scope, intent.id)).toEqual({ kind: "absent" });
|
||||
expect(await timeline.recordRewindCut(scope, intent.id, { kind: "at_target" })).toBeNull();
|
||||
expect(texts(await timeline.readRows(scope))).toEqual(["a", "A", "c", "C"]);
|
||||
expect((await timeline.describe(scope))?.retirementCount).toBe(1);
|
||||
});
|
||||
|
||||
it("drops late output of a retired turn and still accepts updates to active items", async () => {
|
||||
const timeline = new AgentTimeline(createBackend());
|
||||
await timeline.create(scope, { epoch: "e" });
|
||||
await timeline.append(scope, [
|
||||
prompt("a", "t1"),
|
||||
tool("call-a", "t1", "running"),
|
||||
prompt("b", "t2"),
|
||||
tool("call-b", "t2", "running"),
|
||||
]);
|
||||
await rewind(timeline, "b");
|
||||
|
||||
const late = await timeline.ingest(scope, tool("call-b", "t2", "completed"));
|
||||
const lateText = await timeline.ingest(scope, reply("B again", "t2"));
|
||||
const reusedCallId = await timeline.ingest(scope, tool("call-a", "t2", "completed"));
|
||||
const active = await timeline.ingest(scope, tool("call-a", "t1", "completed"));
|
||||
|
||||
expect([late, lateText, reusedCallId].map((result) => result.kind)).toEqual([
|
||||
"dropped",
|
||||
"dropped",
|
||||
"dropped",
|
||||
]);
|
||||
expect(active.kind).toBe("recorded");
|
||||
const rows = await timeline.readRows(scope);
|
||||
expect(rows.map((row) => row.item)).toEqual([
|
||||
{ type: "user_message", text: "a", messageId: "a" },
|
||||
expect.objectContaining({ type: "tool_call", callId: "call-a", status: "completed" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("allows one open rewind at a time and leaves history alone when abandoned", async () => {
|
||||
const { timeline } = await conversation();
|
||||
const intent = await timeline.beginRewind(scope, "b");
|
||||
|
||||
await expect(timeline.beginRewind(scope, "a")).rejects.toBeInstanceOf(
|
||||
TimelineRewindPendingError,
|
||||
);
|
||||
await expect(timeline.completeRewind(scope, intent.id)).rejects.toThrow(
|
||||
"Cannot complete a rewind before its provider outcome",
|
||||
);
|
||||
await timeline.abandonRewind(scope, intent.id);
|
||||
expect((await timeline.describe(scope))?.rewind).toBeNull();
|
||||
expect(texts(await timeline.readRows(scope))).toEqual(["a", "A", "b", "tool_call", "B"]);
|
||||
await expect(timeline.beginRewind(scope, "missing")).rejects.toBeInstanceOf(
|
||||
TimelineRewindTargetError,
|
||||
);
|
||||
expect((await timeline.beginRewind(scope, "a")).targetSeq).toBe(1);
|
||||
});
|
||||
|
||||
it("retires from the boundary the provider actually kept", async () => {
|
||||
const { timeline } = await conversation();
|
||||
await timeline.append(scope, [prompt("c", "t3"), reply("C", "t3")]);
|
||||
|
||||
const completion = await rewind(timeline, "c", { kind: "after", retainedMessageId: "a" });
|
||||
|
||||
expect(completion).toMatchObject({ kind: "retired", retirement: { retireFromSeq: 3 } });
|
||||
expect(texts(await timeline.readRows(scope))).toEqual(["a", "A"]);
|
||||
});
|
||||
|
||||
it("matches the retained boundary by the provider's message id", async () => {
|
||||
const backend = createBackend();
|
||||
const timeline = new AgentTimeline(backend);
|
||||
await timeline.create(scope, { epoch: "e" });
|
||||
await timeline.ingest(scope, {
|
||||
item: { type: "user_message", text: "a", messageId: "a", clientMessageId: "client-a" },
|
||||
});
|
||||
await timeline.attachProviderMessageId(scope, "client-a", "provider-a");
|
||||
await timeline.append(scope, [reply("A", "t1"), prompt("b", "t2"), prompt("c", "t3")]);
|
||||
|
||||
await rewind(timeline, "c", { kind: "after", retainedMessageId: "provider-a" });
|
||||
expect(texts(await timeline.readRows(scope))).toEqual(["a", "A"]);
|
||||
|
||||
await rewind(timeline, "a", { kind: "after", retainedMessageId: null });
|
||||
expect(await timeline.readRows(scope)).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the rewind open when the provider's boundary is after the target", async () => {
|
||||
const { timeline } = await conversation();
|
||||
const intent = await timeline.beginRewind(scope, "a");
|
||||
await timeline.recordRewindCut(scope, intent.id, { kind: "after", retainedMessageId: "a" });
|
||||
|
||||
await expect(timeline.completeRewind(scope, intent.id)).rejects.toBeInstanceOf(
|
||||
TimelineRewindBoundaryError,
|
||||
);
|
||||
expect((await timeline.describe(scope))?.rewind).toMatchObject({ id: intent.id });
|
||||
expect(texts(await timeline.readRows(scope))).toEqual(["a", "A", "b", "tool_call", "B"]);
|
||||
});
|
||||
|
||||
it("keeps active history and the open rewind when the retirement fails to commit", async () => {
|
||||
const { backend, timeline } = await conversation();
|
||||
const intent = await timeline.beginRewind(scope, "b");
|
||||
await timeline.recordRewindCut(scope, intent.id, { kind: "at_target" });
|
||||
backend.failNextRetire = true;
|
||||
|
||||
await expect(timeline.completeRewind(scope, intent.id)).rejects.toThrow("disk full");
|
||||
expect(texts(await timeline.readRows(scope))).toEqual(["a", "A", "b", "tool_call", "B"]);
|
||||
expect(await timeline.describe(scope)).toMatchObject({ retirementCount: 0 });
|
||||
|
||||
expect(await timeline.completeRewind(scope, intent.id)).toMatchObject({
|
||||
kind: "retired",
|
||||
retirement: { seq: 6, retireFromSeq: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it("judges an unsettled rewind from the provider's own history", async () => {
|
||||
const { timeline } = await conversation();
|
||||
await timeline.append(scope, [prompt("c", "t3"), reply("C", "t3")]);
|
||||
const intent = await timeline.beginRewind(scope, "b");
|
||||
|
||||
expect(await timeline.judgeRewind(scope, intent.id, ["a", "b", "c"])).toEqual({
|
||||
kind: "unchanged",
|
||||
});
|
||||
expect(await timeline.judgeRewind(scope, intent.id, ["a"])).toEqual({
|
||||
kind: "applied",
|
||||
cut: { kind: "after", retainedMessageId: "a" },
|
||||
});
|
||||
expect(await timeline.judgeRewind(scope, intent.id, ["a", "b"])).toMatchObject({
|
||||
kind: "unknown",
|
||||
reason: "The provider history ends after the rewind target",
|
||||
});
|
||||
expect(await timeline.judgeRewind(scope, intent.id, ["a", "x"])).toMatchObject({
|
||||
kind: "unknown",
|
||||
reason: "The provider history differs from Paseo's",
|
||||
});
|
||||
expect(await timeline.judgeRewind(scope, intent.id, [])).toMatchObject({
|
||||
kind: "unknown",
|
||||
reason: "The provider history has no prompts",
|
||||
});
|
||||
expect(await timeline.judgeRewind(scope, "other", ["a"])).toEqual({ kind: "absent" });
|
||||
expect(texts(await timeline.readRows(scope))).toEqual([
|
||||
"a",
|
||||
"A",
|
||||
"b",
|
||||
"tool_call",
|
||||
"B",
|
||||
"c",
|
||||
"C",
|
||||
]);
|
||||
expect((await timeline.describe(scope))?.rewind).toMatchObject({ id: intent.id });
|
||||
});
|
||||
|
||||
it("judges against provider ids and skips prompts the provider never acknowledged", async () => {
|
||||
const timeline = new AgentTimeline(createBackend());
|
||||
await timeline.create(scope, { epoch: "e" });
|
||||
for (const id of ["a", "b", "c"]) {
|
||||
await timeline.ingest(scope, {
|
||||
item: { type: "user_message", text: id, messageId: id, clientMessageId: id },
|
||||
});
|
||||
if (id !== "b") await timeline.attachProviderMessageId(scope, id, `provider-${id}`);
|
||||
}
|
||||
const intent = await timeline.beginRewind(scope, "c");
|
||||
|
||||
expect(await timeline.judgeRewind(scope, intent.id, ["provider-a", "provider-c"])).toEqual({
|
||||
kind: "unchanged",
|
||||
});
|
||||
const verdict = await timeline.judgeRewind(scope, intent.id, ["provider-a"]);
|
||||
expect(verdict).toEqual({
|
||||
kind: "applied",
|
||||
cut: { kind: "after", retainedMessageId: "provider-a" },
|
||||
});
|
||||
if (verdict.kind !== "applied") throw new Error("expected an applied verdict");
|
||||
await timeline.recordRewindCut(scope, intent.id, verdict.cut);
|
||||
await timeline.completeRewind(scope, intent.id);
|
||||
expect(texts(await timeline.readRows(scope))).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("bounds pages across a thousand rewinds", async () => {
|
||||
const backend = createBackend();
|
||||
const timeline = new AgentTimeline(backend);
|
||||
await timeline.create(scope, { epoch: "e" });
|
||||
await timeline.append(scope, [prompt("kept", "t0"), reply("kept", "t0")]);
|
||||
for (let index = 0; index < 1000; index++) {
|
||||
await timeline.append(scope, [prompt(`p${index}`, `t${index + 1}`)]);
|
||||
await rewind(timeline, `p${index}`);
|
||||
}
|
||||
|
||||
const tail = await timeline.fetch(scope, { limit: 50 });
|
||||
expect(texts(tail.rows)).toEqual(["kept", "kept"]);
|
||||
expect(tail.retirements).toEqual([{ seq: 2002, retireFromSeq: 3 }]);
|
||||
|
||||
let cursor = 2;
|
||||
let pages = 0;
|
||||
for (;;) {
|
||||
const page = await timeline.fetch(scope, {
|
||||
direction: "after",
|
||||
cursor: { epoch: "e", seq: cursor },
|
||||
limit: 50,
|
||||
});
|
||||
pages += 1;
|
||||
expect(page.rows).toEqual([]);
|
||||
expect(page.retirements.length).toBe(1);
|
||||
if (page.endSeq === null) throw new Error("after-page lost its cursor");
|
||||
cursor = page.endSeq;
|
||||
if (!page.hasNewer) break;
|
||||
}
|
||||
expect(cursor).toBe(2002);
|
||||
// Each page covers 50 prompts and their 50 retirements.
|
||||
expect(pages).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
export {
|
||||
AgentTimeline,
|
||||
TimelineRewindBoundaryError,
|
||||
TimelineRewindPendingError,
|
||||
TimelineRewindTargetError,
|
||||
type TimelineAppendResult,
|
||||
type TimelineCommit,
|
||||
type TimelineEntry,
|
||||
type TimelineHead,
|
||||
type TimelineIngestResult,
|
||||
type TimelineRetirement,
|
||||
type TimelineRetirementCommit,
|
||||
type TimelineRewindCompletion,
|
||||
type TimelineRewindIntent,
|
||||
type TimelineRewindVerdict,
|
||||
type TimelineScope,
|
||||
type TimelineSeed,
|
||||
} from "./internal/agent-timeline.js";
|
||||
|
||||
@@ -1,37 +1,101 @@
|
||||
import type { AgentTimelineItem } from "../../agent-sdk-types.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { AgentTimelineItem, ConversationRewindCut } from "../../agent-sdk-types.js";
|
||||
import { limitAgentTimelineItemContent } from "../../agent-timeline-content.js";
|
||||
import type {
|
||||
AgentTimelineFetchOptions,
|
||||
AgentTimelineFetchResult,
|
||||
AgentTimelineRow,
|
||||
} from "../../agent-timeline-store-types.js";
|
||||
import type { ProjectedTimelineRow } from "../../timeline-projection.js";
|
||||
import type { ProjectedTimelineRow, TimelineRetirement } from "../../timeline-projection.js";
|
||||
import type {
|
||||
AgentTimelineBackend,
|
||||
TimelineEntry,
|
||||
TimelineHead,
|
||||
TimelinePrompt,
|
||||
TimelineRewindIntent,
|
||||
TimelineScope,
|
||||
TimelineSeed,
|
||||
} from "./backend.js";
|
||||
import { UnknownTimelineError } from "./backend.js";
|
||||
import { MemoryTimelineBackend } from "./memory-backend.js";
|
||||
|
||||
export type { TimelineEntry, TimelineHead, TimelineScope, TimelineSeed } from "./backend.js";
|
||||
export type {
|
||||
TimelineEntry,
|
||||
TimelineHead,
|
||||
TimelineRewindIntent,
|
||||
TimelineScope,
|
||||
TimelineSeed,
|
||||
} from "./backend.js";
|
||||
export type { TimelineRetirement } from "../../timeline-projection.js";
|
||||
|
||||
export interface TimelineCommit {
|
||||
row: AgentTimelineRow;
|
||||
epoch: string;
|
||||
/** Rewinds applied to this history so far. */
|
||||
retirementCount: number;
|
||||
}
|
||||
|
||||
export interface TimelineRetirementCommit {
|
||||
retirement: TimelineRetirement;
|
||||
/** The rows that left the active conversation. They stay stored. */
|
||||
retiredRows: ProjectedTimelineRow[];
|
||||
epoch: string;
|
||||
retirementCount: number;
|
||||
}
|
||||
|
||||
export type TimelineRewindCompletion =
|
||||
| ({ kind: "retired" } & TimelineRetirementCommit)
|
||||
/** Committed, but `publish` threw; see `TimelineIngestResult`. */
|
||||
| ({ kind: "unpublished"; error: unknown } & TimelineRetirementCommit)
|
||||
/** No rewind with this id is open: it completed or was abandoned. Nothing changed. */
|
||||
| { kind: "absent" };
|
||||
|
||||
/**
|
||||
* What the provider's own history says about an open rewind, judged against the active prompts.
|
||||
* `unknown` carries the reason the evidence does not decide it.
|
||||
*/
|
||||
export type TimelineRewindVerdict =
|
||||
| { kind: "unchanged" }
|
||||
| { kind: "applied"; cut: ConversationRewindCut }
|
||||
| { kind: "unknown"; reason: string }
|
||||
/** No rewind with this id is open. */
|
||||
| { kind: "absent" };
|
||||
|
||||
/** Another rewind is open on the history; it must settle first. */
|
||||
export class TimelineRewindPendingError extends Error {
|
||||
constructor(readonly intent: TimelineRewindIntent) {
|
||||
super("Another rewind of this conversation has not settled");
|
||||
this.name = "TimelineRewindPendingError";
|
||||
}
|
||||
}
|
||||
|
||||
/** The rewind target is not an active prompt: it never existed or a rewind already retired it. */
|
||||
export class TimelineRewindTargetError extends Error {
|
||||
constructor(readonly messageId: string) {
|
||||
super("The message to rewind to is no longer in the conversation");
|
||||
this.name = "TimelineRewindTargetError";
|
||||
}
|
||||
}
|
||||
|
||||
/** The provider's cut does not resolve to a prompt at or before the target. */
|
||||
export class TimelineRewindBoundaryError extends Error {
|
||||
constructor(readonly intent: TimelineRewindIntent) {
|
||||
super("The provider's rewind boundary does not match this conversation");
|
||||
this.name = "TimelineRewindBoundaryError";
|
||||
}
|
||||
}
|
||||
|
||||
export type TimelineIngestResult =
|
||||
| { kind: "recorded"; row: AgentTimelineRow; epoch: string }
|
||||
| ({ kind: "recorded" } & TimelineCommit)
|
||||
/**
|
||||
* The row is committed but `publish` threw. The commit stands and nothing retries the
|
||||
* publication; ingesting the same submitted prompt again returns `duplicate`.
|
||||
*/
|
||||
| { kind: "unpublished"; row: AgentTimelineRow; epoch: string; error: unknown }
|
||||
| ({ kind: "unpublished"; error: unknown } & TimelineCommit)
|
||||
/** A submitted prompt with this `clientMessageId` is already recorded; nothing was published. */
|
||||
| { kind: "duplicate"; row: AgentTimelineRow; epoch: string };
|
||||
| ({ kind: "duplicate" } & TimelineCommit)
|
||||
/** Late output for a retired item or turn. Nothing was recorded or published. */
|
||||
| { kind: "dropped" };
|
||||
|
||||
export type TimelineAppendResult =
|
||||
| { kind: "recorded"; commits: TimelineCommit[] }
|
||||
@@ -98,8 +162,17 @@ export class AgentTimeline {
|
||||
const clientMessageId = submittedPromptId(entry.item);
|
||||
if (clientMessageId) {
|
||||
const existing = await this.backend.findSubmittedPrompt(scope, clientMessageId);
|
||||
if (existing) return { kind: "duplicate", row: existing, epoch: await this.epoch(scope) };
|
||||
if (existing) {
|
||||
const head = await this.requireHead(scope);
|
||||
return {
|
||||
kind: "duplicate",
|
||||
row: existing,
|
||||
epoch: head.epoch,
|
||||
retirementCount: head.retirementCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (await this.backend.isRetired(scope, entry)) return { kind: "dropped" };
|
||||
const [commit] = await this.commit(scope, [entry]);
|
||||
const failure = publishCommitted(options?.publish, commit);
|
||||
return failure
|
||||
@@ -164,6 +237,98 @@ export class AgentTimeline {
|
||||
return this.run(scope, () => this.backend.lastAssistantMessage(scope));
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a rewind to before the active prompt `messageId`. While it is open, `describe` reports it,
|
||||
* and a second rewind is refused until this one completes or is abandoned.
|
||||
*/
|
||||
beginRewind(scope: TimelineScope, messageId: string): Promise<TimelineRewindIntent> {
|
||||
return this.run(scope, async () => {
|
||||
const head = await this.requireHead(scope);
|
||||
if (head.rewind) throw new TimelineRewindPendingError(head.rewind);
|
||||
const target = (await this.backend.prompts(scope)).find(
|
||||
(prompt) => prompt.messageId === messageId,
|
||||
);
|
||||
if (!target) throw new TimelineRewindTargetError(messageId);
|
||||
const intent: TimelineRewindIntent = {
|
||||
id: randomUUID(),
|
||||
messageId,
|
||||
targetSeq: target.seqStart,
|
||||
};
|
||||
await this.backend.setRewind(scope, intent);
|
||||
return intent;
|
||||
});
|
||||
}
|
||||
|
||||
/** Records that the provider applied the rewind, before the retirement is committed. */
|
||||
recordRewindCut(
|
||||
scope: TimelineScope,
|
||||
intentId: string,
|
||||
cut: ConversationRewindCut,
|
||||
): Promise<TimelineRewindIntent | null> {
|
||||
return this.run(scope, async () => {
|
||||
const head = await this.requireHead(scope);
|
||||
if (head.rewind?.id !== intentId) return null;
|
||||
const intent = { ...head.rewind, cut };
|
||||
await this.backend.setRewind(scope, intent);
|
||||
return intent;
|
||||
});
|
||||
}
|
||||
|
||||
/** Closes a rewind the provider is known not to have applied. History is unchanged. */
|
||||
abandonRewind(scope: TimelineScope, intentId: string): Promise<void> {
|
||||
return this.run(scope, async () => {
|
||||
const head = await this.requireHead(scope);
|
||||
if (head.rewind?.id === intentId) await this.backend.setRewind(scope, null);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Judges an open rewind from the prompt ids the provider's current history holds, in order.
|
||||
* Changes nothing; the caller settles the rewind from the verdict.
|
||||
*/
|
||||
judgeRewind(
|
||||
scope: TimelineScope,
|
||||
intentId: string,
|
||||
providerPromptIds: readonly string[],
|
||||
): Promise<TimelineRewindVerdict> {
|
||||
return this.run(scope, async () => {
|
||||
const intent = (await this.requireHead(scope)).rewind;
|
||||
if (intent?.id !== intentId) return { kind: "absent" };
|
||||
return judgeRewindEvidence(intent, await this.backend.prompts(scope), providerPromptIds);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retires the turns the recorded cut removed, and closes the rewind, in one commit. Completing a
|
||||
* rewind that is no longer open changes nothing, so a repeat cannot retire later turns.
|
||||
*/
|
||||
completeRewind(
|
||||
scope: TimelineScope,
|
||||
intentId: string,
|
||||
options?: { publish?: (commit: TimelineRetirementCommit) => void },
|
||||
): Promise<TimelineRewindCompletion> {
|
||||
return this.run(scope, async () => {
|
||||
const head = await this.requireHead(scope);
|
||||
const intent = head.rewind;
|
||||
if (intent?.id !== intentId) return { kind: "absent" };
|
||||
if (!intent.cut) throw new Error("Cannot complete a rewind before its provider outcome");
|
||||
const retireFromSeq = resolveRetireFromSeq(intent, await this.backend.prompts(scope));
|
||||
if (retireFromSeq === null) throw new TimelineRewindBoundaryError(intent);
|
||||
const result = await this.backend.retire(scope, { retireFromSeq });
|
||||
this.track(scope, result.head);
|
||||
const commit: TimelineRetirementCommit = {
|
||||
retirement: result.retirement,
|
||||
retiredRows: result.retiredRows,
|
||||
epoch: result.head.epoch,
|
||||
retirementCount: result.head.retirementCount,
|
||||
};
|
||||
const failure = publishCommitted(options?.publish, commit);
|
||||
return failure
|
||||
? { kind: "unpublished", error: failure.error, ...commit }
|
||||
: { kind: "retired", ...commit };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves once every operation called before it has finished, including its publication.
|
||||
* Callers use it so an event published outside the timeline cannot overtake queued writes.
|
||||
@@ -187,13 +352,17 @@ export class AgentTimeline {
|
||||
}));
|
||||
const { rows, head } = await this.backend.append(scope, bounded);
|
||||
this.track(scope, head);
|
||||
return rows.map((row) => ({ row, epoch: head.epoch }));
|
||||
return rows.map((row) => ({
|
||||
row,
|
||||
epoch: head.epoch,
|
||||
retirementCount: head.retirementCount,
|
||||
}));
|
||||
}
|
||||
|
||||
private async epoch(scope: TimelineScope): Promise<string> {
|
||||
private async requireHead(scope: TimelineScope): Promise<TimelineHead> {
|
||||
const head = await this.backend.head(scope);
|
||||
if (!head) throw new UnknownTimelineError(scope);
|
||||
return head.epoch;
|
||||
return head;
|
||||
}
|
||||
|
||||
private track(scope: TimelineScope, head: TimelineHead): TimelineHead {
|
||||
@@ -233,6 +402,62 @@ function publishCommitted<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The anchor where retirement starts: the target for `at_target`; for `after`, the first active
|
||||
* prompt after the retained one. Null when the cut does not resolve at or before the target.
|
||||
* Prompts without a provider id (never acknowledged) are retired with the turns around them.
|
||||
*/
|
||||
function resolveRetireFromSeq(
|
||||
intent: TimelineRewindIntent,
|
||||
prompts: readonly TimelinePrompt[],
|
||||
): number | null {
|
||||
const cut = intent.cut;
|
||||
if (!cut) return null;
|
||||
let retireFromSeq: number | undefined;
|
||||
if (cut.kind === "at_target") {
|
||||
retireFromSeq = intent.targetSeq;
|
||||
} else if (cut.retainedMessageId === null) {
|
||||
retireFromSeq = prompts[0]?.seqStart;
|
||||
} else {
|
||||
const retainedId = cut.retainedMessageId;
|
||||
const retained = prompts.findIndex(
|
||||
(prompt) => (prompt.providerMessageId ?? prompt.messageId) === retainedId,
|
||||
);
|
||||
retireFromSeq = retained < 0 ? undefined : prompts[retained + 1]?.seqStart;
|
||||
}
|
||||
return retireFromSeq !== undefined && retireFromSeq <= intent.targetSeq ? retireFromSeq : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider's prompts must be, in order, a prefix of the active prompts it has acknowledged.
|
||||
* All of them: the rewind did not happen. A prefix ending before the target: it happened, keeping
|
||||
* that prefix. Anything else, including an empty history that may mean "no replay", is unknown.
|
||||
*/
|
||||
function judgeRewindEvidence(
|
||||
intent: TimelineRewindIntent,
|
||||
prompts: readonly TimelinePrompt[],
|
||||
providerPromptIds: readonly string[],
|
||||
): TimelineRewindVerdict {
|
||||
const known = prompts.filter((prompt) => !prompt.submitted || prompt.providerMessageId);
|
||||
const keys = known.map((prompt) => prompt.providerMessageId ?? prompt.messageId);
|
||||
if (providerPromptIds.length === 0) {
|
||||
return { kind: "unknown", reason: "The provider history has no prompts" };
|
||||
}
|
||||
const isPrefix = providerPromptIds.every((id, index) => keys[index] === id);
|
||||
if (!isPrefix) {
|
||||
return { kind: "unknown", reason: "The provider history differs from Paseo's" };
|
||||
}
|
||||
if (providerPromptIds.length === keys.length) return { kind: "unchanged" };
|
||||
const target = known.findIndex((prompt) => prompt.messageId === intent.messageId);
|
||||
if (target < 0 || providerPromptIds.length > target) {
|
||||
return { kind: "unknown", reason: "The provider history ends after the rewind target" };
|
||||
}
|
||||
return {
|
||||
kind: "applied",
|
||||
cut: { kind: "after", retainedMessageId: providerPromptIds[providerPromptIds.length - 1] },
|
||||
};
|
||||
}
|
||||
|
||||
function sizeKey(scope: TimelineScope): string {
|
||||
return scope.subagentId === undefined ? scope.agentId : `${scope.agentId}\0${scope.subagentId}`;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import type {
|
||||
AgentTimelineFetchResult,
|
||||
AgentTimelineRow,
|
||||
} from "../../agent-timeline-store-types.js";
|
||||
import type { ProjectedTimelineRow } from "../../timeline-projection.js";
|
||||
import type { ConversationRewindCut } from "../../agent-sdk-types.js";
|
||||
import type { ProjectedTimelineRow, TimelineRetirement } from "../../timeline-projection.js";
|
||||
|
||||
/** Root history uses `agentId` alone; provider-child history adds the child's id. */
|
||||
export interface TimelineScope {
|
||||
@@ -32,6 +33,34 @@ export interface TimelineHead {
|
||||
epoch: string;
|
||||
/** Projected (displayed) row count. */
|
||||
size: number;
|
||||
/** Rewinds applied since the history was created. */
|
||||
retirementCount: number;
|
||||
/** The rewind in progress, or one whose provider outcome is not settled yet. */
|
||||
rewind: TimelineRewindIntent | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A conversation rewind between its start and its retirement commit. It refuses new prompts and
|
||||
* other rewinds. Without `cut` the provider outcome is unknown; with it the provider applied the
|
||||
* rewind and only the local commit is outstanding.
|
||||
*/
|
||||
export interface TimelineRewindIntent {
|
||||
id: string;
|
||||
/** The rewound prompt's `messageId`. */
|
||||
messageId: string;
|
||||
/** The rewound prompt's anchor. */
|
||||
targetSeq: number;
|
||||
cut?: ConversationRewindCut;
|
||||
}
|
||||
|
||||
/** An active user prompt: what a rewind addresses. */
|
||||
export interface TimelinePrompt {
|
||||
seqStart: number;
|
||||
messageId: string | undefined;
|
||||
/** The provider's id for the prompt, when it differs from `messageId`. */
|
||||
providerMessageId: string | undefined;
|
||||
/** Submitted by Paseo; the provider knows it only once `providerMessageId` is attached. */
|
||||
submitted: boolean;
|
||||
}
|
||||
|
||||
export interface TimelineBackendAppendResult {
|
||||
@@ -39,6 +68,13 @@ export interface TimelineBackendAppendResult {
|
||||
head: TimelineHead;
|
||||
}
|
||||
|
||||
export interface TimelineBackendRetireResult {
|
||||
retirement: TimelineRetirement;
|
||||
/** The rows that left the active conversation. They stay stored. */
|
||||
retiredRows: ProjectedTimelineRow[];
|
||||
head: TimelineHead;
|
||||
}
|
||||
|
||||
type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
/**
|
||||
@@ -56,6 +92,19 @@ export interface AgentTimelineBackend {
|
||||
scope: TimelineScope,
|
||||
entries: readonly TimelineEntry[],
|
||||
): MaybePromise<TimelineBackendAppendResult>;
|
||||
/** Whether a new entry would land on retired history (see `TimelineProjection.isRetired`). */
|
||||
isRetired(scope: TimelineScope, entry: TimelineEntry): MaybePromise<boolean>;
|
||||
/** Active user prompts in display order. */
|
||||
prompts(scope: TimelineScope): MaybePromise<TimelinePrompt[]>;
|
||||
setRewind(scope: TimelineScope, rewind: TimelineRewindIntent | null): MaybePromise<TimelineHead>;
|
||||
/**
|
||||
* Allocates the next sequence number for a retirement of the active rows anchored at or after
|
||||
* `retireFromSeq`, and clears the rewind intent, in one commit.
|
||||
*/
|
||||
retire(
|
||||
scope: TimelineScope,
|
||||
input: { retireFromSeq: number },
|
||||
): MaybePromise<TimelineBackendRetireResult>;
|
||||
attachProviderMessageId(
|
||||
scope: TimelineScope,
|
||||
clientMessageId: string,
|
||||
|
||||
@@ -10,8 +10,11 @@ import { TimelineProjection, type ProjectedTimelineRow } from "../../timeline-pr
|
||||
import type {
|
||||
AgentTimelineBackend,
|
||||
TimelineBackendAppendResult,
|
||||
TimelineBackendRetireResult,
|
||||
TimelineEntry,
|
||||
TimelineHead,
|
||||
TimelinePrompt,
|
||||
TimelineRewindIntent,
|
||||
TimelineScope,
|
||||
TimelineSeed,
|
||||
} from "./backend.js";
|
||||
@@ -22,6 +25,10 @@ interface MemoryTimelineState {
|
||||
projection: TimelineProjection;
|
||||
minSeq: number;
|
||||
nextSeq: number;
|
||||
/** Rows rewinds took out of the active conversation, in retirement order. */
|
||||
retired: ProjectedTimelineRow[];
|
||||
retirementCount: number;
|
||||
rewind: TimelineRewindIntent | null;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMELINE_FETCH_LIMIT = 200;
|
||||
@@ -61,6 +68,9 @@ export class MemoryTimelineBackend implements AgentTimelineBackend {
|
||||
projection,
|
||||
minSeq: projection.getRows()[0]?.seqStart ?? 0,
|
||||
nextSeq,
|
||||
retired: [],
|
||||
retirementCount: 0,
|
||||
rewind: null,
|
||||
};
|
||||
this.states.set(scopeKey(scope), state);
|
||||
return this.describe(state);
|
||||
@@ -87,6 +97,47 @@ export class MemoryTimelineBackend implements AgentTimelineBackend {
|
||||
return { rows: rows.map(cloneRow), head: this.describe(state) };
|
||||
}
|
||||
|
||||
isRetired(scope: TimelineScope, entry: TimelineEntry): boolean {
|
||||
return this.requireState(scope).projection.isRetired(entry);
|
||||
}
|
||||
|
||||
prompts(scope: TimelineScope): TimelinePrompt[] {
|
||||
const prompts: TimelinePrompt[] = [];
|
||||
for (const row of this.requireState(scope).projection.getRows()) {
|
||||
if (row.item.type !== "user_message") continue;
|
||||
prompts.push({
|
||||
seqStart: row.seqStart,
|
||||
messageId: row.item.messageId,
|
||||
providerMessageId: row.providerMessageId,
|
||||
submitted: row.item.clientMessageId !== undefined,
|
||||
});
|
||||
}
|
||||
return prompts;
|
||||
}
|
||||
|
||||
setRewind(scope: TimelineScope, rewind: TimelineRewindIntent | null): TimelineHead {
|
||||
const state = this.requireState(scope);
|
||||
state.rewind = rewind ? { ...rewind } : null;
|
||||
return this.describe(state);
|
||||
}
|
||||
|
||||
retire(scope: TimelineScope, input: { retireFromSeq: number }): TimelineBackendRetireResult {
|
||||
const state = this.requireState(scope);
|
||||
const retirement = { seq: state.nextSeq, retireFromSeq: input.retireFromSeq };
|
||||
const retiredRows = state.projection.retire(retirement);
|
||||
state.retired.push(...retiredRows);
|
||||
state.nextSeq = retirement.seq + 1;
|
||||
if (state.minSeq === 0) state.minSeq = retirement.seq;
|
||||
state.retirementCount += 1;
|
||||
state.rewind = null;
|
||||
return { retirement, retiredRows: retiredRows.map(cloneRow), head: this.describe(state) };
|
||||
}
|
||||
|
||||
/** Retired rows, oldest retirement first. Not part of the backend contract; tests read it. */
|
||||
retiredRows(scope: TimelineScope): ProjectedTimelineRow[] {
|
||||
return this.requireState(scope).retired.map(cloneRow);
|
||||
}
|
||||
|
||||
attachProviderMessageId(
|
||||
scope: TimelineScope,
|
||||
clientMessageId: string,
|
||||
@@ -115,7 +166,10 @@ export class MemoryTimelineBackend implements AgentTimelineBackend {
|
||||
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 staleCursor =
|
||||
cursor !== undefined &&
|
||||
(cursor.epoch !== state.epoch ||
|
||||
(cursor.retirementCount !== undefined && cursor.retirementCount !== state.retirementCount));
|
||||
const gap =
|
||||
!staleCursor &&
|
||||
direction === "after" &&
|
||||
@@ -147,6 +201,8 @@ export class MemoryTimelineBackend implements AgentTimelineBackend {
|
||||
item: limitAgentTimelineItemContent(entry.item),
|
||||
}),
|
||||
),
|
||||
retirements: page.retirements,
|
||||
retirementCount: state.retirementCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -164,7 +220,12 @@ export class MemoryTimelineBackend implements AgentTimelineBackend {
|
||||
}
|
||||
|
||||
private describe(state: MemoryTimelineState): TimelineHead {
|
||||
return { epoch: state.epoch, size: state.projection.size };
|
||||
return {
|
||||
epoch: state.epoch,
|
||||
size: state.projection.size,
|
||||
retirementCount: state.retirementCount,
|
||||
rewind: state.rewind ? { ...state.rewind } : null,
|
||||
};
|
||||
}
|
||||
|
||||
private requireState(scope: TimelineScope): MemoryTimelineState {
|
||||
|
||||
@@ -9,8 +9,11 @@ import type { ProjectedTimelineRow } from "../timeline-projection.js";
|
||||
import type {
|
||||
AgentTimelineBackend,
|
||||
TimelineBackendAppendResult,
|
||||
TimelineBackendRetireResult,
|
||||
TimelineEntry,
|
||||
TimelineHead,
|
||||
TimelinePrompt,
|
||||
TimelineRewindIntent,
|
||||
TimelineScope,
|
||||
TimelineSeed,
|
||||
} from "./internal/backend.js";
|
||||
@@ -27,6 +30,7 @@ export class DeferredTimelineBackend implements AgentTimelineBackend {
|
||||
readonly appends: TimelineEntry[][] = [];
|
||||
readRowsCalls = 0;
|
||||
failNextAppend = false;
|
||||
failNextRetire = false;
|
||||
|
||||
private async defer<T>(call: () => T): Promise<T> {
|
||||
await nextTurn();
|
||||
@@ -56,6 +60,31 @@ export class DeferredTimelineBackend implements AgentTimelineBackend {
|
||||
return result;
|
||||
});
|
||||
}
|
||||
isRetired(scope: TimelineScope, entry: TimelineEntry): Promise<boolean> {
|
||||
return this.defer(() => this.memory.isRetired(scope, entry));
|
||||
}
|
||||
prompts(scope: TimelineScope): Promise<TimelinePrompt[]> {
|
||||
return this.defer(() => this.memory.prompts(scope));
|
||||
}
|
||||
setRewind(scope: TimelineScope, rewind: TimelineRewindIntent | null): Promise<TimelineHead> {
|
||||
return this.defer(() => this.memory.setRewind(scope, rewind));
|
||||
}
|
||||
retire(
|
||||
scope: TimelineScope,
|
||||
input: { retireFromSeq: number },
|
||||
): Promise<TimelineBackendRetireResult> {
|
||||
return this.defer(() => {
|
||||
if (this.failNextRetire) {
|
||||
this.failNextRetire = false;
|
||||
throw new Error("disk full");
|
||||
}
|
||||
return this.memory.retire(scope, input);
|
||||
});
|
||||
}
|
||||
/** Rows rewinds retired; they stay stored. */
|
||||
retiredRows(scope: TimelineScope): ProjectedTimelineRow[] {
|
||||
return this.memory.retiredRows(scope);
|
||||
}
|
||||
attachProviderMessageId(
|
||||
scope: TimelineScope,
|
||||
clientMessageId: string,
|
||||
|
||||
@@ -257,6 +257,7 @@ const OUTBOUND_PERMISSION = {
|
||||
"agent.timeline.list_prompts.response": "workspace.read",
|
||||
"agent.timeline.append.response": "workspace.write",
|
||||
"agent.timeline.replacement": ["workspace.read", "hub.execute"],
|
||||
"agent.timeline.rewind": ["workspace.read", "hub.execute"],
|
||||
"browser.host.register.response": ["workspace.write"],
|
||||
"subscription.release.response": null,
|
||||
"session.events.set_subscription.response": ["workspace.read", "daemon.read", "hub.execute"],
|
||||
|
||||
@@ -181,6 +181,7 @@ async function connect(input: {
|
||||
clientId: string;
|
||||
selective: boolean;
|
||||
timelineReplacementInvalidation?: boolean;
|
||||
timelineRewindOperations?: boolean;
|
||||
timelineNotifications?: boolean;
|
||||
pluginTimelineItems?: boolean;
|
||||
workspaceSetupBlocked?: boolean;
|
||||
@@ -202,6 +203,7 @@ async function connect(input: {
|
||||
? {}
|
||||
: { [CLIENT_CAPS.timelineNotifications]: input.timelineNotifications }),
|
||||
[CLIENT_CAPS.timelineReplacementInvalidation]: input.timelineReplacementInvalidation ?? false,
|
||||
[CLIENT_CAPS.timelineRewindOperations]: input.timelineRewindOperations ?? false,
|
||||
},
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
@@ -376,7 +378,7 @@ test("plugin timeline items are sent only to clients that advertise support", as
|
||||
}
|
||||
});
|
||||
|
||||
test("rewind routes replacement completion by source capability and subscription", async () => {
|
||||
test("a rewind reaches each client in the shape it can apply", async () => {
|
||||
await daemon.close();
|
||||
daemon = await createTestPaseoDaemon({
|
||||
isDev: true,
|
||||
@@ -386,6 +388,13 @@ test("rewind routes replacement completion by source capability and subscription
|
||||
clientId: "rewind-initiating",
|
||||
selective: true,
|
||||
timelineReplacementInvalidation: true,
|
||||
timelineRewindOperations: true,
|
||||
});
|
||||
const capable = await connect({
|
||||
clientId: "rewind-capable",
|
||||
selective: true,
|
||||
timelineReplacementInvalidation: true,
|
||||
timelineRewindOperations: true,
|
||||
});
|
||||
const passive = await connect({
|
||||
clientId: "rewind-passive",
|
||||
@@ -407,48 +416,133 @@ test("rewind routes replacement completion by source capability and subscription
|
||||
|
||||
await Promise.all([
|
||||
initiating.setLegacyTimelineMembership([agent.id]),
|
||||
capable.setLegacyTimelineMembership([agent.id]),
|
||||
passive.setLegacyTimelineMembership([agent.id]),
|
||||
unrelated.setLegacyTimelineMembership([]),
|
||||
]);
|
||||
await initiating.client.sendMessage(agent.id, "Rewind this synthetic prompt");
|
||||
await initiating.client.sendMessage(agent.id, "Keep this prompt");
|
||||
await initiating.client.cancelAgent(agent.id);
|
||||
const timeline = await initiating.client.fetchAgentTimeline(agent.id, {
|
||||
await initiating.client.sendMessage(agent.id, "Rewind this prompt");
|
||||
await initiating.client.cancelAgent(agent.id);
|
||||
const loaded = await initiating.client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
const target = timeline.entries.find(
|
||||
(entry) =>
|
||||
entry.item.type === "user_message" && entry.item.text === "Rewind this synthetic prompt",
|
||||
const target = loaded.entries.find(
|
||||
(entry) => entry.item.type === "user_message" && entry.item.text === "Rewind this prompt",
|
||||
);
|
||||
if (!target || target.item.type !== "user_message" || !target.item.messageId) {
|
||||
throw new Error("Expected rewindable canonical user message");
|
||||
throw new Error("Expected rewindable user message");
|
||||
}
|
||||
const epoch = loaded.epoch;
|
||||
const legacyEpoch = `${epoch}.r1`;
|
||||
|
||||
for (const connected of [initiating, passive, unrelated, legacy]) connected.clear();
|
||||
for (const connected of [initiating, capable, passive, unrelated, legacy]) connected.clear();
|
||||
await initiating.client.rewindAgent(agent.id, target.item.messageId, "conversation");
|
||||
await Promise.all([
|
||||
initiating.barrier("rewind-initiator"),
|
||||
passive.barrier("rewind-passive"),
|
||||
unrelated.barrier("rewind-unrelated"),
|
||||
legacy.barrier("rewind-legacy"),
|
||||
]);
|
||||
await Promise.all(
|
||||
[initiating, capable, passive, unrelated, legacy].map((connected, index) =>
|
||||
connected.barrier(`rewind-${index}`),
|
||||
),
|
||||
);
|
||||
|
||||
const rewind = {
|
||||
type: "agent.timeline.rewind",
|
||||
payload: {
|
||||
agentId: agent.id,
|
||||
epoch,
|
||||
seq: loaded.window.nextSeq,
|
||||
retireFromSeq: target.seqStart,
|
||||
},
|
||||
};
|
||||
const timelineChanges = (connected: ConnectedClient) =>
|
||||
connected.messages.filter(
|
||||
(message) =>
|
||||
message.type === "agent.timeline.rewind" || message.type === "agent.timeline.replacement",
|
||||
);
|
||||
expect(
|
||||
initiating.messages.filter((message) => message.type === "agent.rewind.response"),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
initiating.messages.filter((message) => message.type === "agent.timeline.replacement"),
|
||||
).toHaveLength(0);
|
||||
expect(
|
||||
passive.messages.filter((message) => message.type === "agent.timeline.replacement"),
|
||||
).toHaveLength(1);
|
||||
// A client that applies rewinds keeps its history and epoch, including the one that asked.
|
||||
expect(timelineChanges(initiating)).toEqual([
|
||||
expect.objectContaining({ ...rewind, payload: expect.objectContaining(rewind.payload) }),
|
||||
]);
|
||||
expect(timelineChanges(capable)).toEqual([
|
||||
expect.objectContaining({ ...rewind, payload: expect.objectContaining(rewind.payload) }),
|
||||
]);
|
||||
// An older client only drops history on an epoch change, so it sees a new one.
|
||||
expect(timelineChanges(passive)).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "agent.timeline.replacement",
|
||||
payload: expect.objectContaining({ agentId: agent.id, epoch: legacyEpoch }),
|
||||
}),
|
||||
]);
|
||||
expect(passive.hasTimeline(agent.id)).toBe(false);
|
||||
expect(legacy.messages.filter(isAgentStream(agent.id)).length).toBeGreaterThan(0);
|
||||
expect(
|
||||
unrelated.messages.filter((message) => message.type === "agent.timeline.replacement"),
|
||||
).toHaveLength(0);
|
||||
expect(timelineChanges(unrelated)).toEqual([]);
|
||||
expect(unrelated.hasTimeline(agent.id)).toBe(false);
|
||||
// A client older than replacement messages gets the remaining rows under the new epoch.
|
||||
const legacyRows = legacy.messages.filter(isAgentStream(agent.id));
|
||||
expect(
|
||||
legacyRows.map((message) => message.type === "agent_stream" && message.payload.epoch),
|
||||
).toEqual(legacyRows.map(() => legacyEpoch));
|
||||
expect(
|
||||
legacyRows.flatMap((message) =>
|
||||
message.type === "agent_stream" &&
|
||||
message.payload.event.type === "timeline" &&
|
||||
message.payload.event.item.type === "user_message"
|
||||
? [message.payload.event.item.text]
|
||||
: [],
|
||||
),
|
||||
).toEqual(["Keep this prompt"]);
|
||||
|
||||
// A capable reader catches up from where it was; an older one is reset to the new epoch.
|
||||
const capableCatchUp = await capable.client.fetchAgentTimeline(agent.id, {
|
||||
direction: "after",
|
||||
cursor: { epoch, seq: loaded.endCursor!.seq },
|
||||
limit: 50,
|
||||
});
|
||||
expect(capableCatchUp).toMatchObject({
|
||||
epoch,
|
||||
reset: false,
|
||||
retirements: [{ seq: loaded.window.nextSeq, retireFromSeq: target.seqStart }],
|
||||
entries: [],
|
||||
});
|
||||
const olderCatchUp = await passive.client.fetchAgentTimeline(agent.id, {
|
||||
direction: "after",
|
||||
cursor: { epoch, seq: loaded.endCursor!.seq },
|
||||
limit: 50,
|
||||
});
|
||||
expect(olderCatchUp).toMatchObject({ epoch: legacyEpoch, reset: true, staleCursor: true });
|
||||
expect(olderCatchUp.retirements).toBeUndefined();
|
||||
expect(olderCatchUp.endCursor?.epoch).toBe(legacyEpoch);
|
||||
const texts = (page: typeof olderCatchUp) =>
|
||||
page.entries.flatMap((entry) => (entry.item.type === "user_message" ? [entry.item.text] : []));
|
||||
expect(texts(olderCatchUp)).toEqual(["Keep this prompt"]);
|
||||
// Its cursor under the new epoch keeps working.
|
||||
const olderNext = await passive.client.fetchAgentTimeline(agent.id, {
|
||||
direction: "after",
|
||||
cursor: olderCatchUp.endCursor!,
|
||||
limit: 50,
|
||||
});
|
||||
expect(olderNext).toMatchObject({ epoch: legacyEpoch, reset: false, entries: [] });
|
||||
|
||||
// Live rows after the rewind carry each client's epoch.
|
||||
for (const connected of [capable, passive]) connected.clear();
|
||||
await initiating.client.sendMessage(agent.id, "After the rewind");
|
||||
await initiating.client.cancelAgent(agent.id);
|
||||
await Promise.all([capable.barrier("after-capable"), passive.barrier("after-passive")]);
|
||||
const liveEpochs = (connected: ConnectedClient) => [
|
||||
...new Set(
|
||||
connected.messages.flatMap((message) =>
|
||||
message.type === "agent_stream" &&
|
||||
message.payload.agentId === agent.id &&
|
||||
message.payload.event.type === "timeline"
|
||||
? [message.payload.epoch]
|
||||
: [],
|
||||
),
|
||||
),
|
||||
];
|
||||
expect(liveEpochs(capable)).toEqual([epoch]);
|
||||
expect(liveEpochs(passive)).toEqual([legacyEpoch]);
|
||||
}, 30_000);
|
||||
|
||||
test("subscription acknowledgements stay on the requesting socket of a retained session", async () => {
|
||||
@@ -753,13 +847,15 @@ test("plugin items are gated in provider child streams, child fetches, and rewin
|
||||
]);
|
||||
expect(oldChild.window).toEqual(newChild.window);
|
||||
|
||||
await capable.client.sendMessage(agent.id, "Kept prompt");
|
||||
await capable.client.cancelAgent(agent.id);
|
||||
await capable.client.sendMessage(agent.id, "Rewind target");
|
||||
await capable.client.cancelAgent(agent.id);
|
||||
const timeline = await capable.client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
projection: "canonical",
|
||||
});
|
||||
const targetMessageId = rewindMessageId(timeline);
|
||||
const targetMessageId = rewindMessageId(timeline, "Rewind target");
|
||||
capable.clear();
|
||||
legacy.clear();
|
||||
await capable.client.rewindAgent(agent.id, targetMessageId, "conversation");
|
||||
@@ -773,7 +869,12 @@ test("plugin items are gated in provider child streams, child fetches, and rewin
|
||||
}
|
||||
expect(replayItems(capable)).toContainEqual(plugin);
|
||||
expect(replayItems(legacy).some((item) => item.type === "plugin")).toBe(false);
|
||||
expect(replayItems(legacy)).toContainEqual(expect.objectContaining({ type: "user_message" }));
|
||||
expect(replayItems(legacy)).toContainEqual(
|
||||
expect.objectContaining({ type: "user_message", text: "Kept prompt" }),
|
||||
);
|
||||
expect(replayItems(legacy)).not.toContainEqual(
|
||||
expect.objectContaining({ type: "user_message", text: "Rewind target" }),
|
||||
);
|
||||
});
|
||||
|
||||
async function createAttentionWorkspace(client: DaemonClient): Promise<string> {
|
||||
@@ -786,8 +887,11 @@ async function createAttentionWorkspace(client: DaemonClient): Promise<string> {
|
||||
|
||||
function rewindMessageId(
|
||||
timeline: Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>,
|
||||
text: string,
|
||||
): string {
|
||||
const target = timeline.entries.find((entry) => entry.item.type === "user_message");
|
||||
const target = timeline.entries.find(
|
||||
(entry) => entry.item.type === "user_message" && entry.item.text === text,
|
||||
);
|
||||
if (target?.item.type !== "user_message" || !target.item.messageId)
|
||||
throw new Error("Expected rewind target");
|
||||
return target.item.messageId;
|
||||
|
||||
@@ -1510,7 +1510,9 @@ function createStoredAgentRecord(
|
||||
describe("plugin timeline append RPC", () => {
|
||||
test("stamps the plugin identity and returns the timeline position", async () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const appendTimelineItem = vi.fn().mockResolvedValue({ seq: 7, epoch: "epoch-1" });
|
||||
const appendTimelineItem = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ seq: 7, epoch: "epoch-1", retirementCount: 0 });
|
||||
const session = createSessionForTest({
|
||||
clientId: "plugin:review",
|
||||
messages,
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { MessageReceipts } from "./message-receipts/index.js";
|
||||
import equal from "fast-deep-equal";
|
||||
import { WorkspaceSetupObservation } from "./workspace-setup-observation.js";
|
||||
import { SessionDelivery, type OwnedSubscription } from "./session/owned-subscriptions/index.js";
|
||||
import { legacyWireEpoch, readLegacyWireCursor } from "./session/legacy-rewind-epoch/index.js";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { lstat, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises";
|
||||
import { basename, join, resolve, sep } from "path";
|
||||
@@ -1381,10 +1382,17 @@ export class Session {
|
||||
},
|
||||
});
|
||||
}
|
||||
const message: SessionOutboundMessage = {
|
||||
const payload = this.buildAgentStreamPayload(event, serializedEvent);
|
||||
const messageFor = (source?: object): SessionOutboundMessage => ({
|
||||
type: "agent_stream",
|
||||
payload: this.buildAgentStreamPayload(event, serializedEvent),
|
||||
};
|
||||
payload:
|
||||
payload.epoch === undefined
|
||||
? payload
|
||||
: {
|
||||
...payload,
|
||||
epoch: this.wireEpoch(payload.epoch, event.retirementCount ?? 0, source),
|
||||
},
|
||||
});
|
||||
for (const subscription of this.timelineSubscriptions.values()) {
|
||||
const source = subscription.owner.source;
|
||||
if (!subscription.agentIds.has(event.agentId)) continue;
|
||||
@@ -1399,7 +1407,7 @@ export class Session {
|
||||
!this.supportsTimelineItem(serializedEvent.item, source)
|
||||
)
|
||||
continue;
|
||||
subscription.owner.emit(message);
|
||||
subscription.owner.emit(messageFor(source));
|
||||
}
|
||||
// COMPAT(ownedSubscriptions): added in v0.8.0, remove implicit timeline delivery after 2027-03-09.
|
||||
for (const [source, { capabilities }] of this.clientSources) {
|
||||
@@ -1414,14 +1422,33 @@ export class Session {
|
||||
(subscription) =>
|
||||
subscription.owner.source === source && subscription.agentIds.has(event.agentId),
|
||||
);
|
||||
if (!alreadyDelivered) this.onMessageToSource?.(source, message);
|
||||
if (!alreadyDelivered) this.onMessageToSource?.(source, messageFor(source));
|
||||
}
|
||||
if (
|
||||
this.clientSources.size === 0 &&
|
||||
!this.supports(CLIENT_CAPS.selectiveAgentTimeline) &&
|
||||
this.timelineSubscriptions.size === 0
|
||||
)
|
||||
this.emit(message);
|
||||
this.emit(messageFor());
|
||||
}
|
||||
|
||||
// COMPAT(timelineRewindOperations): added in v0.14.1, remove after 2027-05-10 with
|
||||
// ./session/legacy-rewind-epoch. A client that cannot apply rewinds sees each one as a new epoch.
|
||||
private appliesRewinds(source?: object): boolean {
|
||||
return source
|
||||
? this.supportsForSource(CLIENT_CAPS.timelineRewindOperations, source)
|
||||
: this.supports(CLIENT_CAPS.timelineRewindOperations);
|
||||
}
|
||||
|
||||
private wireEpoch(epoch: string, retirementCount: number, source?: object): string {
|
||||
return this.appliesRewinds(source) ? epoch : legacyWireEpoch(epoch, retirementCount);
|
||||
}
|
||||
|
||||
private readWireCursor(
|
||||
cursor: { epoch: string; seq: number },
|
||||
source?: object,
|
||||
): AgentTimelineCursor {
|
||||
return this.appliesRewinds(source) ? cursor : readLegacyWireCursor(cursor);
|
||||
}
|
||||
|
||||
supports(capability: ClientCapability): boolean {
|
||||
@@ -1988,8 +2015,15 @@ export class Session {
|
||||
|
||||
this.unsubscribeAgentEvents = this.agentManager.subscribe(
|
||||
(event) => {
|
||||
if (event.type === "timeline_replacement") {
|
||||
this.deliverTimelineReplacement(event, this.rewindInitiators.get(event.agentId));
|
||||
if (event.type === "timeline_retirement") {
|
||||
this.deliverTimelineRetirement(event, this.rewindInitiators.get(event.agentId)).catch(
|
||||
(error) => {
|
||||
this.sessionLogger.error(
|
||||
{ err: error, agentId: event.agentId },
|
||||
"Failed to deliver timeline retirement",
|
||||
);
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4910,17 +4944,35 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private deliverTimelineReplacement(
|
||||
timeline: Extract<AgentManagerEvent, { type: "timeline_replacement" }>,
|
||||
private async deliverTimelineRetirement(
|
||||
retirement: Extract<AgentManagerEvent, { type: "timeline_retirement" }>,
|
||||
initiatingSource?: object,
|
||||
): void {
|
||||
const { agentId, epoch } = timeline;
|
||||
): Promise<void> {
|
||||
const { agentId, epoch, seq, retireFromSeq, retirementCount } = retirement;
|
||||
const agent = this.agentManager.getAgent(agentId);
|
||||
if (!agent) return;
|
||||
const rewind: SessionOutboundMessage = {
|
||||
type: "agent.timeline.rewind",
|
||||
payload: { agentId, epoch, seq, retireFromSeq },
|
||||
};
|
||||
const legacyEpoch = legacyWireEpoch(epoch, retirementCount);
|
||||
const replacement: SessionOutboundMessage = {
|
||||
type: "agent.timeline.replacement",
|
||||
payload: { agentId, epoch: legacyEpoch },
|
||||
};
|
||||
const readActiveRows = async () =>
|
||||
(await this.agentManager.fetchTimeline(agentId, { limit: 0 })).rows;
|
||||
|
||||
if (this.clientSources.size === 0 || !this.onMessageToSource) {
|
||||
if (!this.supports(CLIENT_CAPS.timelineReplacementInvalidation)) {
|
||||
this.emitReconstructedTimelineRows(agentId, agent.provider, timeline.rows, epoch);
|
||||
if (this.appliesRewinds()) this.emit(rewind);
|
||||
else if (this.supports(CLIENT_CAPS.timelineReplacementInvalidation)) this.emit(replacement);
|
||||
else {
|
||||
this.emitReconstructedTimelineRows(
|
||||
agentId,
|
||||
agent.provider,
|
||||
await readActiveRows(),
|
||||
legacyEpoch,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -4928,26 +4980,32 @@ export class Session {
|
||||
for (const subscription of this.timelineSubscriptions.values()) {
|
||||
const source = subscription.owner.source;
|
||||
if (!subscription.agentIds.has(agentId)) continue;
|
||||
const modern = this.delivery.isModern(source);
|
||||
if (this.appliesRewinds(source)) {
|
||||
subscription.owner.emit(rewind);
|
||||
continue;
|
||||
}
|
||||
// COMPAT(timelineRewindOperations): added in v0.14.1, remove after 2027-05-10. An older
|
||||
// client that started the rewind refetches its history when the rewind response arrives.
|
||||
if (
|
||||
modern ||
|
||||
this.delivery.isModern(source) ||
|
||||
(source !== initiatingSource &&
|
||||
this.supportsForSource(CLIENT_CAPS.timelineReplacementInvalidation, source))
|
||||
) {
|
||||
subscription.owner.emit({
|
||||
type: "agent.timeline.replacement",
|
||||
payload: { agentId, epoch },
|
||||
});
|
||||
subscription.owner.emit(replacement);
|
||||
}
|
||||
}
|
||||
// COMPAT(timelineReplacementInvalidation): added in v0.5.0, remove reconstructed replay after 2027-02-21.
|
||||
for (const [source, { capabilities }] of this.clientSources) {
|
||||
if (
|
||||
!this.delivery.isModern(source) &&
|
||||
!capabilities.has(CLIENT_CAPS.timelineReplacementInvalidation)
|
||||
) {
|
||||
this.emitReconstructedTimelineRows(agentId, agent.provider, timeline.rows, epoch, source);
|
||||
}
|
||||
const replaySources = [...this.clientSources]
|
||||
.filter(
|
||||
([source, { capabilities }]) =>
|
||||
!this.delivery.isModern(source) &&
|
||||
!capabilities.has(CLIENT_CAPS.timelineReplacementInvalidation),
|
||||
)
|
||||
.map(([source]) => source);
|
||||
if (replaySources.length === 0) return;
|
||||
const rows = await readActiveRows();
|
||||
for (const source of replaySources) {
|
||||
this.emitReconstructedTimelineRows(agentId, agent.provider, rows, legacyEpoch, source);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7830,12 +7888,7 @@ export class Session {
|
||||
): Promise<void> {
|
||||
const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail");
|
||||
const projection = "projected" as const;
|
||||
const cursor: AgentTimelineCursor | undefined = msg.cursor
|
||||
? {
|
||||
epoch: msg.cursor.epoch,
|
||||
seq: msg.cursor.seq,
|
||||
}
|
||||
: undefined;
|
||||
const cursor = msg.cursor ? this.readWireCursor(msg.cursor, source) : undefined;
|
||||
|
||||
try {
|
||||
const snapshot = await ensureAgentLoaded(msg.agentId, {
|
||||
@@ -7859,14 +7912,15 @@ export class Session {
|
||||
hasOlder: fetchedControlTimeline.hasOlder,
|
||||
hasNewer: fetchedControlTimeline.hasNewer,
|
||||
};
|
||||
const epoch = this.wireEpoch(
|
||||
fetchedControlTimeline.epoch,
|
||||
fetchedControlTimeline.retirementCount,
|
||||
source,
|
||||
);
|
||||
const startCursor =
|
||||
selectedTimeline.startSeq !== null
|
||||
? { epoch: selectedTimeline.timeline.epoch, seq: selectedTimeline.startSeq }
|
||||
: null;
|
||||
selectedTimeline.startSeq !== null ? { epoch, seq: selectedTimeline.startSeq } : null;
|
||||
const endCursor =
|
||||
selectedTimeline.endSeq !== null
|
||||
? { epoch: selectedTimeline.timeline.epoch, seq: selectedTimeline.endSeq }
|
||||
: null;
|
||||
selectedTimeline.endSeq !== null ? { epoch, seq: selectedTimeline.endSeq } : null;
|
||||
const entries = selectedTimeline.entries.filter((entry) =>
|
||||
this.supportsTimelineItem(entry.item, source),
|
||||
);
|
||||
@@ -7880,7 +7934,7 @@ export class Session {
|
||||
agent: agentPayload,
|
||||
direction,
|
||||
projection,
|
||||
epoch: selectedTimeline.timeline.epoch,
|
||||
epoch,
|
||||
reset: fetchedControlTimeline.reset,
|
||||
staleCursor: fetchedControlTimeline.staleCursor,
|
||||
gap: fetchedControlTimeline.gap,
|
||||
@@ -7890,6 +7944,9 @@ export class Session {
|
||||
hasOlder: selectedTimeline.hasOlder,
|
||||
hasNewer: selectedTimeline.hasNewer,
|
||||
...(msg.mergeWindow === true ? { mergeWindow: true } : {}),
|
||||
...(this.appliesRewinds(source)
|
||||
? { retirements: fetchedControlTimeline.retirements }
|
||||
: {}),
|
||||
entries: entries.map((entry) => {
|
||||
const payloadEntry = {
|
||||
provider: snapshot.provider,
|
||||
@@ -7954,13 +8011,13 @@ export class Session {
|
||||
const pluginId = parsePluginClientId(this.clientId);
|
||||
if (!pluginId) throw new Error("Only plugin sessions can append plugin timeline items");
|
||||
assertPluginTimelineDataSize(msg.item.data);
|
||||
const { seq, epoch } = await this.agentManager.appendTimelineItem(msg.agentId, {
|
||||
...msg.item,
|
||||
pluginId,
|
||||
});
|
||||
const { seq, epoch, retirementCount } = await this.agentManager.appendTimelineItem(
|
||||
msg.agentId,
|
||||
{ ...msg.item, pluginId },
|
||||
);
|
||||
this.emit({
|
||||
type: "agent.timeline.append.response",
|
||||
payload: { requestId: msg.requestId, seq, epoch },
|
||||
payload: { requestId: msg.requestId, seq, epoch: this.wireEpoch(epoch, retirementCount) },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7975,17 +8032,19 @@ export class Session {
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
const rows = await this.agentManager.getTimelineRows(msg.agentId);
|
||||
const { epoch } = await this.agentManager.fetchTimeline(msg.agentId, {
|
||||
const before = await this.agentManager.fetchTimeline(msg.agentId, {
|
||||
direction: "tail",
|
||||
limit: 1,
|
||||
});
|
||||
const result = await searchTimeline({ rows, query: msg.query, cursor: msg.cursor });
|
||||
if (
|
||||
(await this.agentManager.fetchTimeline(msg.agentId, { direction: "tail", limit: 1 }))
|
||||
.epoch !== epoch
|
||||
) {
|
||||
const after = await this.agentManager.fetchTimeline(msg.agentId, {
|
||||
direction: "tail",
|
||||
limit: 1,
|
||||
});
|
||||
if (after.epoch !== before.epoch || after.retirementCount !== before.retirementCount) {
|
||||
throw new Error("History changed; search again");
|
||||
}
|
||||
const epoch = this.wireEpoch(before.epoch, before.retirementCount, source);
|
||||
this.emitForSource(
|
||||
{
|
||||
type: "agent.timeline.search.response",
|
||||
@@ -8032,7 +8091,10 @@ export class Session {
|
||||
direction: "tail",
|
||||
limit: 1,
|
||||
});
|
||||
const index = buildTimelinePromptIndex(timeline.epoch, rows);
|
||||
const index = buildTimelinePromptIndex(
|
||||
this.wireEpoch(timeline.epoch, timeline.retirementCount, source),
|
||||
rows,
|
||||
);
|
||||
this.emitForSource(
|
||||
{
|
||||
type: "agent.timeline.list_prompts.response",
|
||||
@@ -8214,7 +8276,10 @@ export class Session {
|
||||
const forkContext = buildAgentForkContextAttachment({
|
||||
rows: timeline.rows,
|
||||
cursorBoundary: msg.boundaryCursor
|
||||
? { timelineEpoch: timeline.epoch, cursor: msg.boundaryCursor }
|
||||
? {
|
||||
timelineEpoch: this.wireEpoch(timeline.epoch, timeline.retirementCount),
|
||||
cursor: msg.boundaryCursor,
|
||||
}
|
||||
: null,
|
||||
boundaryMessageId: msg.boundaryMessageId,
|
||||
agentTitle: agentPayload.title,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// COMPAT(timelineRewindOperations): added in v0.14.1, remove after 2027-05-10 once the client floor
|
||||
// applies rewinds. Delete this module and its call sites in session.ts.
|
||||
//
|
||||
// A client without the capability cannot drop retired rows from what it already holds. The only
|
||||
// signal it honors is an epoch change, so it sees each retirement as a new history: the stored
|
||||
// epoch, suffixed with the retirement count once there is one. Nothing is stored or rotated.
|
||||
|
||||
import type { AgentTimelineCursor } from "../../agent/agent-timeline-store-types.js";
|
||||
|
||||
const RETIREMENT_SUFFIX = /^(.*)\.r([1-9][0-9]*)$/;
|
||||
|
||||
/** The epoch a client without rewind operations sees for a history. */
|
||||
export function legacyWireEpoch(epoch: string, retirementCount: number): string {
|
||||
return retirementCount === 0 ? epoch : `${epoch}.r${retirementCount}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a cursor such a client sends back. It stays valid only while no retirement has happened
|
||||
* since, so the timeline treats a later retirement as a stale cursor and resets the client.
|
||||
*/
|
||||
export function readLegacyWireCursor(cursor: { epoch: string; seq: number }): AgentTimelineCursor {
|
||||
const match = RETIREMENT_SUFFIX.exec(cursor.epoch);
|
||||
return match
|
||||
? { epoch: match[1], seq: cursor.seq, retirementCount: Number(match[2]) }
|
||||
: { epoch: cursor.epoch, seq: cursor.seq, retirementCount: 0 };
|
||||
}
|
||||
@@ -2080,6 +2080,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
workspaceTerminals: true,
|
||||
// COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X.
|
||||
rewind: true,
|
||||
// COMPAT(timelineRewindOperations): added in v0.14.1, remove gate after 2027-05-10.
|
||||
timelineRewindOperations: true,
|
||||
// COMPAT(agentTimelinePromptIndex): added in v0.2.X, drop the gate when floor >= v0.2.X.
|
||||
agentTimelinePromptIndex: true,
|
||||
// COMPAT(agentHistorySearch): added in v0.3.0, remove gate after 2027-02-07.
|
||||
|
||||
@@ -45,6 +45,8 @@ await directory.subscription.release();
|
||||
## Follow timeline events
|
||||
|
||||
```ts
|
||||
let entries = (await agent.timeline.refetch()).entries;
|
||||
|
||||
const unsubscribe = agent.timeline.subscribe((update) => {
|
||||
const { event } = update;
|
||||
if (event.type === "subscription_restored") {
|
||||
@@ -61,6 +63,13 @@ const unsubscribe = agent.timeline.subscribe((update) => {
|
||||
void agent.timeline.refetch().then((page) => console.log(page.entries));
|
||||
return;
|
||||
}
|
||||
if (event.type === "rewind") {
|
||||
// Entries that started in [retireFromSeq, seq) left the conversation. Later ones stay.
|
||||
entries = entries.filter(
|
||||
(entry) => entry.seqStart < event.retireFromSeq || entry.seqStart >= event.seq,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.type === "timeline" && event.item.type === "assistant_message") {
|
||||
process.stdout.write(event.item.text);
|
||||
}
|
||||
@@ -75,7 +84,7 @@ Await `unsubscribe.ready` before starting work whose events you need to observe.
|
||||
|
||||
After reconnect, the same handle receives `{ agentId, subscriptionId, event: { type: "subscription_restored" } }` before subsequent live updates. This is a local SDK notification after membership acknowledgement (local attachment on broadcast-only hosts). The subscription gets a fresh ID. No history is fetched automatically, and missed events are not replayed.
|
||||
|
||||
Choose recovery for your consumer: continue live, request a recent page, or call `timeline.refetch({ direction: "after", cursor: { epoch, seq } })` using your saved position. Live delivery continues while your read is pending; buffer or reconcile those events with the returned page by epoch and sequence. Follow `hasNewer` and `endCursor` to read further pages when needed. A live `replacement` invalidates the previous epoch. A failed explicit history read rejects that read and leaves the live subscription active.
|
||||
Choose recovery for your consumer: continue live, request a recent page, or call `timeline.refetch({ direction: "after", cursor: { epoch, seq } })` using your saved position. Live delivery continues while your read is pending; buffer or reconcile those events with the returned page by epoch and sequence. Follow `hasNewer` and `endCursor` to read further pages when needed. A live `replacement` invalidates the previous epoch. A live `rewind` keeps the epoch and removes the entries whose `seqStart` is at least `retireFromSeq` and below `seq`; entries from `seq` on belong to the rewound conversation. A failed explicit history read rejects that read and leaves the live subscription active.
|
||||
|
||||
Subscription establishment failures deliver `{ agentId, event: { type: "error", error } }` and release the observation. Establish a new subscription when ready to retry. Releasing a subscription stops its callbacks and prevents restoration after reconnect.
|
||||
|
||||
@@ -97,7 +106,7 @@ for (const entry of page.entries) {
|
||||
}
|
||||
```
|
||||
|
||||
Use `startCursor`, `endCursor`, `hasOlder`, and `hasNewer` from the result to page without inventing offsets.
|
||||
Use `startCursor`, `endCursor`, `hasOlder`, and `hasNewer` from the result to page without inventing offsets. A page can carry `retirements`, the rewinds whose `seq` falls inside it. When you append the page to entries you already hold, first drop held entries whose `seqStart` is at least that retirement's `retireFromSeq` and below its `seq`. Entries you hold from `seq` on stay.
|
||||
|
||||
## Follow workspace updates
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ await directory.subscription.release();
|
||||
## 跟踪时间线事件 {#follow-timeline-events}
|
||||
|
||||
```ts
|
||||
let entries = (await agent.timeline.refetch()).entries;
|
||||
|
||||
const unsubscribe = agent.timeline.subscribe((update) => {
|
||||
const { event } = update;
|
||||
if (event.type === "subscription_restored") {
|
||||
@@ -59,6 +61,13 @@ const unsubscribe = agent.timeline.subscribe((update) => {
|
||||
void agent.timeline.refetch().then((page) => console.log(page.entries));
|
||||
return;
|
||||
}
|
||||
if (event.type === "rewind") {
|
||||
// Entries that started in [retireFromSeq, seq) left the conversation. Later ones stay.
|
||||
entries = entries.filter(
|
||||
(entry) => entry.seqStart < event.retireFromSeq || entry.seqStart >= event.seq,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.type === "timeline" && event.item.type === "assistant_message") {
|
||||
process.stdout.write(event.item.text);
|
||||
}
|
||||
@@ -73,7 +82,7 @@ const unsubscribe = agent.timeline.subscribe((update) => {
|
||||
|
||||
重连后,同一个句柄会在后续实时更新之前收到 `{ agentId, subscriptionId, event: { type: "subscription_restored" } }`。这是 SDK 在成员关系确认后(在只支持广播的主机上是本地挂载后)发出的本地通知。订阅会获得一个新的 ID。不会自动获取历史,错过的事件也不会重放。
|
||||
|
||||
请为你的使用方选择恢复方式:继续接收实时事件、请求最近一页,或者用你保存的位置调用 `timeline.refetch({ direction: "after", cursor: { epoch, seq } })`。读取进行期间,实时投递会继续;请按 epoch 和序号缓冲这些事件,或与返回的页面进行对账。需要时,根据 `hasNewer` 和 `endCursor` 继续读取后面的页面。实时的 `replacement` 会使之前的 epoch 失效。显式的历史读取失败时,只会让那次读取 reject,实时订阅仍然有效。
|
||||
请为你的使用方选择恢复方式:继续接收实时事件、请求最近一页,或者用你保存的位置调用 `timeline.refetch({ direction: "after", cursor: { epoch, seq } })`。读取进行期间,实时投递会继续;请按 epoch 和序号缓冲这些事件,或与返回的页面进行对账。需要时,根据 `hasNewer` 和 `endCursor` 继续读取后面的页面。实时的 `replacement` 会使之前的 epoch 失效。实时的 `rewind` 保持 epoch 不变,并移除 `seqStart` 不小于 `retireFromSeq` 且小于 `seq` 的条目;从 `seq` 起的条目属于回退后的对话。显式的历史读取失败时,只会让那次读取 reject,实时订阅仍然有效。
|
||||
|
||||
订阅建立失败时会投递 `{ agentId, event: { type: "error", error } }` 并释放该观察。准备好重试时,请建立新的订阅。释放订阅会停止它的回调,并阻止重连后的恢复。
|
||||
|
||||
@@ -95,7 +104,7 @@ for (const entry of page.entries) {
|
||||
}
|
||||
```
|
||||
|
||||
使用结果中的 `startCursor`、`endCursor`、`hasOlder` 和 `hasNewer` 翻页,不要自己计算偏移量。
|
||||
使用结果中的 `startCursor`、`endCursor`、`hasOlder` 和 `hasNewer` 翻页,不要自己计算偏移量。页面可能带有 `retirements`,即 `seq` 落在该页范围内的回退。把页面追加到已持有的条目之前,先针对每个回退丢弃 `seqStart` 不小于其 `retireFromSeq` 且小于其 `seq` 的已持有条目。从 `seq` 起已持有的条目保留。
|
||||
|
||||
## 跟踪工作区更新 {#follow-workspace-updates}
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ See [background workspaces](./workspaces.md) for caller inheritance and inclusiv
|
||||
|
||||
`agent.timeline.refetch(options?)` fetches a page. Options are `direction`, `cursor`, `limit`, `projection`, and `requestId`.
|
||||
|
||||
`agent.timeline.subscribe(handler)` establishes network demand for this agent and restores it after reconnect. Its unsubscribe function releases that demand; await `unsubscribe.ready` for initial daemon acknowledgement before starting work. Delivery is live-only. Reconnect emits a local `subscription_restored` event; request missed history explicitly with `refetch()`. A live replacement invalidates the previous epoch. See the callback shapes, paging and failure behavior in [timeline events](./events.md#follow-timeline-events).
|
||||
`agent.timeline.subscribe(handler)` establishes network demand for this agent and restores it after reconnect. Its unsubscribe function releases that demand; await `unsubscribe.ready` for initial daemon acknowledgement before starting work. Delivery is live-only. Reconnect emits a local `subscription_restored` event; request missed history explicitly with `refetch()`. A live replacement invalidates the previous epoch; a live rewind removes a range of entries and keeps the epoch. See the callback shapes, paging and failure behavior in [timeline events](./events.md#follow-timeline-events).
|
||||
|
||||
## `client.projects`
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ nav: API 参考
|
||||
|
||||
`agent.timeline.refetch(options?)` 获取一页时间线。选项有 `direction`、`cursor`、`limit`、`projection` 和 `requestId`。
|
||||
|
||||
`agent.timeline.subscribe(handler)` 为该 Agent 建立网络订阅需求,并在重连后恢复它。它返回的取消订阅函数会释放这项需求;开始工作之前,请 await `unsubscribe.ready` 以等待 daemon 的初始确认。投递只包含实时内容。重连时会发出一个本地的 `subscription_restored` 事件;错过的历史请用 `refetch()` 显式请求。实时的替换会使之前的 epoch 失效。回调的形状、分页和失败行为见[时间线事件](./events.md#follow-timeline-events)。
|
||||
`agent.timeline.subscribe(handler)` 为该 Agent 建立网络订阅需求,并在重连后恢复它。它返回的取消订阅函数会释放这项需求;开始工作之前,请 await `unsubscribe.ready` 以等待 daemon 的初始确认。投递只包含实时内容。重连时会发出一个本地的 `subscription_restored` 事件;错过的历史请用 `refetch()` 显式请求。实时的替换会使之前的 epoch 失效;实时的回退会移除一段条目并保持 epoch 不变。回调的形状、分页和失败行为见[时间线事件](./events.md#follow-timeline-events)。
|
||||
|
||||
## `client.projects` {#clientprojects}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user