Reduce agent timeline focus catch-up

This commit is contained in:
Mohamed Boudra
2026-05-03 13:57:48 +07:00
parent fa20f313d3
commit 65cfe4bda4
6 changed files with 136 additions and 44 deletions
+4 -1
View File
@@ -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.
@@ -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,
@@ -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(
@@ -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 () => {
@@ -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" },
},
],
});
});
});
@@ -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<string, AgentTimelineState>();
@@ -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);
}