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:
Mohamed Boudra
2026-11-10 10:16:13 +01:00
parent 4178497d3d
commit 5ed1f8d108
69 changed files with 3782 additions and 513 deletions
@@ -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.
+26
View File
@@ -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",