fix(rewind): replace long timelines without replay (#3642)

* fix(rewind): refresh canonical timeline after rewind

A rewind replaces the daemon timeline and epoch, but replaying the rebuilt history made long conversations visibly reload from the beginning. Capable clients now refresh the existing bounded tail path after a replacement signal while legacy clients retain replay compatibility.

* test(rewind): cover coherent timeline replacement

Hold rewind completion in the browser regression so it can verify the existing pending state and detect any reconstructed timeline rows before the normal tail refresh is allowed to finish.
This commit is contained in:
Mohamed Boudra
2026-08-21 19:09:56 +02:00
committed by GitHub
parent 956f6ea7ad
commit c3ae54a1a2
18 changed files with 418 additions and 34 deletions
@@ -24,6 +24,9 @@ export const CLIENT_CAPS = {
// provider catalogs with shared thinking sets and may revalidate by content hash.
// Remove the legacy snapshot encoding after 2027-02-04.
compactProviderSnapshots: "compact_provider_snapshots",
// 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",
browserHost: "browser_host",
} as const;
+10
View File
@@ -4266,6 +4266,14 @@ export const FetchAgentTimelineResponseMessageSchema = z.object({
}),
});
export const AgentTimelineReplacementMessageSchema = z.object({
type: z.literal("agent.timeline.replacement"),
payload: z.object({
agentId: z.string(),
epoch: z.string(),
}),
});
export const AgentTimelineListPromptsResponseMessageSchema = z.object({
type: z.literal("agent.timeline.list_prompts.response"),
payload: z.object({
@@ -6222,6 +6230,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ArchiveWorkspaceResponseMessageSchema,
FetchAgentResponseMessageSchema,
FetchAgentTimelineResponseMessageSchema,
AgentTimelineReplacementMessageSchema,
AgentTimelineListPromptsResponseMessageSchema,
ProviderSubagentListResponseMessageSchema,
ProviderSubagentTimelineResponseMessageSchema,
@@ -6829,6 +6838,7 @@ export const WSHelloMessageSchema = z.object({
[CLIENT_CAPS.providerSubagents]: z.boolean().optional(),
[CLIENT_CAPS.projectUpdates]: z.boolean().optional(),
[CLIENT_CAPS.compactProviderSnapshots]: z.boolean().optional(),
[CLIENT_CAPS.timelineReplacementInvalidation]: z.boolean().optional(),
[CLIENT_CAPS.browserHost]: BrowserAutomationHostCapabilitySchema.optional(),
})
.passthrough()
@@ -4,6 +4,7 @@ import {
AgentSnapshotPayloadSchema,
AgentTimelineItemPayloadSchema,
ServerInfoStatusPayloadSchema,
SessionOutboundMessageSchema,
WSHelloMessageSchema,
} from "./messages.js";
@@ -75,6 +76,28 @@ describe("wire schema compatibility", () => {
]);
});
test("timeline replacement invalidation is opt-in and carries no timeline rows", () => {
expect(
WSHelloMessageSchema.parse({
type: "hello",
clientId: "capable-client",
clientType: "mobile",
protocolVersion: 1,
capabilities: { timeline_replacement_invalidation: true },
}).capabilities,
).toEqual({ timeline_replacement_invalidation: true });
expect(
SessionOutboundMessageSchema.parse({
type: "agent.timeline.replacement",
payload: { agentId: "agent-1", epoch: "epoch-2" },
}),
).toEqual({
type: "agent.timeline.replacement",
payload: { agentId: "agent-1", epoch: "epoch-2" },
});
});
test("server info strips unknown legacy features while accepting former turn identity", () => {
const parsed = ServerInfoStatusPayloadSchema.parse({
status: "server_info",