Reduce agent timeline focus catch-up
This commit is contained in:
+4
-1
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user