From 65cfe4bda488922205c562774a21e2cf16db6be7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 3 May 2026 13:57:48 +0700 Subject: [PATCH] Reduce agent timeline focus catch-up --- CHANGELOG.md | 5 +- .../hooks/use-agent-initialization.test.ts | 23 ++++++- .../app/src/hooks/use-agent-initialization.ts | 24 +++++-- .../src/server/agent/agent-manager.test.ts | 7 +- .../server/agent/agent-timeline-store.test.ts | 53 +++++++++++++++ .../src/server/agent/agent-timeline-store.ts | 68 ++++++++++--------- 6 files changed, 136 insertions(+), 44 deletions(-) create mode 100644 packages/server/src/server/agent/agent-timeline-store.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 056a8af33..3a95fcd80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.1.65-beta.4 - 2026-05-02 +## 0.1.65-beta.5 - 2026-05-03 ### Added @@ -31,6 +31,9 @@ - Cursor CLI and other ACP custom providers launch reliably. ([#628](https://github.com/getpaseo/paseo/pull/628)) - Daemon stays up when WebSocket clients disconnect mid-stream, and crashes now write a fatal log entry instead of disappearing silently. ([#613](https://github.com/getpaseo/paseo/pull/613) by [@yuruiz](https://github.com/yuruiz)) - Long agent timelines reconnect cleanly over the relay instead of looping through disconnects while catching up. ([#657](https://github.com/getpaseo/paseo/pull/657) by [@fireblue](https://github.com/fireblue)) +- Agent timelines refresh with smaller catch-up requests when you reopen an agent. +- Terminal snapshots flush reliably before clients reconnect. +- Workspace reconnects avoid unnecessary refresh work when the focused workspace is already current. - Voice dictation keeps recording when the agent tab loses focus. - OpenCode mode picker now lists agents available in every mode. ([#606](https://github.com/getpaseo/paseo/pull/606) by [@thisisryanswift](https://github.com/thisisryanswift)) - Codex plan approval panels no longer duplicate. diff --git a/packages/app/src/hooks/use-agent-initialization.test.ts b/packages/app/src/hooks/use-agent-initialization.test.ts index 9051ea9d2..e6e7bf543 100644 --- a/packages/app/src/hooks/use-agent-initialization.test.ts +++ b/packages/app/src/hooks/use-agent-initialization.test.ts @@ -24,7 +24,7 @@ afterEach(() => { }); describe("useAgentInitialization", () => { - it("always requests a canonical tail bootstrap even with an authoritative cursor", () => { + it("requests bounded canonical catch-up after the current cursor when authoritative history is loaded", () => { const client = makeClient(); useSessionStore.getState().initializeSession(serverId, client as never); useSessionStore @@ -43,6 +43,27 @@ describe("useAgentInitialization", () => { void result.current.ensureAgentIsInitialized(agentId); }); + expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, { + direction: "after", + cursor: { epoch: "epoch-1", seq: 42 }, + limit: TIMELINE_FETCH_PAGE_SIZE, + projection: "canonical", + }); + expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("after"); + }); + + it("requests a bounded canonical tail when no authoritative cursor is available", () => { + const client = makeClient(); + useSessionStore.getState().initializeSession(serverId, client as never); + + const { result } = renderHook(() => + useAgentInitialization({ serverId, client: client as never }), + ); + + act(() => { + void result.current.ensureAgentIsInitialized(agentId); + }); + expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, { direction: "tail", limit: TIMELINE_FETCH_PAGE_SIZE, diff --git a/packages/app/src/hooks/use-agent-initialization.ts b/packages/app/src/hooks/use-agent-initialization.ts index b53e95e23..cad77c880 100644 --- a/packages/app/src/hooks/use-agent-initialization.ts +++ b/packages/app/src/hooks/use-agent-initialization.ts @@ -42,13 +42,25 @@ export function useAgentInitialization({ return existing.promise; } - const timelineRequest = { - direction: "tail" as const, - limit: TIMELINE_FETCH_PAGE_SIZE, - projection: "canonical" as const, - }; + const session = useSessionStore.getState().sessions[serverId]; + const cursor = session?.agentTimelineCursor.get(agentId); + const hasAuthoritativeHistory = + session?.agentAuthoritativeHistoryApplied.get(agentId) === true; + const timelineRequest = + hasAuthoritativeHistory && cursor + ? { + direction: "after" as const, + cursor: { epoch: cursor.epoch, seq: cursor.endSeq }, + limit: TIMELINE_FETCH_PAGE_SIZE, + projection: "canonical" as const, + } + : { + direction: "tail" as const, + limit: TIMELINE_FETCH_PAGE_SIZE, + projection: "canonical" as const, + }; - const deferred = createInitDeferred(key, "tail"); + const deferred = createInitDeferred(key, timelineRequest.direction); const timeoutId = setTimeout(() => { setAgentInitializing(agentId, false); rejectInitDeferred( diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 3aacd2ade..bb4f76feb 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -1749,7 +1749,7 @@ test("reloadAgentSession cancels active run and resumes existing session once th } }); -test("fetchTimeline returns full timeline with reset when cursor epoch is stale", async () => { +test("fetchTimeline returns a bounded reset window when cursor epoch is stale", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-stale-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); @@ -1798,9 +1798,10 @@ test("fetchTimeline returns full timeline with reset when cursor epoch is stale" expect(result.reset).toBe(true); expect(result.staleCursor).toBe(true); expect(result.gap).toBe(false); - expect(result.rows).toHaveLength(3); - expect(result.rows[0]?.seq).toBe(1); + expect(result.rows).toHaveLength(1); + expect(result.rows[0]?.seq).toBe(3); expect(result.rows[result.rows.length - 1]?.seq).toBe(3); + expect(result.hasOlder).toBe(true); }); test("getTimelineRows falls back to the in-memory timeline when no durable store is configured", async () => { diff --git a/packages/server/src/server/agent/agent-timeline-store.test.ts b/packages/server/src/server/agent/agent-timeline-store.test.ts new file mode 100644 index 000000000..b033a856e --- /dev/null +++ b/packages/server/src/server/agent/agent-timeline-store.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js"; + +describe("InMemoryAgentTimelineStore", () => { + it("returns a bounded reset window when an after cursor is behind retained history", () => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("agent-1", { + epoch: "epoch-1", + nextSeq: 8, + rows: [ + { + seq: 5, + timestamp: "2026-01-01T00:00:00.000Z", + item: { type: "assistant_message", text: "five" }, + }, + { + seq: 6, + timestamp: "2026-01-01T00:00:01.000Z", + item: { type: "assistant_message", text: "six" }, + }, + { + seq: 7, + timestamp: "2026-01-01T00:00:02.000Z", + item: { type: "assistant_message", text: "seven" }, + }, + ], + }); + + const result = store.fetch("agent-1", { + direction: "after", + cursor: { epoch: "epoch-1", seq: 1 }, + limit: 1, + }); + + expect(result).toEqual({ + epoch: "epoch-1", + direction: "after", + reset: true, + staleCursor: false, + gap: true, + window: { minSeq: 5, maxSeq: 7, nextSeq: 8 }, + hasOlder: true, + hasNewer: false, + rows: [ + { + seq: 7, + timestamp: "2026-01-01T00:00:02.000Z", + item: { type: "assistant_message", text: "seven" }, + }, + ], + }); + }); +}); diff --git a/packages/server/src/server/agent/agent-timeline-store.ts b/packages/server/src/server/agent/agent-timeline-store.ts index d015dc82d..17bcde1a6 100644 --- a/packages/server/src/server/agent/agent-timeline-store.ts +++ b/packages/server/src/server/agent/agent-timeline-store.ts @@ -121,6 +121,28 @@ function fetchBefore(ctx: FetchContext): AgentTimelineFetchResult { }; } +function fetchReset( + ctx: FetchContext, + flags: { staleCursor: boolean; gap: boolean }, +): AgentTimelineFetchResult { + const { state, direction, limit, selectAll, minSeq, window } = ctx; + const rows = + selectAll || limit >= state.rows.length + ? state.rows.map(cloneRow) + : state.rows.slice(state.rows.length - limit).map(cloneRow); + return { + epoch: state.epoch, + direction, + reset: true, + staleCursor: flags.staleCursor, + gap: flags.gap, + window, + hasOlder: rows.length > 0 && rows[0]!.seq > minSeq, + hasNewer: false, + rows, + }; +} + export class InMemoryAgentTimelineStore { private readonly states = new Map(); @@ -176,32 +198,23 @@ export class InMemoryAgentTimelineStore { nextSeq: state.nextSeq, }; + const ctx: FetchContext = { + state, + direction, + limit, + selectAll, + cursor, + minSeq, + maxSeq, + window, + }; + if (cursor && typeof cursor.epoch === "string" && cursor.epoch !== state.epoch) { - return { - epoch: state.epoch, - direction, - reset: true, - staleCursor: true, - gap: false, - window, - hasOlder: false, - hasNewer: false, - rows: state.rows.map(cloneRow), - }; + return fetchReset(ctx, { staleCursor: true, gap: false }); } if (direction === "after" && cursor && state.rows.length > 0 && cursor.seq < minSeq - 1) { - return { - epoch: state.epoch, - direction, - reset: true, - staleCursor: false, - gap: true, - window, - hasOlder: false, - hasNewer: false, - rows: state.rows.map(cloneRow), - }; + return fetchReset(ctx, { staleCursor: false, gap: true }); } if (state.rows.length === 0) { @@ -218,17 +231,6 @@ export class InMemoryAgentTimelineStore { }; } - const ctx: FetchContext = { - state, - direction, - limit, - selectAll, - cursor, - minSeq, - maxSeq, - window, - }; - if (direction === "tail") { return fetchTail(ctx); }