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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user