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.
This commit is contained in:
Mohamed Boudra
2026-06-01 11:17:19 +07:00
parent 3095bcb760
commit f9f6ff2dbc
3 changed files with 169 additions and 55 deletions

View File

@@ -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

View File

@@ -9,6 +9,42 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
async function appendToolLifecycleAcrossCursor(
ctx: DaemonTestContext,
agentId: string,
): Promise<void> {
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);

View File

@@ -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<SessionInboundMessage, { type: "fetch_agent_timeline_request" }>,
): Promise<void> {
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,