From f9f6ff2dbc7ffeef82f25c47933c5005cb255137 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 1 Jun 2026 11:17:19 +0700 Subject: [PATCH] Restore canonical timeline fetches Older clients still request canonical rows for catch-up. Honor that explicit request while leaving projected as the default and projected app sync path. Update the pending 0.1.88 changelog with the fixes already on main. --- CHANGELOG.md | 4 + .../daemon-e2e/timeline-window.e2e.test.ts | 110 ++++++++++++------ packages/server/src/server/session.ts | 110 ++++++++++++++---- 3 files changed, 169 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c251f5393..ee1a2905f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,15 @@ - **Adjust the interface text size** - **Adjust the code text size** - **Choose a syntax highlighting theme** +- **Keep cron schedules aligned to a chosen time zone** ([#1232](https://github.com/getpaseo/paseo/pull/1232) by [@damselem](https://github.com/damselem)) ### Improved - Settings now has a flatter sidebar with a host picker - Workspace tab switching is faster +- Compact composers now show context usage as a percentage - Agent terminals opened in workspace subdirectories now appear with the rest of the workspace terminals +- macOS displays can idle normally while the desktop app is open ([#1242](https://github.com/getpaseo/paseo/pull/1242) by [@fireblue](https://github.com/fireblue)) - Large generated diffs now show a clear too-large placeholder instead of trying to render the whole file ### Fixed @@ -24,6 +27,7 @@ - Terminal panes keep the right size after splitting or resizing panes - Restored terminal snapshots reflow correctly after the pane size changes - Workspace scripts menus keep the right size after launching a service +- iOS chat messages no longer hide inline links, URLs, or linked file paths ([#1257](https://github.com/getpaseo/paseo/pull/1257) by [@outofrange-consulting](https://github.com/outofrange-consulting)) ## 0.1.87 - 2026-05-30 diff --git a/packages/server/src/server/daemon-e2e/timeline-window.e2e.test.ts b/packages/server/src/server/daemon-e2e/timeline-window.e2e.test.ts index eb4322dba..409a12261 100644 --- a/packages/server/src/server/daemon-e2e/timeline-window.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/timeline-window.e2e.test.ts @@ -9,6 +9,42 @@ function tmpCwd(): string { return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); } +async function appendToolLifecycleAcrossCursor( + ctx: DaemonTestContext, + agentId: string, +): Promise { + await ctx.daemon.daemon.agentManager.appendTimelineItem(agentId, { + type: "tool_call", + callId: "call_1", + name: "shell", + status: "running", + error: null, + detail: { + type: "unknown", + input: { cmd: "sleep 10" }, + output: null, + }, + }); + for (let seq = 2; seq <= 249; seq += 1) { + await ctx.daemon.daemon.agentManager.appendTimelineItem(agentId, { + type: "assistant_message", + text: `background ${seq}`, + }); + } + await ctx.daemon.daemon.agentManager.appendTimelineItem(agentId, { + type: "tool_call", + callId: "call_1", + name: "shell", + status: "completed", + error: null, + detail: { + type: "unknown", + input: { cmd: "sleep 10" }, + output: { stdout: "done" }, + }, + }); +} + describe("daemon E2E - timeline window", () => { let ctx: DaemonTestContext; @@ -124,7 +160,7 @@ describe("daemon E2E - timeline window", () => { const timeline = await ctx.client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 100, - projection: "canonical", + projection: "projected", }); const toolEntries = timeline.entries.filter((entry) => entry.item.type === "tool_call"); @@ -142,46 +178,17 @@ describe("daemon E2E - timeline window", () => { } }); - test("after fetch returns the full projected tool item for a new lifecycle update", async () => { + test("canonical after fetch returns only committed rows after the cursor", async () => { const cwd = tmpCwd(); try { const agent = await ctx.client.createAgent({ provider: "codex", cwd, - title: "Timeline Tool Catch-up Test", + title: "Timeline Canonical Catch-up Test", modeId: "full-access", }); - await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, { - type: "tool_call", - callId: "call_1", - name: "shell", - status: "running", - error: null, - detail: { - type: "unknown", - input: { cmd: "sleep 10" }, - output: null, - }, - }); - for (let seq = 2; seq <= 249; seq += 1) { - await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, { - type: "assistant_message", - text: `background ${seq}`, - }); - } - await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, { - type: "tool_call", - callId: "call_1", - name: "shell", - status: "completed", - error: null, - detail: { - type: "unknown", - input: { cmd: "sleep 10" }, - output: { stdout: "done" }, - }, - }); + await appendToolLifecycleAcrossCursor(ctx, agent.id); const baseline = await ctx.client.fetchAgentTimeline(agent.id, { direction: "tail", @@ -194,6 +201,43 @@ describe("daemon E2E - timeline window", () => { projection: "canonical", }); + expect(timeline.projection).toBe("canonical"); + expect(timeline.entries).toHaveLength(1); + expect(timeline.startCursor?.seq).toBe(250); + expect(timeline.endCursor?.seq).toBe(250); + expect(timeline.entries[0]?.seqStart).toBe(250); + expect(timeline.entries[0]?.seqEnd).toBe(250); + expect(timeline.entries[0]?.sourceSeqRanges).toEqual([{ startSeq: 250, endSeq: 250 }]); + expect(timeline.entries[0]?.item.type).toBe("tool_call"); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test("projected after fetch returns the full projected tool item for a new lifecycle update", async () => { + const cwd = tmpCwd(); + try { + const agent = await ctx.client.createAgent({ + provider: "codex", + cwd, + title: "Timeline Tool Catch-up Test", + modeId: "full-access", + }); + + await appendToolLifecycleAcrossCursor(ctx, agent.id); + + const baseline = await ctx.client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + }); + const timeline = await ctx.client.fetchAgentTimeline(agent.id, { + direction: "after", + cursor: { epoch: baseline.epoch, seq: 249 }, + limit: 100, + projection: "projected", + }); + + expect(timeline.projection).toBe("projected"); expect(timeline.entries).toHaveLength(1); expect(timeline.startCursor?.seq).toBe(250); expect(timeline.endCursor?.seq).toBe(250); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index ffc31024a..174cb5c41 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -121,7 +121,9 @@ import { emitLiveTimelineItemIfAgentKnown, } from "./agent/timeline-append.js"; import { + projectTimelineRows, selectProjectedTimelinePage, + type TimelineProjectionEntry, type TimelineProjectionMode, } from "./agent/timeline-projection.js"; import { @@ -716,6 +718,15 @@ function parseClientCapabilities( return new Set(result); } +interface AgentTimelineProjectionSelection { + timeline: AgentTimelineFetchResult; + entries: TimelineProjectionEntry[]; + startSeq: number | null; + endSeq: number | null; + hasOlder: boolean; + hasNewer: boolean; +} + /** * Session represents a single connected client session. * It owns all state management, orchestration logic, and message processing. @@ -7437,11 +7448,70 @@ export class Session { return timeline.rows.some((row) => row.item.type === "tool_call"); } + private selectCanonicalTimelineProjection(input: { + timeline: AgentTimelineFetchResult; + }): AgentTimelineProjectionSelection { + const entries = projectTimelineRows({ rows: input.timeline.rows, mode: "canonical" }); + return { + timeline: input.timeline, + entries, + startSeq: entries[0]?.seqStart ?? null, + endSeq: entries[entries.length - 1]?.seqEnd ?? null, + hasOlder: input.timeline.hasOlder, + hasNewer: input.timeline.hasNewer, + }; + } + + private selectProjectedTimelineProjection(input: { + agentId: string; + controlTimeline: AgentTimelineFetchResult; + direction: AgentTimelineFetchDirection; + cursor?: AgentTimelineCursor; + pageLimit: number; + }): AgentTimelineProjectionSelection { + const timeline = this.shouldUseFullTimelineForProjectedPage({ + timeline: input.controlTimeline, + }) + ? this.agentManager.fetchTimeline(input.agentId, { direction: "tail", limit: 0 }) + : input.controlTimeline; + const page = selectProjectedTimelinePage({ + rows: timeline.rows, + bounds: timeline.window, + direction: input.controlTimeline.reset ? "tail" : input.direction, + ...(input.cursor ? { cursorSeq: input.cursor.seq } : {}), + limit: input.pageLimit, + }); + + return { + timeline, + entries: page.entries, + startSeq: page.startSeq, + endSeq: page.endSeq, + hasOlder: page.hasOlder || (page.startSeq !== null && page.startSeq > timeline.window.minSeq), + hasNewer: page.hasNewer, + }; + } + + private selectTimelineProjection(input: { + agentId: string; + projection: TimelineProjectionMode; + controlTimeline: AgentTimelineFetchResult; + direction: AgentTimelineFetchDirection; + cursor?: AgentTimelineCursor; + pageLimit: number; + }): AgentTimelineProjectionSelection { + if (input.projection === "canonical") { + return this.selectCanonicalTimelineProjection({ timeline: input.controlTimeline }); + } + + return this.selectProjectedTimelineProjection(input); + } + private async handleFetchAgentTimelineRequest( msg: Extract, ): Promise { const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail"); - const projection: TimelineProjectionMode = "projected"; + const projection: TimelineProjectionMode = msg.projection ?? "projected"; const requestedLimit = msg.limit; const pageLimit = requestedLimit ?? (direction === "after" ? 0 : 200); const cursor: AgentTimelineCursor | undefined = msg.cursor @@ -7464,24 +7534,22 @@ export class Session { cursor, limit: pageLimit, }); - const timeline = this.shouldUseFullTimelineForProjectedPage({ - timeline: controlTimeline, - }) - ? this.agentManager.fetchTimeline(msg.agentId, { direction: "tail", limit: 0 }) - : controlTimeline; - const projectedPage = selectProjectedTimelinePage({ - rows: timeline.rows, - bounds: timeline.window, - direction: controlTimeline.reset ? "tail" : direction, - ...(cursor ? { cursorSeq: cursor.seq } : {}), - limit: pageLimit, + const selectedTimeline = this.selectTimelineProjection({ + agentId: msg.agentId, + projection, + controlTimeline, + direction, + ...(cursor ? { cursor } : {}), + pageLimit, }); const startCursor = - projectedPage.startSeq !== null - ? { epoch: timeline.epoch, seq: projectedPage.startSeq } + selectedTimeline.startSeq !== null + ? { epoch: selectedTimeline.timeline.epoch, seq: selectedTimeline.startSeq } : null; const endCursor = - projectedPage.endSeq !== null ? { epoch: timeline.epoch, seq: projectedPage.endSeq } : null; + selectedTimeline.endSeq !== null + ? { epoch: selectedTimeline.timeline.epoch, seq: selectedTimeline.endSeq } + : null; this.emit({ type: "fetch_agent_timeline_response", @@ -7491,18 +7559,16 @@ export class Session { agent: agentPayload, direction, projection, - epoch: timeline.epoch, + epoch: selectedTimeline.timeline.epoch, reset: controlTimeline.reset, staleCursor: controlTimeline.staleCursor, gap: controlTimeline.gap, - window: timeline.window, + window: selectedTimeline.timeline.window, startCursor, endCursor, - hasOlder: - projectedPage.hasOlder || - (projectedPage.startSeq !== null && projectedPage.startSeq > timeline.window.minSeq), - hasNewer: projectedPage.hasNewer, - entries: projectedPage.entries.map((entry) => ({ + hasOlder: selectedTimeline.hasOlder, + hasNewer: selectedTimeline.hasNewer, + entries: selectedTimeline.entries.map((entry) => ({ provider: snapshot.provider, item: entry.item, timestamp: entry.timestamp,