mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 02:02:42 +00:00
fix(agents): prevent OOM from cumulative tool history (#4838)
* fix(agents): prevent OOM from cumulative tool history Repeated progress updates retained complete historical copies of growing tool output. Retain projected items on ingestion and serve them for every history fetch, preserving source sequence coverage for catch-up. Keep intermediate payloads only in streaming; confine compatibility degradation to child transcripts and overwritten fork checkpoints. * test(client): expect projected subagent capability in hello * fix(app): report unexpected child history failures Keep connection failures recoverable while forwarding unexpected fetch and reconciliation errors to diagnostics. Name observer arguments to keep server and agent identities explicit. * test(agents): assert projected history after rewind * docs(protocol): tag projected child compatibility gates
This commit is contained in:
+22
-3
@@ -5,8 +5,10 @@ Agent chat delivery has two paths:
|
||||
1. **Live stream** — `agent_stream` WebSocket messages for immediacy. These may be delta-shaped lifecycle updates.
|
||||
2. **Authoritative history** — `fetch_agent_timeline_request` for correctness. This always returns full projected timeline items, never lifecycle deltas.
|
||||
|
||||
The daemon keeps canonical rows only for its runtime. Provider history is the durable transcript
|
||||
authority and repopulates those rows when an agent resumes.
|
||||
The daemon retains projected items in memory. Each source event advances the stream sequence,
|
||||
then replaces the previous tool state or merges into the current text item. Intermediate payloads
|
||||
are never retained for history or catch-up. Provider history is the durable transcript authority
|
||||
and rebuilds the projection when an agent resumes.
|
||||
|
||||
The invariants are:
|
||||
|
||||
@@ -67,7 +69,10 @@ Provider message IDs are not guaranteed for every displayed item. Paseo-generate
|
||||
|
||||
Actions that address a point in chat history, such as Fork, use the daemon timeline `epoch` plus the projected item's `seqEnd`. The app carries that position on the rendered assistant item for both live and fetched history. When adjacent projected chunks merge, the merged item retains the newer chunk's position.
|
||||
|
||||
The daemon validates that the epoch is current and the exact source sequence still exists before slicing rows. It slices before projection so later lifecycle updates cannot leak into the selected context.
|
||||
The daemon validates the epoch and locates the projected item at the selected position. A fork
|
||||
includes projected items through that checkpoint. If an item spans the checkpoint and changed
|
||||
afterward, the daemon refuses that fork with an actionable error: discarded historical payloads
|
||||
cannot be reconstructed from sequence metadata. Forking the current context remains available.
|
||||
|
||||
## Resume behavior
|
||||
|
||||
@@ -93,6 +98,20 @@ history replay. The pending approval UI can hide that tool from presentation, bu
|
||||
must retain its position. Creating a new history card on rejection places it after the prompt that
|
||||
rejected it; changing steer-event ordering would also put new assistant output before that prompt.
|
||||
|
||||
## Provider child history
|
||||
|
||||
Child transcripts use the same projected-page reconciliation as the main conversation. The client
|
||||
retains rendered items and sequence cursors, never a second cache of source events. Pagination
|
||||
uses the projected display anchor; live updates advance the source cursor without moving tools.
|
||||
|
||||
Clients advertising `projected_subagent_timeline` receive child streams and projected fetches.
|
||||
Updated clients require `features.projectedSubagentTimeline` for child history; older hosts show
|
||||
an update-host notice for that pane.
|
||||
Older clients retain child names/status and receive an upgrade message when opening a child
|
||||
conversation. This gate affects only the child transcript; it does not gate the connection,
|
||||
main conversation, or current-context forks. A legacy `canonical` root request still receives
|
||||
projected items.
|
||||
|
||||
## Client replica lifetime
|
||||
|
||||
The session projection remains host-scoped for as long as the host is registered. The viewed-timeline
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
providerSubagentKey,
|
||||
providerSubagentLifecycleStatus,
|
||||
refreshProviderSubagents,
|
||||
observeProviderSubagentTimeline,
|
||||
useProviderSubagentStore,
|
||||
} from "@/subagents/provider-store";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -126,8 +127,8 @@ function ProviderSubagentPanel() {
|
||||
);
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
const serverInfo = useSessionStore((state) => state.sessions[serverId]?.serverInfo ?? null);
|
||||
// COMPAT(providerSubagents): added in v0.2.11, remove after 2027-01-12.
|
||||
const supported = serverInfo?.features?.providerSubagents === true;
|
||||
// COMPAT(projectedSubagentTimeline): added after v0.8.0, remove after 2027-03-14.
|
||||
const supported = serverInfo?.features?.projectedSubagentTimeline === true;
|
||||
const [isLoadingOlder, setIsLoadingOlder] = useState(false);
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const childRows = useSubagentsForParent({
|
||||
@@ -150,23 +151,28 @@ function ProviderSubagentPanel() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !supported) return;
|
||||
void client
|
||||
.fetchProviderSubagentTimeline(target.parentAgentId, target.subagentId, {
|
||||
direction: "tail",
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
})
|
||||
.then((payload) => {
|
||||
useProviderSubagentStore.getState().replaceTimeline(serverId, payload);
|
||||
return undefined;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, [client, serverId, supported, target.parentAgentId, target.subagentId]);
|
||||
return observeProviderSubagentTimeline({
|
||||
client,
|
||||
serverId,
|
||||
parentAgentId: target.parentAgentId,
|
||||
subagentId: target.subagentId,
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
reportError: (error) => {
|
||||
console.error("[ProviderSubagentTimeline] Failed to refresh child history", {
|
||||
error,
|
||||
serverId,
|
||||
parentAgentId: target.parentAgentId,
|
||||
subagentId: target.subagentId,
|
||||
});
|
||||
},
|
||||
});
|
||||
}, [client, supported, serverId, target.parentAgentId, target.subagentId]);
|
||||
|
||||
const loadOlder = useCallback((): boolean => {
|
||||
if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) {
|
||||
return false;
|
||||
}
|
||||
const firstSeq = timeline.rows.size ? Math.min(...timeline.rows.keys()) : null;
|
||||
const firstSeq = timeline.cursor?.startSeq ?? null;
|
||||
if (firstSeq === null) return false;
|
||||
setIsLoadingOlder(true);
|
||||
void client
|
||||
@@ -191,7 +197,7 @@ function ProviderSubagentPanel() {
|
||||
target.subagentId,
|
||||
timeline,
|
||||
]);
|
||||
const firstTimelineSeq = timeline?.rows.size ? Math.min(...timeline.rows.keys()) : null;
|
||||
const firstTimelineSeq = timeline?.cursor?.startSeq ?? null;
|
||||
const progressKey =
|
||||
timeline?.epoch && firstTimelineSeq !== null ? `${timeline.epoch}:${firstTimelineSeq}` : null;
|
||||
const subtitle = descriptor?.subtitle?.trim();
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { providerSubagentKey, useProviderSubagentStore } from "./provider-store";
|
||||
import {
|
||||
observeProviderSubagentTimeline,
|
||||
providerSubagentKey,
|
||||
useProviderSubagentStore,
|
||||
} from "./provider-store";
|
||||
|
||||
const SERVER_ID = "server-1";
|
||||
const PARENT_ID = "parent-1";
|
||||
@@ -41,6 +46,7 @@ describe("provider subagent client store", () => {
|
||||
item: { type: "assistant_message", text: "New live output." },
|
||||
});
|
||||
subagents.replaceTimeline(SERVER_ID, {
|
||||
projection: "projected",
|
||||
requestId: "history-1",
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
@@ -103,12 +109,12 @@ describe("provider subagent client store", () => {
|
||||
const state = useProviderSubagentStore.getState();
|
||||
expect(state.descriptors.get(key)?.status).toBe("completed");
|
||||
expect(state.timelines.get(key)?.head).toEqual([]);
|
||||
expect(state.timelines.get(key)?.tail).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "assistant_message",
|
||||
text: "Older history.New live output.",
|
||||
}),
|
||||
]);
|
||||
expect(
|
||||
state.timelines
|
||||
.get(key)
|
||||
?.tail.map((item) => (item.kind === "assistant_message" ? item.text : ""))
|
||||
.join(""),
|
||||
).toBe("Older history.New live output.");
|
||||
});
|
||||
|
||||
test("removes timelines for children no longer returned by the provider", () => {
|
||||
@@ -325,6 +331,7 @@ describe("provider subagent client store", () => {
|
||||
test("merges bounded older pages and tracks whether more history remains", () => {
|
||||
const store = useProviderSubagentStore.getState();
|
||||
store.replaceTimeline(SERVER_ID, {
|
||||
projection: "projected",
|
||||
requestId: "tail-page",
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
@@ -347,6 +354,7 @@ describe("provider subagent client store", () => {
|
||||
error: null,
|
||||
});
|
||||
store.replaceTimeline(SERVER_ID, {
|
||||
projection: "projected",
|
||||
requestId: "older-page",
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
@@ -373,8 +381,8 @@ describe("provider subagent client store", () => {
|
||||
.getState()
|
||||
.timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID));
|
||||
expect(timeline?.hasOlder).toBe(false);
|
||||
expect([...timeline!.rows.keys()]).toEqual([2, 1]);
|
||||
expect(timeline?.head).toEqual([
|
||||
expect(timeline?.cursor).toMatchObject({ startSeq: 1, endSeq: 2 });
|
||||
expect([...(timeline?.tail ?? []), ...(timeline?.head ?? [])]).toEqual([
|
||||
expect.objectContaining({ kind: "assistant_message", text: "Older output.Recent output." }),
|
||||
]);
|
||||
});
|
||||
@@ -382,6 +390,7 @@ describe("provider subagent client store", () => {
|
||||
test("ignores delayed live updates from a stale timeline epoch", () => {
|
||||
const store = useProviderSubagentStore.getState();
|
||||
store.replaceTimeline(SERVER_ID, {
|
||||
projection: "projected",
|
||||
requestId: "current-page",
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
@@ -419,8 +428,8 @@ describe("provider subagent client store", () => {
|
||||
.getState()
|
||||
.timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID));
|
||||
expect(timeline?.epoch).toBe("epoch-current");
|
||||
expect([...timeline!.rows.keys()]).toEqual([2]);
|
||||
expect(timeline?.head).toEqual([
|
||||
expect(timeline?.cursor).toMatchObject({ startSeq: 2, endSeq: 2 });
|
||||
expect([...(timeline?.tail ?? []), ...(timeline?.head ?? [])]).toEqual([
|
||||
expect.objectContaining({ kind: "assistant_message", text: "Current output." }),
|
||||
]);
|
||||
});
|
||||
@@ -428,6 +437,7 @@ describe("provider subagent client store", () => {
|
||||
test("replaces cached rows with an authoritative tail page after a reconnect gap", () => {
|
||||
const store = useProviderSubagentStore.getState();
|
||||
store.replaceTimeline(SERVER_ID, {
|
||||
projection: "projected",
|
||||
requestId: "old-tail",
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
@@ -450,6 +460,7 @@ describe("provider subagent client store", () => {
|
||||
error: null,
|
||||
});
|
||||
store.replaceTimeline(SERVER_ID, {
|
||||
projection: "projected",
|
||||
requestId: "reconnect-tail",
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
@@ -475,9 +486,244 @@ describe("provider subagent client store", () => {
|
||||
const timeline = useProviderSubagentStore
|
||||
.getState()
|
||||
.timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID));
|
||||
expect([...timeline!.rows.keys()]).toEqual([401]);
|
||||
expect(timeline?.head).toEqual([
|
||||
expect(timeline?.cursor).toMatchObject({ startSeq: 401, endSeq: 401 });
|
||||
expect([...(timeline?.tail ?? []), ...(timeline?.head ?? [])]).toEqual([
|
||||
expect.objectContaining({ kind: "assistant_message", text: "Current tail output." }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("projected child history", () => {
|
||||
const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID);
|
||||
const timestamp = "2026-09-14T00:00:00.000Z";
|
||||
const current = () => useProviderSubagentStore.getState().timelines.get(key)!;
|
||||
const text = () =>
|
||||
[...current().tail, ...current().head]
|
||||
.map((item) => (item.kind === "assistant_message" ? item.text : ""))
|
||||
.join("");
|
||||
function stream(seq: number, value: string) {
|
||||
useProviderSubagentStore.getState().applyUpdate(SERVER_ID, {
|
||||
kind: "timeline",
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
provider: "codex",
|
||||
epoch: "e",
|
||||
seq,
|
||||
timestamp,
|
||||
item: { type: "assistant_message", messageId: "m", text: value },
|
||||
});
|
||||
}
|
||||
type Payload = Parameters<
|
||||
ReturnType<typeof useProviderSubagentStore.getState>["replaceTimeline"]
|
||||
>[1];
|
||||
function response(
|
||||
value: string,
|
||||
seqStart: number,
|
||||
seqEnd: number,
|
||||
direction: "tail" | "after" = "tail",
|
||||
): Payload {
|
||||
return {
|
||||
requestId: "r",
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
provider: "codex",
|
||||
epoch: "e",
|
||||
direction,
|
||||
projection: "projected",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 1, maxSeq: seqEnd, nextSeq: seqEnd + 1 },
|
||||
startCursor: { epoch: "e", seq: seqStart },
|
||||
endCursor: { epoch: "e", seq: seqEnd },
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
error: null,
|
||||
rows: [
|
||||
{
|
||||
seq: seqEnd,
|
||||
seqStart,
|
||||
seqEnd,
|
||||
sourceSeqRanges: [{ startSeq: seqStart, endSeq: seqEnd }],
|
||||
timestamp,
|
||||
item: { type: "assistant_message", messageId: "m", text: value },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
function page(
|
||||
value: string,
|
||||
seqStart: number,
|
||||
seqEnd: number,
|
||||
direction: "tail" | "after" = "tail",
|
||||
) {
|
||||
useProviderSubagentStore
|
||||
.getState()
|
||||
.replaceTimeline(SERVER_ID, response(value, seqStart, seqEnd, direction));
|
||||
}
|
||||
test("replaces overlapping fetched text and continues live streaming", () => {
|
||||
page("AB", 1, 2);
|
||||
stream(3, "C");
|
||||
page("ABCD", 1, 4, "after");
|
||||
expect(text()).toBe("ABCD");
|
||||
stream(5, "E");
|
||||
expect(text()).toBe("ABCDE");
|
||||
});
|
||||
test("reconciles a tail snapshot racing newer live text", () => {
|
||||
stream(1, "A");
|
||||
stream(2, "B");
|
||||
stream(3, "C");
|
||||
page("AB", 1, 2);
|
||||
page("ABC", 1, 3);
|
||||
expect(text()).toBe("ABC");
|
||||
expect(current().needsRefresh).toBe(false);
|
||||
});
|
||||
test("recovers a live sequence gap while observed and stops fetching when closed", async () => {
|
||||
let calls = 0;
|
||||
const client = {
|
||||
async fetchProviderSubagentTimeline() {
|
||||
calls++;
|
||||
return calls === 1 ? response("A", 1, 1) : response("ABC", 1, 3);
|
||||
},
|
||||
};
|
||||
const errors: unknown[] = [];
|
||||
const stop = observeProviderSubagentTimeline({
|
||||
client,
|
||||
serverId: SERVER_ID,
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
limit: 100,
|
||||
reportError: (error) => {
|
||||
errors.push(error);
|
||||
},
|
||||
});
|
||||
try {
|
||||
await expect.poll(() => current()?.cursor?.endSeq).toBe(1);
|
||||
stream(3, "C");
|
||||
await expect.poll(text).toBe("ABC");
|
||||
expect(calls).toBe(2);
|
||||
expect(current().needsRefresh).toBe(false);
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
stream(5, "E");
|
||||
await Promise.resolve();
|
||||
expect(calls).toBe(2);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
test("retries a disconnected history read on the next stream update", async () => {
|
||||
let calls = 0;
|
||||
const errors: unknown[] = [];
|
||||
const stop = observeProviderSubagentTimeline({
|
||||
client: {
|
||||
async fetchProviderSubagentTimeline() {
|
||||
calls++;
|
||||
if (calls === 2) throw new DaemonConnectionError("Connection lost");
|
||||
return calls === 1 ? response("A", 1, 1) : response("ABCD", 1, 4);
|
||||
},
|
||||
},
|
||||
serverId: SERVER_ID,
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
limit: 100,
|
||||
reportError: (error) => {
|
||||
errors.push(error);
|
||||
},
|
||||
});
|
||||
try {
|
||||
await expect.poll(() => current()?.cursor?.endSeq).toBe(1);
|
||||
stream(3, "C");
|
||||
await expect.poll(() => calls).toBe(2);
|
||||
expect(current().needsRefresh).toBe(true);
|
||||
stream(4, "D");
|
||||
await expect.poll(text).toBe("ABCD");
|
||||
expect(current().needsRefresh).toBe(false);
|
||||
expect(errors).toEqual([]);
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
});
|
||||
test("reports unexpected child history failures", async () => {
|
||||
const failure = new TypeError("Invalid child timeline response");
|
||||
const errors: unknown[] = [];
|
||||
const stop = observeProviderSubagentTimeline({
|
||||
client: {
|
||||
async fetchProviderSubagentTimeline() {
|
||||
throw failure;
|
||||
},
|
||||
},
|
||||
serverId: SERVER_ID,
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
limit: 100,
|
||||
reportError: (error) => {
|
||||
errors.push(error);
|
||||
},
|
||||
});
|
||||
try {
|
||||
await expect.poll(() => errors).toEqual([failure]);
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
});
|
||||
test("keeps a completed tool at its original position after fetching its latest update", () => {
|
||||
const payload = response("Answer", 1, 5);
|
||||
payload.rows = [
|
||||
{
|
||||
seq: 5,
|
||||
seqStart: 1,
|
||||
seqEnd: 5,
|
||||
sourceSeqRanges: [
|
||||
{ startSeq: 1, endSeq: 1 },
|
||||
{ startSeq: 5, endSeq: 5 },
|
||||
],
|
||||
timestamp,
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "t",
|
||||
name: "shell",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "plain_text", label: "work" },
|
||||
},
|
||||
},
|
||||
{
|
||||
seq: 4,
|
||||
seqStart: 2,
|
||||
seqEnd: 4,
|
||||
sourceSeqRanges: [{ startSeq: 2, endSeq: 4 }],
|
||||
timestamp,
|
||||
item: { type: "assistant_message", text: "Answer", messageId: "m" },
|
||||
},
|
||||
];
|
||||
useProviderSubagentStore.getState().replaceTimeline(SERVER_ID, payload);
|
||||
expect([...current().tail, ...current().head].map((item) => item.kind)).toEqual([
|
||||
"tool_call",
|
||||
"assistant_message",
|
||||
]);
|
||||
});
|
||||
test("retains projected display state rather than cumulative tool snapshots", () => {
|
||||
for (let seq = 1; seq <= 2000; seq++) {
|
||||
useProviderSubagentStore.getState().applyUpdate(SERVER_ID, {
|
||||
kind: "timeline",
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
provider: "codex",
|
||||
epoch: "e",
|
||||
seq,
|
||||
timestamp,
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "tool",
|
||||
name: "task",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: { type: "sub_agent", description: "Child", log: "x".repeat(seq * 128) },
|
||||
},
|
||||
});
|
||||
}
|
||||
expect([...current().tail, ...current().head]).toHaveLength(1);
|
||||
expect(JSON.stringify(current()).length).toBeLessThan(1_000_000);
|
||||
expect(current().lastSeq).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,30 +3,25 @@ import type {
|
||||
ProviderSubagentDescriptorPayload,
|
||||
SessionOutboundMessage,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { DaemonConnectionError, type DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { create } from "zustand";
|
||||
import { applyStreamEvent } from "@/types/stream";
|
||||
import {
|
||||
processTimelineResponse,
|
||||
processAgentStreamEvent,
|
||||
type TimelineCursor,
|
||||
} from "@/timeline/session-stream-reducers";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle";
|
||||
|
||||
type ProviderSubagentTimelineItem = Extract<
|
||||
Extract<SessionOutboundMessage, { type: "agent.provider_subagents.update" }>["payload"],
|
||||
{ kind: "timeline" }
|
||||
>["item"];
|
||||
|
||||
interface ProviderSubagentTimelineRow {
|
||||
provider: ProviderSubagentDescriptorPayload["provider"];
|
||||
item: ProviderSubagentTimelineItem;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface ProviderSubagentTimelineState {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
epoch: string | null;
|
||||
lastSeq: number;
|
||||
hasOlder: boolean;
|
||||
rows: Map<number, ProviderSubagentTimelineRow>;
|
||||
cursor: TimelineCursor | undefined;
|
||||
needsRefresh: boolean;
|
||||
}
|
||||
|
||||
interface ProviderSubagentState {
|
||||
@@ -112,7 +107,8 @@ const EMPTY_TIMELINE: ProviderSubagentTimelineState = {
|
||||
epoch: null,
|
||||
lastSeq: 0,
|
||||
hasOlder: false,
|
||||
rows: new Map(),
|
||||
cursor: undefined,
|
||||
needsRefresh: false,
|
||||
};
|
||||
|
||||
function providerSubagentTerminalEvent(
|
||||
@@ -130,68 +126,23 @@ function providerSubagentTerminalEvent(
|
||||
return { type: "turn_completed", provider: subagent.provider };
|
||||
}
|
||||
|
||||
function buildTimelineState(
|
||||
rows: ProviderSubagentTimelineState["rows"],
|
||||
epoch: string | null,
|
||||
function settleTimeline(
|
||||
timeline: ProviderSubagentTimelineState,
|
||||
descriptor?: ProviderSubagentDescriptorPayload,
|
||||
hasOlder = false,
|
||||
): ProviderSubagentTimelineState {
|
||||
let timeline = { tail: [] as StreamItem[], head: [] as StreamItem[] };
|
||||
for (const [, row] of [...rows].sort(([left], [right]) => left - right)) {
|
||||
timeline = applyStreamEvent({
|
||||
...timeline,
|
||||
event: { type: "timeline", provider: row.provider, item: row.item },
|
||||
timestamp: new Date(row.timestamp),
|
||||
});
|
||||
}
|
||||
const terminalEvent = descriptor ? providerSubagentTerminalEvent(descriptor) : null;
|
||||
if (terminalEvent && descriptor) {
|
||||
timeline = applyStreamEvent({
|
||||
...timeline,
|
||||
event: terminalEvent,
|
||||
timestamp: new Date(descriptor.updatedAt),
|
||||
});
|
||||
}
|
||||
const event = descriptor ? providerSubagentTerminalEvent(descriptor) : null;
|
||||
if (!event || !descriptor) return timeline;
|
||||
return {
|
||||
...timeline,
|
||||
epoch,
|
||||
lastSeq: rows.size ? Math.max(...rows.keys()) : 0,
|
||||
hasOlder,
|
||||
rows,
|
||||
...applyStreamEvent({
|
||||
tail: timeline.tail,
|
||||
head: timeline.head,
|
||||
event,
|
||||
timestamp: new Date(descriptor.updatedAt),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildTimelineResponseRows(
|
||||
existing: ProviderSubagentTimelineState | undefined,
|
||||
payload: Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "agent.provider_subagents.timeline.get.response" }
|
||||
>["payload"],
|
||||
provider: ProviderSubagentDescriptorPayload["provider"],
|
||||
): ProviderSubagentTimelineState["rows"] {
|
||||
const rows = new Map<number, ProviderSubagentTimelineRow>();
|
||||
for (const row of payload.rows) {
|
||||
rows.set(row.seq, { provider, item: row.item, timestamp: row.timestamp });
|
||||
}
|
||||
if (payload.reset || existing?.epoch !== payload.epoch) {
|
||||
return rows;
|
||||
}
|
||||
if (payload.direction !== "tail") {
|
||||
return new Map([...existing.rows, ...rows]);
|
||||
}
|
||||
|
||||
let nextSeq = payload.rows.length
|
||||
? Math.max(...payload.rows.map((row) => row.seq)) + 1
|
||||
: payload.window.maxSeq + 1;
|
||||
for (const [seq, row] of [...existing.rows].sort(([left], [right]) => left - right)) {
|
||||
if (seq < nextSeq) continue;
|
||||
if (seq !== nextSeq) break;
|
||||
rows.set(seq, row);
|
||||
nextSeq += 1;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export const useProviderSubagentStore = create<ProviderSubagentState>((set) => ({
|
||||
descriptors: new Map(),
|
||||
timelines: new Map(),
|
||||
@@ -229,10 +180,7 @@ export const useProviderSubagentStore = create<ProviderSubagentState>((set) => (
|
||||
const current = timelines.get(key);
|
||||
const previous = state.descriptors.get(key);
|
||||
if (current && previous?.status !== subagent.status) {
|
||||
timelines.set(
|
||||
key,
|
||||
buildTimelineState(current.rows, current.epoch, subagent, current.hasOlder),
|
||||
);
|
||||
timelines.set(key, settleTimeline(current, subagent));
|
||||
}
|
||||
}
|
||||
return { descriptors, timelines, hiddenFromTrack };
|
||||
@@ -257,10 +205,7 @@ export const useProviderSubagentStore = create<ProviderSubagentState>((set) => (
|
||||
const current = state.timelines.get(key);
|
||||
if (current && previous?.status !== payload.subagent.status) {
|
||||
timelines = new Map(state.timelines);
|
||||
timelines.set(
|
||||
key,
|
||||
buildTimelineState(current.rows, current.epoch, payload.subagent, current.hasOlder),
|
||||
);
|
||||
timelines.set(key, settleTimeline(current, payload.subagent));
|
||||
}
|
||||
return { descriptors, timelines, hiddenFromTrack };
|
||||
}
|
||||
@@ -281,30 +226,33 @@ export const useProviderSubagentStore = create<ProviderSubagentState>((set) => (
|
||||
if (payload.seq <= current.lastSeq) {
|
||||
return state;
|
||||
}
|
||||
const rows = new Map(current.rows);
|
||||
rows.set(payload.seq, {
|
||||
provider: payload.provider,
|
||||
item: payload.item,
|
||||
timestamp: payload.timestamp,
|
||||
});
|
||||
const descriptor = state.descriptors.get(key);
|
||||
const next =
|
||||
descriptor && descriptor.status !== "running"
|
||||
? buildTimelineState(rows, payload.epoch, descriptor, current.hasOlder)
|
||||
: applyStreamEvent({
|
||||
tail: current.tail,
|
||||
head: current.head,
|
||||
event: { type: "timeline", provider: payload.provider, item: payload.item },
|
||||
timestamp: new Date(payload.timestamp),
|
||||
});
|
||||
const timelines = new Map(state.timelines);
|
||||
timelines.set(key, {
|
||||
...next,
|
||||
const next = processAgentStreamEvent({
|
||||
currentTail: current.tail,
|
||||
currentHead: current.head,
|
||||
currentCursor: current.cursor,
|
||||
hasAuthoritativeBaseline: current.cursor !== undefined,
|
||||
event: { type: "timeline", provider: payload.provider, item: payload.item },
|
||||
timestamp: new Date(payload.timestamp),
|
||||
seq: payload.seq,
|
||||
epoch: payload.epoch,
|
||||
lastSeq: payload.seq,
|
||||
hasOlder: current.hasOlder,
|
||||
rows,
|
||||
});
|
||||
const timelines = new Map(state.timelines);
|
||||
timelines.set(
|
||||
key,
|
||||
settleTimeline(
|
||||
{
|
||||
tail: next.tail,
|
||||
head: next.head,
|
||||
epoch: payload.epoch,
|
||||
lastSeq: payload.seq,
|
||||
cursor: next.cursor ?? current.cursor,
|
||||
hasOlder: current.hasOlder,
|
||||
needsRefresh:
|
||||
current.needsRefresh || next.sideEffects.some((effect) => effect.type === "catch_up"),
|
||||
},
|
||||
state.descriptors.get(key),
|
||||
),
|
||||
);
|
||||
return { timelines };
|
||||
});
|
||||
},
|
||||
@@ -316,11 +264,115 @@ export const useProviderSubagentStore = create<ProviderSubagentState>((set) => (
|
||||
set((state) => {
|
||||
const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId);
|
||||
const existing = state.timelines.get(key);
|
||||
const rows = buildTimelineResponseRows(existing, payload, provider);
|
||||
const descriptor = state.descriptors.get(key);
|
||||
const current = existing ?? EMPTY_TIMELINE;
|
||||
// Normalize optional wire metadata at the child timeline boundary. Legacy
|
||||
// notices are complete singleton items; history always comes from a projected host.
|
||||
const entries = payload.rows.map((row) => ({
|
||||
...row,
|
||||
provider,
|
||||
seqStart: row.seqStart ?? row.seq,
|
||||
seqEnd: row.seqEnd ?? row.seq,
|
||||
}));
|
||||
const result = processTimelineResponse({
|
||||
payload: {
|
||||
...payload,
|
||||
agentId: payload.subagentId,
|
||||
projection: "projected",
|
||||
startCursor:
|
||||
payload.startCursor ??
|
||||
(entries.length ? { seq: Math.min(...entries.map((entry) => entry.seqStart)) } : null),
|
||||
endCursor:
|
||||
payload.endCursor ??
|
||||
(entries.length ? { seq: Math.max(...entries.map((entry) => entry.seqEnd)) } : null),
|
||||
entries,
|
||||
},
|
||||
currentTail: current.tail,
|
||||
currentHead: current.head,
|
||||
currentCursor: current.cursor,
|
||||
isInitializing: current.cursor === undefined,
|
||||
hasActiveInitDeferred: current.cursor === undefined,
|
||||
initRequestDirection: "tail",
|
||||
sendingClientMessageIds: [],
|
||||
});
|
||||
const timelines = new Map(state.timelines);
|
||||
timelines.set(key, buildTimelineState(rows, payload.epoch, descriptor, payload.hasOlder));
|
||||
timelines.set(
|
||||
key,
|
||||
settleTimeline(
|
||||
{
|
||||
tail: result.tail,
|
||||
head: result.head,
|
||||
epoch: payload.epoch,
|
||||
lastSeq:
|
||||
payload.reset || current.epoch !== payload.epoch
|
||||
? (result.cursor?.endSeq ?? 0)
|
||||
: Math.max(current.lastSeq, result.cursor?.endSeq ?? 0),
|
||||
cursor: result.cursor ?? undefined,
|
||||
hasOlder:
|
||||
result.older === "unchanged" ? current.hasOlder : result.older === "available",
|
||||
needsRefresh:
|
||||
result.sideEffects.some((effect) => effect.type === "catch_up") &&
|
||||
(payload.hasNewer || current.lastSeq > (result.cursor?.endSeq ?? 0)),
|
||||
},
|
||||
state.descriptors.get(key),
|
||||
),
|
||||
);
|
||||
return { timelines };
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
/** Owns child history bootstrap and recovery while a pane observes the child. */
|
||||
export function observeProviderSubagentTimeline({
|
||||
client,
|
||||
serverId,
|
||||
parentAgentId,
|
||||
subagentId,
|
||||
limit,
|
||||
reportError,
|
||||
}: {
|
||||
client: Pick<DaemonClient, "fetchProviderSubagentTimeline">;
|
||||
serverId: string;
|
||||
parentAgentId: string;
|
||||
subagentId: string;
|
||||
limit: number;
|
||||
reportError: (error: unknown) => void;
|
||||
}): () => void {
|
||||
const key = providerSubagentKey(serverId, parentAgentId, subagentId);
|
||||
let active = true;
|
||||
let fetching = false;
|
||||
const refresh = async () => {
|
||||
if (!active || fetching) return;
|
||||
fetching = true;
|
||||
let succeeded = false;
|
||||
try {
|
||||
const payload = await client.fetchProviderSubagentTimeline(parentAgentId, subagentId, {
|
||||
direction: "tail",
|
||||
limit,
|
||||
});
|
||||
if (active) useProviderSubagentStore.getState().replaceTimeline(serverId, payload);
|
||||
succeeded = true;
|
||||
} catch (error) {
|
||||
// A later stream update or reopening the pane retries a disconnected read.
|
||||
if (!(error instanceof DaemonConnectionError)) throw error;
|
||||
} finally {
|
||||
fetching = false;
|
||||
if (
|
||||
succeeded &&
|
||||
active &&
|
||||
useProviderSubagentStore.getState().timelines.get(key)?.needsRefresh
|
||||
)
|
||||
requestRefresh();
|
||||
}
|
||||
};
|
||||
const requestRefresh = () => {
|
||||
void refresh().catch(reportError);
|
||||
};
|
||||
const unsubscribe = useProviderSubagentStore.subscribe((state) => {
|
||||
if (state.timelines.get(key)?.needsRefresh) requestRefresh();
|
||||
});
|
||||
requestRefresh();
|
||||
return () => {
|
||||
active = false;
|
||||
unsubscribe();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@ export const DEFAULT_CLIENT_CAPABILITIES = {
|
||||
[CLIENT_CAPS.customModeIcons]: true,
|
||||
[CLIENT_CAPS.terminalReflowableSnapshot]: true,
|
||||
[CLIENT_CAPS.providerSubagents]: true,
|
||||
[CLIENT_CAPS.projectedSubagentTimeline]: true,
|
||||
[CLIENT_CAPS.projectUpdates]: true,
|
||||
[CLIENT_CAPS.compactProviderSnapshots]: true,
|
||||
[CLIENT_CAPS.providerSnapshotReferences]: true,
|
||||
|
||||
@@ -740,6 +740,7 @@ test("advertises client capabilities in hello", async () => {
|
||||
custom_mode_icons: true,
|
||||
project_updates: true,
|
||||
provider_subagents: true,
|
||||
projected_subagent_timeline: true,
|
||||
reasoning_merge_enum: true,
|
||||
terminal_reflowable_snapshot: true,
|
||||
timeline_notifications: true,
|
||||
|
||||
@@ -3137,6 +3137,10 @@ export class DaemonClient {
|
||||
subagentId: string,
|
||||
options: FetchProviderSubagentTimelineOptions = {},
|
||||
): Promise<ProviderSubagentTimelinePayload> {
|
||||
// COMPAT(projectedSubagentTimeline): added after v0.8.0, remove after 2027-03-14.
|
||||
if (this.lastServerInfoMessage?.features?.projectedSubagentTimeline !== true) {
|
||||
throw new Error("Update the host to view subagent conversations.");
|
||||
}
|
||||
const requestId = this.createRequestId(options.requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "agent.provider_subagents.timeline.get.request",
|
||||
|
||||
@@ -24,6 +24,8 @@ export const CLIENT_CAPS = {
|
||||
// COMPAT(providerSubagents): added in v0.1.107. The daemon emits provider-owned
|
||||
// child descriptors and timelines only to clients that understand the new messages.
|
||||
providerSubagents: "provider_subagents",
|
||||
// COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gates after 2027-03-14; retain wire capability.
|
||||
projectedSubagentTimeline: "projected_subagent_timeline",
|
||||
// COMPAT(projectUpdates): added in v0.1.109, remove gate after 2027-01-15.
|
||||
projectUpdates: "project_updates",
|
||||
// COMPAT(compactProviderSnapshots): added in v0.2.X. Capable clients receive
|
||||
|
||||
@@ -3579,6 +3579,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
agentForkContextCursor: z.boolean().optional(),
|
||||
// COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12.
|
||||
providerSubagents: z.boolean().optional(),
|
||||
// COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gates after 2027-03-14; retain wire field.
|
||||
projectedSubagentTimeline: z.boolean().optional(),
|
||||
// COMPAT(providerSubagentNesting): added in v0.7, remove gate after 2027-03-04.
|
||||
providerSubagentNesting: z.boolean().optional(),
|
||||
// COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12.
|
||||
@@ -4608,6 +4610,9 @@ export const ProviderSubagentTimelineResponseMessageSchema = z.object({
|
||||
provider: AgentProviderSchema.nullable(),
|
||||
direction: z.enum(["tail", "before", "after"]),
|
||||
epoch: z.string(),
|
||||
projection: z.literal("projected").optional(),
|
||||
startCursor: AgentTimelineCursorSchema.nullable().optional(),
|
||||
endCursor: AgentTimelineCursorSchema.nullable().optional(),
|
||||
reset: z.boolean(),
|
||||
staleCursor: z.boolean(),
|
||||
gap: z.boolean(),
|
||||
@@ -4623,6 +4628,16 @@ export const ProviderSubagentTimelineResponseMessageSchema = z.object({
|
||||
item: AgentTimelineItemPayloadSchema,
|
||||
timestamp: z.string(),
|
||||
seq: z.number().int().nonnegative(),
|
||||
seqStart: z.number().int().nonnegative().optional(),
|
||||
seqEnd: z.number().int().nonnegative().optional(),
|
||||
sourceSeqRanges: z
|
||||
.array(
|
||||
z.object({
|
||||
startSeq: z.number().int().nonnegative(),
|
||||
endSeq: z.number().int().nonnegative(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
),
|
||||
error: z.string().nullable(),
|
||||
|
||||
@@ -354,46 +354,44 @@ second line'`,
|
||||
expect(result.attachment.text).toContain("[Assistant] Done.");
|
||||
});
|
||||
|
||||
it("selects the fork boundary before collapsing later tool updates", () => {
|
||||
const result = buildAgentForkContextAttachment({
|
||||
boundaryMessageId: "assistant-1",
|
||||
rows: [
|
||||
row(1, { type: "user_message", text: "Run it", messageId: "user-1" }),
|
||||
row(
|
||||
2,
|
||||
toolCallItem({
|
||||
callId: "terminal-1",
|
||||
name: "terminal",
|
||||
status: "running",
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
label: "before boundary",
|
||||
},
|
||||
it("rejects a checkpoint whose projected tool state changed later", () => {
|
||||
expect(() =>
|
||||
buildAgentForkContextAttachment({
|
||||
boundaryMessageId: "assistant-1",
|
||||
rows: [
|
||||
row(1, { type: "user_message", text: "Run it", messageId: "user-1" }),
|
||||
row(
|
||||
2,
|
||||
toolCallItem({
|
||||
callId: "terminal-1",
|
||||
name: "terminal",
|
||||
status: "running",
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
label: "before boundary",
|
||||
},
|
||||
}),
|
||||
),
|
||||
row(3, {
|
||||
type: "assistant_message",
|
||||
text: "Partial result.",
|
||||
messageId: "assistant-1",
|
||||
}),
|
||||
),
|
||||
row(3, {
|
||||
type: "assistant_message",
|
||||
text: "Partial result.",
|
||||
messageId: "assistant-1",
|
||||
}),
|
||||
row(
|
||||
4,
|
||||
toolCallItem({
|
||||
callId: "terminal-1",
|
||||
name: "terminal",
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
label: "after boundary",
|
||||
},
|
||||
}),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.attachment.text).toContain("[Terminal] before boundary");
|
||||
expect(result.attachment.text).toContain("[Assistant] Partial result.");
|
||||
expect(result.attachment.text).not.toContain("after boundary");
|
||||
row(
|
||||
4,
|
||||
toolCallItem({
|
||||
callId: "terminal-1",
|
||||
name: "terminal",
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
label: "after boundary",
|
||||
},
|
||||
}),
|
||||
),
|
||||
],
|
||||
}),
|
||||
).toThrow("Fork from a later completed response");
|
||||
});
|
||||
|
||||
it("selects a synthetic assistant error by its timeline cursor", () => {
|
||||
|
||||
@@ -230,12 +230,12 @@ function selectForkContextRows(input: {
|
||||
boundaryCursor: { epoch: string; seq: number } | null;
|
||||
boundaryMessageId: string | null;
|
||||
} {
|
||||
const projectedRows = projectTimelineRows({ rows: input.rows, mode: "projected" });
|
||||
const boundaryCursor = input.cursorBoundary?.cursor ?? null;
|
||||
const boundaryMessageId = input.boundaryMessageId?.trim() || null;
|
||||
if (!boundaryCursor && !boundaryMessageId) {
|
||||
const projected = projectTimelineRows({ rows: input.rows, mode: "projected" });
|
||||
return {
|
||||
items: projected.map((entry) => entry.item),
|
||||
items: projectedRows.map((entry) => entry.item),
|
||||
boundaryCursor: null,
|
||||
boundaryMessageId: null,
|
||||
};
|
||||
@@ -248,8 +248,8 @@ function selectForkContextRows(input: {
|
||||
throw new Error("Selected timeline position is no longer available.");
|
||||
}
|
||||
const boundaryIndex = boundaryCursor
|
||||
? input.rows.findIndex((row) => row.seq === boundaryCursor.seq)
|
||||
: input.rows.findLastIndex(
|
||||
? projectedRows.findIndex((row) => row.seqEnd === boundaryCursor.seq)
|
||||
: projectedRows.findLastIndex(
|
||||
(row) => row.item.type === "assistant_message" && row.item.messageId === boundaryMessageId,
|
||||
);
|
||||
if (boundaryIndex < 0) {
|
||||
@@ -259,8 +259,13 @@ function selectForkContextRows(input: {
|
||||
: "Selected assistant message is no longer available.",
|
||||
);
|
||||
}
|
||||
const selectedRows = input.rows.slice(0, boundaryIndex + 1);
|
||||
const projected = projectTimelineRows({ rows: selectedRows, mode: "projected" });
|
||||
const boundarySeq = projectedRows[boundaryIndex].seqEnd;
|
||||
if (projectedRows.some((row) => row.seqStart <= boundarySeq && row.seqEnd > boundarySeq)) {
|
||||
throw new Error(
|
||||
"This checkpoint changed after it was created. Fork from a later completed response instead.",
|
||||
);
|
||||
}
|
||||
const projected = projectedRows.filter((row) => row.seqEnd <= boundarySeq);
|
||||
|
||||
return {
|
||||
items: projected.map((entry) => entry.item),
|
||||
|
||||
@@ -366,7 +366,16 @@ function getProjectedTimelineItems(rows: AgentTimelineRow[]): AgentTimelineItem[
|
||||
}
|
||||
|
||||
function expectContiguousRowSeqs(rows: AgentTimelineRow[], expected: number[]): void {
|
||||
expect(rows.map((row) => row.seq)).toEqual(expected);
|
||||
const projected = projectTimelineRows({ rows, mode: "projected" });
|
||||
const covered = projected.flatMap((row) =>
|
||||
row.sourceSeqRanges.flatMap((range) =>
|
||||
Array.from(
|
||||
{ length: range.endSeq - range.startSeq + 1 },
|
||||
(_, index) => range.startSeq + index,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect([...new Set(covered)].sort((a, b) => a - b)).toEqual(expected);
|
||||
}
|
||||
|
||||
function expectContiguousLiveSeqs(events: AgentManagerEvent[], expected: number[]): void {
|
||||
@@ -524,11 +533,10 @@ describe("target coalesced behavior", () => {
|
||||
const rows = await harness.manager.getTimelineRows(agentId);
|
||||
const events = getTimelineStreamEvents(harness.events, agentId);
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(events).toHaveLength(2);
|
||||
expect(getTimelineItems(rows)).toEqual([
|
||||
{ type: "assistant_message", text: "x" },
|
||||
{ type: "assistant_message", text: "x".repeat(999) },
|
||||
{ type: "assistant_message", text: "x".repeat(1000) },
|
||||
]);
|
||||
expect(events.map((event) => (event.type === "agent_stream" ? event.event : null))).toEqual([
|
||||
{
|
||||
@@ -567,10 +575,7 @@ describe("target coalesced behavior", () => {
|
||||
const rows = await harness.manager.getTimelineRows(agentId);
|
||||
const events = getTimelineStreamEvents(harness.events, agentId);
|
||||
|
||||
expect(getTimelineItems(rows)).toEqual([
|
||||
{ type: "reasoning", text: "r" },
|
||||
{ type: "reasoning", text: "r".repeat(99) },
|
||||
]);
|
||||
expect(getTimelineItems(rows)).toEqual([{ type: "reasoning", text: "r".repeat(100) }]);
|
||||
expect(events).toHaveLength(2);
|
||||
expectContiguousRowSeqs(rows, [1, 2]);
|
||||
expectContiguousLiveSeqs(events, [1, 2]);
|
||||
@@ -604,8 +609,7 @@ describe("target coalesced behavior", () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(COALESCE_WINDOW_MS);
|
||||
expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([
|
||||
{ type: "assistant_message", text: "a" },
|
||||
{ type: "assistant_message", text: "a".repeat(499) },
|
||||
{ type: "assistant_message", text: "a".repeat(500) },
|
||||
runningToolCall,
|
||||
]);
|
||||
expectContiguousLiveSeqs(getTimelineStreamEvents(harness.events, agentId), [1, 2, 3]);
|
||||
@@ -617,15 +621,14 @@ describe("target coalesced behavior", () => {
|
||||
// The "b" burst arrives inside the window that just flushed, so it gets no
|
||||
// leading flush of its own and coalesces whole.
|
||||
await vi.advanceTimersByTimeAsync(BEFORE_COALESCE_WINDOW_MS);
|
||||
expect(await harness.manager.getTimelineRows(agentId)).toHaveLength(3);
|
||||
expect(await harness.manager.getTimelineRows(agentId)).toHaveLength(2);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
const rows = await harness.manager.getTimelineRows(agentId);
|
||||
const events = getTimelineStreamEvents(harness.events, agentId);
|
||||
|
||||
expect(getTimelineItems(rows)).toEqual([
|
||||
{ type: "assistant_message", text: "a" },
|
||||
{ type: "assistant_message", text: "a".repeat(499) },
|
||||
{ type: "assistant_message", text: "a".repeat(500) },
|
||||
runningToolCall,
|
||||
{ type: "assistant_message", text: "b".repeat(500) },
|
||||
]);
|
||||
@@ -665,10 +668,7 @@ describe("target coalesced behavior", () => {
|
||||
const rows = await harness.manager.getTimelineRows(agentId);
|
||||
const events = getTimelineStreamEvents(harness.events, agentId);
|
||||
|
||||
expect(getTimelineItems(rows)).toEqual([
|
||||
toolCall({ output: "chunk-0" }),
|
||||
toolCall({ output: "chunk-199" }),
|
||||
]);
|
||||
expect(getTimelineItems(rows)).toEqual([toolCall({ output: "chunk-199" })]);
|
||||
expect(events).toHaveLength(2);
|
||||
expectContiguousRowSeqs(rows, [1, 2]);
|
||||
expectContiguousLiveSeqs(events, [1, 2]);
|
||||
@@ -695,9 +695,8 @@ describe("target coalesced behavior", () => {
|
||||
const events = getTimelineStreamEvents(harness.events, agentId);
|
||||
|
||||
expect(getTimelineItems(rows)).toEqual([
|
||||
toolCall({ callId: "tool-1", output: "one-a" }),
|
||||
toolCall({ callId: "tool-2", output: "two-b" }),
|
||||
toolCall({ callId: "tool-1", output: "one-b" }),
|
||||
toolCall({ callId: "tool-2", output: "two-b" }),
|
||||
]);
|
||||
expect(events).toHaveLength(3);
|
||||
expectContiguousRowSeqs(rows, [1, 2, 3]);
|
||||
@@ -876,14 +875,8 @@ describe("target coalesced behavior", () => {
|
||||
const firstEvents = getTimelineStreamEvents(harness.events, first.agentId);
|
||||
const secondEvents = getTimelineStreamEvents(harness.events, second.agentId);
|
||||
|
||||
expect(getTimelineItems(firstRows)).toEqual([
|
||||
{ type: "assistant_message", text: "a" },
|
||||
{ type: "assistant_message", text: "a" },
|
||||
]);
|
||||
expect(getTimelineItems(secondRows)).toEqual([
|
||||
{ type: "assistant_message", text: "b" },
|
||||
{ type: "assistant_message", text: "b" },
|
||||
]);
|
||||
expect(getTimelineItems(firstRows)).toEqual([{ type: "assistant_message", text: "aa" }]);
|
||||
expect(getTimelineItems(secondRows)).toEqual([{ type: "assistant_message", text: "bb" }]);
|
||||
expect(firstEvents).toHaveLength(2);
|
||||
expect(secondEvents).toHaveLength(2);
|
||||
expectContiguousRowSeqs(firstRows, [1, 2]);
|
||||
@@ -916,10 +909,7 @@ describe("target coalesced behavior", () => {
|
||||
const rows = await harness.manager.getTimelineRows(agentId);
|
||||
const events = getTimelineStreamEvents(harness.events, agentId);
|
||||
|
||||
expect(getTimelineItems(rows)).toEqual([
|
||||
{ type: "assistant_message", text: "codex" },
|
||||
{ type: "assistant_message", text: "claude" },
|
||||
]);
|
||||
expect(getTimelineItems(rows)).toEqual([{ type: "assistant_message", text: "codexclaude" }]);
|
||||
expect(events.map((event) => (event.type === "agent_stream" ? event.event : null))).toEqual([
|
||||
{ type: "timeline", provider: "codex", item: { type: "assistant_message", text: "codex" } },
|
||||
{
|
||||
@@ -991,10 +981,7 @@ describe("target coalesced behavior", () => {
|
||||
const events = getTimelineStreamEvents(harness.events, agentId);
|
||||
|
||||
// The empty chunk is dropped before the coalescer, so " " leads the burst.
|
||||
expect(getTimelineItems(rows)).toEqual([
|
||||
{ type: "assistant_message", text: " " },
|
||||
{ type: "assistant_message", text: "\n\tdone" },
|
||||
]);
|
||||
expect(getTimelineItems(rows)).toEqual([{ type: "assistant_message", text: " \n\tdone" }]);
|
||||
expect(events).toHaveLength(2);
|
||||
expectContiguousRowSeqs(rows, [1, 2]);
|
||||
expectContiguousLiveSeqs(events, [1, 2]);
|
||||
@@ -1189,9 +1176,7 @@ describe("target coalesced behavior", () => {
|
||||
const timelineEvents = getTimelineStreamEvents(idempotencyHarness.events, agentId);
|
||||
|
||||
expect(getTimelineItems(rows)).toEqual([
|
||||
{ type: "assistant_message", text: "a" },
|
||||
{ type: "assistant_message", text: "bc" },
|
||||
TOOL_CALL,
|
||||
{ type: "assistant_message", text: "abc" },
|
||||
TOOL_CALL,
|
||||
]);
|
||||
expect(timelineEvents).toHaveLength(4);
|
||||
@@ -1243,8 +1228,7 @@ describe("target coalesced behavior", () => {
|
||||
const rowsAfterSecondFlush = await reentryHarness.manager.getTimelineRows(agentId);
|
||||
const timelineEvents = getTimelineStreamEvents(reentryHarness.events, agentId);
|
||||
expect(getTimelineItems(rowsAfterSecondFlush)).toEqual([
|
||||
{ type: "assistant_message", text: "A" },
|
||||
{ type: "assistant_message", text: "B" },
|
||||
{ type: "assistant_message", text: "AB" },
|
||||
]);
|
||||
expect(timelineEvents).toHaveLength(2);
|
||||
expectContiguousRowSeqs(rowsAfterSecondFlush, [1, 2]);
|
||||
@@ -1274,20 +1258,19 @@ describe("target coalesced behavior", () => {
|
||||
const rows = await harness.manager.getTimelineRows(agentId);
|
||||
const events = getTimelineStreamEvents(harness.events, agentId);
|
||||
expect(getTimelineItems(rows)).toEqual([
|
||||
{ type: "assistant_message", text: "flush" },
|
||||
{ type: "assistant_message", text: "-pending" },
|
||||
{ type: "assistant_message", text: "flush-pending" },
|
||||
]);
|
||||
expect(events).toHaveLength(2);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(await harness.manager.getTimelineRows(agentId)).toHaveLength(2);
|
||||
expect(await harness.manager.getTimelineRows(agentId)).toHaveLength(1);
|
||||
expect(getTimelineStreamEvents(harness.events, agentId)).toHaveLength(2);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("history hydration is uncoalesced and immediate", async () => {
|
||||
test("history hydration projects immediately without waiting for a streaming window", async () => {
|
||||
vi.useFakeTimers();
|
||||
const harness = createHarness();
|
||||
try {
|
||||
@@ -1298,20 +1281,16 @@ describe("target coalesced behavior", () => {
|
||||
|
||||
const rows = await harness.manager.getTimelineRows(agentId);
|
||||
expect(getTimelineItems(rows)).toEqual([
|
||||
{ type: "assistant_message", text: "a1" },
|
||||
{ type: "assistant_message", text: "a2" },
|
||||
{ type: "reasoning", text: "r1" },
|
||||
{ type: "reasoning", text: "r2" },
|
||||
{ type: "assistant_message", text: "a1a2" },
|
||||
{ type: "reasoning", text: "r1r2" },
|
||||
]);
|
||||
expectContiguousRowSeqs(rows, [1, 2, 3, 4]);
|
||||
expect(getTimelineStreamEvents(harness.events, agentId)).toHaveLength(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([
|
||||
{ type: "assistant_message", text: "a1" },
|
||||
{ type: "assistant_message", text: "a2" },
|
||||
{ type: "reasoning", text: "r1" },
|
||||
{ type: "reasoning", text: "r2" },
|
||||
{ type: "assistant_message", text: "a1a2" },
|
||||
{ type: "reasoning", text: "r1r2" },
|
||||
]);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
@@ -1362,10 +1341,7 @@ describe("target coalesced behavior", () => {
|
||||
|
||||
const rows = await harness.manager.getTimelineRows(agentId);
|
||||
const streamEvents = getStreamEvents(harness.events, agentId);
|
||||
expect(getTimelineItems(rows)).toEqual([
|
||||
{ type: "assistant_message", text: "hello " },
|
||||
{ type: "assistant_message", text: "world" },
|
||||
]);
|
||||
expect(getTimelineItems(rows)).toEqual([{ type: "assistant_message", text: "hello world" }]);
|
||||
expect(getProjectedTimelineItems(rows)).toEqual([
|
||||
{ type: "assistant_message", text: "hello world" },
|
||||
]);
|
||||
@@ -1417,8 +1393,7 @@ describe("target coalesced behavior", () => {
|
||||
|
||||
const rows = await harness.manager.getTimelineRows(agentId);
|
||||
expect(getTimelineItems(rows)).toEqual([
|
||||
{ type: "assistant_message", text: "a1" },
|
||||
{ type: "assistant_message", text: "a2" },
|
||||
{ type: "assistant_message", text: "a1a2" },
|
||||
{ type: "reasoning", text: "r1r2" },
|
||||
{ type: "assistant_message", text: "b1b2" },
|
||||
]);
|
||||
@@ -1434,8 +1409,7 @@ describe("target coalesced behavior", () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([
|
||||
{ type: "assistant_message", text: "a1" },
|
||||
{ type: "assistant_message", text: "a2" },
|
||||
{ type: "assistant_message", text: "a1a2" },
|
||||
{ type: "reasoning", text: "r1r2" },
|
||||
{ type: "assistant_message", text: "b1b2" },
|
||||
]);
|
||||
|
||||
@@ -5386,14 +5386,17 @@ test("fetchTimeline returns a bounded reset window when cursor epoch is stale",
|
||||
|
||||
await manager.appendTimelineItem(snapshot.id, {
|
||||
type: "assistant_message",
|
||||
messageId: "one",
|
||||
text: "one",
|
||||
});
|
||||
await manager.appendTimelineItem(snapshot.id, {
|
||||
type: "assistant_message",
|
||||
messageId: "two",
|
||||
text: "two",
|
||||
});
|
||||
await manager.appendTimelineItem(snapshot.id, {
|
||||
type: "assistant_message",
|
||||
messageId: "three",
|
||||
text: "three",
|
||||
});
|
||||
|
||||
@@ -5467,21 +5470,14 @@ test("getTimelineRows falls back to the in-memory timeline when no durable store
|
||||
});
|
||||
|
||||
await expect(manager.getTimelineRows(snapshot.id)).resolves.toEqual([
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: expect.any(String),
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: "row one",
|
||||
},
|
||||
},
|
||||
{
|
||||
seq: 2,
|
||||
seqStart: 1,
|
||||
seqEnd: 2,
|
||||
sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }],
|
||||
collapsed: ["assistant_merge"],
|
||||
timestamp: expect.any(String),
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: "row two",
|
||||
},
|
||||
item: { type: "assistant_message", text: "row onerow two" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -5543,7 +5539,7 @@ test("getAgent does not expose committed history internals once manager owns the
|
||||
expect(fetched.rows.map((row) => row.seq)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
test("coalesces assistant chunks and persists the canonical row", async () => {
|
||||
test("streams coalesced assistant chunks and retains the projected message", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-provisional-timeline-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
@@ -5600,8 +5596,8 @@ test("coalesces assistant chunks and persists the canonical row", async () => {
|
||||
}
|
||||
|
||||
// The coalescer flushes the first chunk on the leading edge, so "final " ships
|
||||
// as its own row and "reply" follows on the trailing window. Clients read the
|
||||
// projected timeline, which merges the two back into one assistant message.
|
||||
// as its own event and "reply" follows on the trailing window. History retains
|
||||
// their complete projected assistant message.
|
||||
const assistantTimelineEvents = streamEvents.filter(
|
||||
(event) => event.itemType === "assistant_message",
|
||||
);
|
||||
@@ -5624,18 +5620,15 @@ test("coalesces assistant chunks and persists the canonical row", async () => {
|
||||
expect(manager.getTimeline(snapshot.id)).toEqual([
|
||||
{
|
||||
type: "assistant_message",
|
||||
text: "final ",
|
||||
},
|
||||
{
|
||||
type: "assistant_message",
|
||||
text: "reply",
|
||||
text: "final reply",
|
||||
},
|
||||
]);
|
||||
const fetched = await manager.fetchTimeline(snapshot.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
});
|
||||
expect(fetched.rows).toHaveLength(2);
|
||||
expect(fetched.rows).toHaveLength(1);
|
||||
expect(fetched.rows[0]).toMatchObject({ seqStart: 1, seqEnd: 2 });
|
||||
expect(assistantTimelineEvents[0]?.epoch).toBe(fetched.epoch);
|
||||
expect(projectTimelineRows({ rows: fetched.rows, mode: "projected" }).map((e) => e.item)).toEqual(
|
||||
[
|
||||
@@ -5671,28 +5664,34 @@ test("fetchTimeline supports older-history pagination with before seq", async ()
|
||||
|
||||
await manager.appendTimelineItem(snapshot.id, {
|
||||
type: "assistant_message",
|
||||
messageId: "first",
|
||||
text: "first",
|
||||
});
|
||||
await manager.appendTimelineItem(snapshot.id, {
|
||||
type: "assistant_message",
|
||||
messageId: "second",
|
||||
text: "second",
|
||||
});
|
||||
await manager.appendTimelineItem(snapshot.id, {
|
||||
type: "assistant_message",
|
||||
messageId: "third",
|
||||
text: "third",
|
||||
});
|
||||
await manager.appendTimelineItem(snapshot.id, {
|
||||
type: "assistant_message",
|
||||
messageId: "fourth",
|
||||
text: "fourth",
|
||||
});
|
||||
await manager.appendTimelineItem(snapshot.id, {
|
||||
type: "assistant_message",
|
||||
messageId: "fifth",
|
||||
text: "fifth",
|
||||
});
|
||||
|
||||
const result = await manager.fetchTimeline(snapshot.id, {
|
||||
direction: "before",
|
||||
cursor: {
|
||||
epoch: manager.fetchTimeline(snapshot.id).epoch,
|
||||
seq: 5,
|
||||
},
|
||||
limit: 2,
|
||||
@@ -5729,14 +5728,17 @@ test("does not trim committed history", async () => {
|
||||
|
||||
await manager.appendTimelineItem(snapshot.id, {
|
||||
type: "assistant_message",
|
||||
messageId: "first",
|
||||
text: "first",
|
||||
});
|
||||
await manager.appendTimelineItem(snapshot.id, {
|
||||
type: "assistant_message",
|
||||
messageId: "second",
|
||||
text: "second",
|
||||
});
|
||||
await manager.appendTimelineItem(snapshot.id, {
|
||||
type: "assistant_message",
|
||||
messageId: "third",
|
||||
text: "third",
|
||||
});
|
||||
|
||||
@@ -5834,8 +5836,7 @@ test("hydrateTimeline preserves assistant chunk, reasoning, and tool timeline hi
|
||||
await manager.hydrateTimelineFromProvider(snapshot.id, { force: true });
|
||||
|
||||
expect(manager.getTimeline(snapshot.id)).toEqual([
|
||||
{ type: "assistant_message", text: "chunk one " },
|
||||
{ type: "assistant_message", text: "chunk two" },
|
||||
{ type: "assistant_message", text: "chunk one chunk two" },
|
||||
{ type: "reasoning", text: "internal" },
|
||||
{
|
||||
type: "tool_call",
|
||||
@@ -10305,7 +10306,7 @@ test("canonical submitted prompt keeps wire identity while rewind resolves provi
|
||||
]);
|
||||
|
||||
const timeline = manager.fetchTimeline(snapshot.id, { direction: "tail", limit: 20 }).rows;
|
||||
expect(timeline).toEqual([
|
||||
expect(timeline).toMatchObject([
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: expect.any(String),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { projectTimelineRows } from "./timeline-projection.js";
|
||||
import type { PluginLifecycle } from "../plugins/lifecycle/index.js";
|
||||
import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js";
|
||||
import type { PluginSessionOpenRequest } from "@getpaseo/plugin/server";
|
||||
@@ -1154,7 +1155,10 @@ export class AgentManager {
|
||||
async getTimelineRows(id: string): Promise<AgentTimelineRow[]> {
|
||||
this.requireAgent(id);
|
||||
if (this.durableTimelineStore) {
|
||||
return await this.durableTimelineStore.getCommittedRows(id);
|
||||
return projectTimelineRows({
|
||||
rows: await this.durableTimelineStore.getCommittedRows(id),
|
||||
mode: "projected",
|
||||
}).map((entry) => Object.assign({ seq: entry.seqEnd }, entry));
|
||||
}
|
||||
return this.timelineStore.getRows(id);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ProjectedTimelineRow } from "./timeline-projection.js";
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
|
||||
export interface AgentTimelineRow {
|
||||
@@ -19,7 +20,7 @@ export interface AgentTimelineFetchOptions {
|
||||
direction?: AgentTimelineFetchDirection;
|
||||
cursor?: AgentTimelineCursor;
|
||||
/**
|
||||
* Number of canonical rows to return.
|
||||
* Number of projected items to return.
|
||||
* - undefined: store default
|
||||
* - 0: all rows in the selected window
|
||||
*/
|
||||
@@ -41,7 +42,9 @@ export interface AgentTimelineFetchResult {
|
||||
window: AgentTimelineWindow;
|
||||
hasOlder: boolean;
|
||||
hasNewer: boolean;
|
||||
rows: AgentTimelineRow[];
|
||||
startSeq: number | null;
|
||||
endSeq: number | null;
|
||||
rows: ProjectedTimelineRow[];
|
||||
}
|
||||
|
||||
export interface AgentTimelineStore {
|
||||
|
||||
@@ -11,17 +11,17 @@ describe("InMemoryAgentTimelineStore", () => {
|
||||
{
|
||||
seq: 5,
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
item: { type: "assistant_message", text: "five" },
|
||||
item: { type: "assistant_message", text: "five", messageId: "five" },
|
||||
},
|
||||
{
|
||||
seq: 6,
|
||||
timestamp: "2026-01-01T00:00:01.000Z",
|
||||
item: { type: "assistant_message", text: "six" },
|
||||
item: { type: "assistant_message", text: "six", messageId: "six" },
|
||||
},
|
||||
{
|
||||
seq: 7,
|
||||
timestamp: "2026-01-01T00:00:02.000Z",
|
||||
item: { type: "assistant_message", text: "seven" },
|
||||
item: { type: "assistant_message", text: "seven", messageId: "seven" },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -32,7 +32,7 @@ describe("InMemoryAgentTimelineStore", () => {
|
||||
limit: 2,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
expect(result).toMatchObject({
|
||||
epoch: "epoch-1",
|
||||
direction: "before",
|
||||
reset: false,
|
||||
@@ -45,12 +45,12 @@ describe("InMemoryAgentTimelineStore", () => {
|
||||
{
|
||||
seq: 6,
|
||||
timestamp: "2026-01-01T00:00:01.000Z",
|
||||
item: { type: "assistant_message", text: "six" },
|
||||
item: { type: "assistant_message", text: "six", messageId: "six" },
|
||||
},
|
||||
{
|
||||
seq: 7,
|
||||
timestamp: "2026-01-01T00:00:02.000Z",
|
||||
item: { type: "assistant_message", text: "seven" },
|
||||
item: { type: "assistant_message", text: "seven", messageId: "seven" },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -65,17 +65,17 @@ describe("InMemoryAgentTimelineStore", () => {
|
||||
{
|
||||
seq: 5,
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
item: { type: "assistant_message", text: "five" },
|
||||
item: { type: "assistant_message", text: "five", messageId: "five" },
|
||||
},
|
||||
{
|
||||
seq: 6,
|
||||
timestamp: "2026-01-01T00:00:01.000Z",
|
||||
item: { type: "assistant_message", text: "six" },
|
||||
item: { type: "assistant_message", text: "six", messageId: "six" },
|
||||
},
|
||||
{
|
||||
seq: 7,
|
||||
timestamp: "2026-01-01T00:00:02.000Z",
|
||||
item: { type: "assistant_message", text: "seven" },
|
||||
item: { type: "assistant_message", text: "seven", messageId: "seven" },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -86,7 +86,7 @@ describe("InMemoryAgentTimelineStore", () => {
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
expect(result).toMatchObject({
|
||||
epoch: "epoch-1",
|
||||
direction: "after",
|
||||
reset: true,
|
||||
@@ -99,9 +99,115 @@ describe("InMemoryAgentTimelineStore", () => {
|
||||
{
|
||||
seq: 7,
|
||||
timestamp: "2026-01-01T00:00:02.000Z",
|
||||
item: { type: "assistant_message", text: "seven" },
|
||||
item: { type: "assistant_message", text: "seven", messageId: "seven" },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("projected timeline retention", () => {
|
||||
it("retains one full tool state while streaming every source update", () => {
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("agent");
|
||||
for (let seq = 1; seq <= 2000; seq++) {
|
||||
const item = {
|
||||
type: "tool_call" as const,
|
||||
callId: "child",
|
||||
name: "task",
|
||||
status: "running" as const,
|
||||
error: null,
|
||||
detail: { type: "plain_text" as const, label: "Child", text: "x".repeat(seq * 128) },
|
||||
};
|
||||
expect(store.append("agent", item)).toMatchObject({ seq, item });
|
||||
}
|
||||
const result = store.fetch("agent", { limit: 0 });
|
||||
expect(result.rows).toHaveLength(1);
|
||||
expect(JSON.stringify(result.rows).length).toBeLessThan(260_000);
|
||||
expect(result.window.maxSeq).toBe(2000);
|
||||
});
|
||||
|
||||
it("catches up a mid-message cursor with the complete projected message", () => {
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("agent", { epoch: "e" });
|
||||
store.append("agent", { type: "assistant_message", messageId: "m", text: "A" });
|
||||
store.append("agent", { type: "assistant_message", messageId: "m", text: "B" });
|
||||
expect(
|
||||
store.fetch("agent", { direction: "after", cursor: { epoch: "e", seq: 1 } }).rows,
|
||||
).toMatchObject([
|
||||
{
|
||||
item: { text: "AB" },
|
||||
seqStart: 1,
|
||||
seqEnd: 2,
|
||||
sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("projected sequence ownership", () => {
|
||||
it("preserves fetched coverage when another source chunk arrives", () => {
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("a");
|
||||
store.append("a", { type: "assistant_message", text: "A" });
|
||||
const before = store.fetch("a");
|
||||
store.append("a", { type: "assistant_message", text: "B" });
|
||||
expect(before.rows[0]).toMatchObject({
|
||||
item: { text: "A" },
|
||||
seqEnd: 1,
|
||||
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
|
||||
});
|
||||
expect(store.fetch("a").rows[0]).toMatchObject({
|
||||
item: { text: "AB" },
|
||||
seqEnd: 2,
|
||||
sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }],
|
||||
});
|
||||
});
|
||||
it("preserves source positions when seeding a projected history whose last update is an earlier tool", () => {
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("a");
|
||||
const tool = {
|
||||
type: "tool_call" as const,
|
||||
callId: "t",
|
||||
name: "shell",
|
||||
error: null,
|
||||
detail: { type: "plain_text" as const, label: "work" },
|
||||
};
|
||||
store.append("a", { ...tool, status: "running" });
|
||||
store.append("a", { type: "assistant_message", text: "Answer" });
|
||||
store.append("a", { ...tool, status: "completed" });
|
||||
store.initialize("b", { rows: store.getRows("a") });
|
||||
expect(store.append("b", { type: "user_message", text: "next" }).seq).toBe(4);
|
||||
expect(store.fetch("b").rows.map((row) => row.seqStart)).toEqual([1, 2, 4]);
|
||||
});
|
||||
it("returns the last projected assistant message without joining different messages", () => {
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("a");
|
||||
store.append("a", { type: "assistant_message", messageId: "first", text: "First" });
|
||||
store.append("a", { type: "assistant_message", messageId: "second", text: "Sec" });
|
||||
store.append("a", { type: "assistant_message", messageId: "second", text: "ond" });
|
||||
expect(store.getLastAssistantMessage("a")).toBe("Second");
|
||||
});
|
||||
});
|
||||
|
||||
it("includes transitive tool updates in a contiguous projected tail", () => {
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("a");
|
||||
const tool = (callId: string, status: "running" | "completed") => ({
|
||||
type: "tool_call" as const,
|
||||
callId,
|
||||
name: "shell",
|
||||
status,
|
||||
error: null,
|
||||
detail: { type: "plain_text" as const, label: "work" },
|
||||
});
|
||||
store.append("a", tool("first", "running"));
|
||||
store.append("a", tool("second", "running"));
|
||||
store.append("a", { type: "user_message", text: "Continue" });
|
||||
store.append("a", tool("first", "completed"));
|
||||
store.append("a", { type: "assistant_message", text: "Answer" });
|
||||
store.append("a", tool("second", "completed"));
|
||||
const tail = store.fetch("a", { limit: 1 });
|
||||
expect(tail.rows.map((row) => row.seqStart)).toEqual([1, 2, 3, 5]);
|
||||
expect(tail).toMatchObject({ startSeq: 1, endSeq: 6, hasOlder: false });
|
||||
});
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
TimelineProjection,
|
||||
selectProjectedTimelinePage,
|
||||
type ProjectedTimelineRow,
|
||||
} from "./timeline-projection.js";
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import type {
|
||||
AgentTimelineFetchOptions,
|
||||
@@ -16,125 +21,15 @@ export interface SeedAgentTimelineOptions {
|
||||
|
||||
interface AgentTimelineState {
|
||||
epoch: string;
|
||||
rows: AgentTimelineRow[];
|
||||
projection: TimelineProjection;
|
||||
minSeq: number;
|
||||
nextSeq: number;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMELINE_FETCH_LIMIT = 200;
|
||||
|
||||
function cloneRow(row: AgentTimelineRow): AgentTimelineRow {
|
||||
function cloneRow<T extends AgentTimelineRow>(row: T): T {
|
||||
return { ...row };
|
||||
}
|
||||
|
||||
interface FetchContext {
|
||||
state: AgentTimelineState;
|
||||
direction: NonNullable<AgentTimelineFetchOptions["direction"]>;
|
||||
limit: number;
|
||||
selectAll: boolean;
|
||||
cursor: AgentTimelineFetchOptions["cursor"];
|
||||
minSeq: number;
|
||||
maxSeq: number;
|
||||
window: { minSeq: number; maxSeq: number; nextSeq: number };
|
||||
}
|
||||
|
||||
function fetchTail(ctx: FetchContext): AgentTimelineFetchResult {
|
||||
const { state, direction, limit, selectAll, minSeq, window } = ctx;
|
||||
const selected =
|
||||
selectAll || limit >= state.rows.length
|
||||
? state.rows
|
||||
: state.rows.slice(state.rows.length - limit);
|
||||
return {
|
||||
epoch: state.epoch,
|
||||
direction,
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window,
|
||||
hasOlder: selected.length > 0 && selected[0].seq > minSeq,
|
||||
hasNewer: false,
|
||||
rows: selected.map(cloneRow),
|
||||
};
|
||||
}
|
||||
|
||||
function fetchAfter(ctx: FetchContext): AgentTimelineFetchResult {
|
||||
const { state, direction, limit, selectAll, cursor, minSeq, maxSeq, window } = ctx;
|
||||
const baseSeq = cursor?.seq ?? 0;
|
||||
const startIdx = state.rows.findIndex((row) => row.seq > baseSeq);
|
||||
if (startIdx < 0) {
|
||||
return {
|
||||
epoch: state.epoch,
|
||||
direction,
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window,
|
||||
hasOlder: baseSeq >= minSeq,
|
||||
hasNewer: false,
|
||||
rows: [],
|
||||
};
|
||||
}
|
||||
|
||||
const selected = selectAll
|
||||
? state.rows.slice(startIdx)
|
||||
: state.rows.slice(startIdx, startIdx + limit);
|
||||
const lastSelected = selected[selected.length - 1];
|
||||
return {
|
||||
epoch: state.epoch,
|
||||
direction,
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window,
|
||||
hasOlder: selected[0].seq > minSeq,
|
||||
hasNewer: lastSelected !== null && lastSelected !== undefined && lastSelected.seq < maxSeq,
|
||||
rows: selected.map(cloneRow),
|
||||
};
|
||||
}
|
||||
|
||||
function fetchBefore(ctx: FetchContext): AgentTimelineFetchResult {
|
||||
const { state, direction, limit, selectAll, cursor, minSeq, window } = ctx;
|
||||
const beforeSeq = cursor?.seq ?? state.nextSeq;
|
||||
const endExclusive = state.rows.findIndex((row) => row.seq >= beforeSeq);
|
||||
const boundedRows = endExclusive < 0 ? state.rows : state.rows.slice(0, endExclusive);
|
||||
const selected =
|
||||
selectAll || limit >= boundedRows.length
|
||||
? boundedRows
|
||||
: boundedRows.slice(boundedRows.length - limit);
|
||||
return {
|
||||
epoch: state.epoch,
|
||||
direction,
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window,
|
||||
hasOlder: selected.length > 0 && selected[0].seq > minSeq,
|
||||
hasNewer: endExclusive >= 0,
|
||||
rows: selected.map(cloneRow),
|
||||
};
|
||||
}
|
||||
|
||||
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>();
|
||||
|
||||
@@ -147,10 +42,13 @@ export class InMemoryAgentTimelineStore {
|
||||
const rows = options?.rows?.length
|
||||
? options.rows.map(cloneRow)
|
||||
: this.buildRowsFromItems(options?.items ?? [], options?.nextSeq ?? 1, timestamp);
|
||||
const nextSeq = options?.nextSeq ?? (rows.length ? rows[rows.length - 1].seq + 1 : 1);
|
||||
const nextSeq = rows.reduce((next, row) => Math.max(next, row.seq + 1), options?.nextSeq ?? 1);
|
||||
const projection = new TimelineProjection();
|
||||
for (const row of rows) projection.append(row);
|
||||
this.states.set(agentId, {
|
||||
epoch: options?.epoch ?? randomUUID(),
|
||||
rows,
|
||||
projection,
|
||||
minSeq: projection.getRows()[0]?.seqStart ?? 0,
|
||||
nextSeq,
|
||||
});
|
||||
}
|
||||
@@ -160,19 +58,23 @@ export class InMemoryAgentTimelineStore {
|
||||
}
|
||||
|
||||
getItems(agentId: string): AgentTimelineItem[] {
|
||||
return this.requireState(agentId).rows.map((row) => row.item);
|
||||
return this.requireState(agentId)
|
||||
.projection.getRows()
|
||||
.map((row) => row.item);
|
||||
}
|
||||
|
||||
getRows(agentId: string): AgentTimelineRow[] {
|
||||
return this.requireState(agentId).rows.map(cloneRow);
|
||||
getRows(agentId: string): ProjectedTimelineRow[] {
|
||||
return this.requireState(agentId).projection.getRows().map(cloneRow);
|
||||
}
|
||||
|
||||
getSubmittedUserMessage(agentId: string, clientMessageId: string): AgentTimelineRow | null {
|
||||
const row = this.requireState(agentId).rows.find(
|
||||
(candidate) =>
|
||||
candidate.item.type === "user_message" &&
|
||||
candidate.item.clientMessageId === clientMessageId,
|
||||
);
|
||||
const row = this.requireState(agentId)
|
||||
.projection.getRows()
|
||||
.find(
|
||||
(candidate) =>
|
||||
candidate.item.type === "user_message" &&
|
||||
candidate.item.clientMessageId === clientMessageId,
|
||||
);
|
||||
return row ? cloneRow(row) : null;
|
||||
}
|
||||
|
||||
@@ -181,19 +83,10 @@ export class InMemoryAgentTimelineStore {
|
||||
clientMessageId: string,
|
||||
providerMessageId: string,
|
||||
): AgentTimelineRow | null {
|
||||
const state = this.requireState(agentId);
|
||||
const index = state.rows.findIndex(
|
||||
(candidate) =>
|
||||
candidate.item.type === "user_message" &&
|
||||
candidate.item.clientMessageId === clientMessageId,
|
||||
return this.requireState(agentId).projection.enrichSubmittedUserMessage(
|
||||
clientMessageId,
|
||||
providerMessageId,
|
||||
);
|
||||
const row = state.rows[index];
|
||||
if (!row || row.item.type !== "user_message") {
|
||||
return null;
|
||||
}
|
||||
const enriched: AgentTimelineRow = { ...row, providerMessageId };
|
||||
state.rows[index] = enriched;
|
||||
return cloneRow(enriched);
|
||||
}
|
||||
|
||||
getEpoch(agentId: string): string {
|
||||
@@ -203,62 +96,37 @@ export class InMemoryAgentTimelineStore {
|
||||
fetch(agentId: string, options?: AgentTimelineFetchOptions): AgentTimelineFetchResult {
|
||||
const state = this.requireState(agentId);
|
||||
const direction = options?.direction ?? "tail";
|
||||
const requestedLimit = options?.limit;
|
||||
const limit =
|
||||
requestedLimit === undefined
|
||||
? DEFAULT_TIMELINE_FETCH_LIMIT
|
||||
: Math.max(0, Math.floor(requestedLimit));
|
||||
const cursor = options?.cursor;
|
||||
const minSeq = state.rows.length ? state.rows[0].seq : 0;
|
||||
const maxSeq = state.rows.length ? state.rows[state.rows.length - 1].seq : 0;
|
||||
const selectAll = limit === 0;
|
||||
|
||||
const window = {
|
||||
minSeq,
|
||||
maxSeq,
|
||||
nextSeq: state.nextSeq,
|
||||
};
|
||||
|
||||
const ctx: FetchContext = {
|
||||
state,
|
||||
const rows = state.projection.getRows();
|
||||
const window = { minSeq: state.minSeq, maxSeq: state.nextSeq - 1, nextSeq: state.nextSeq };
|
||||
const staleCursor = cursor !== undefined && cursor.epoch !== state.epoch;
|
||||
const gap =
|
||||
!staleCursor &&
|
||||
direction === "after" &&
|
||||
cursor !== undefined &&
|
||||
rows.length > 0 &&
|
||||
cursor.seq < state.minSeq - 1;
|
||||
const reset = staleCursor || gap;
|
||||
const page = selectProjectedTimelinePage({
|
||||
rows,
|
||||
bounds: window,
|
||||
direction: reset ? "tail" : direction,
|
||||
cursorSeq: cursor?.seq,
|
||||
limit: options?.limit ?? DEFAULT_TIMELINE_FETCH_LIMIT,
|
||||
});
|
||||
return {
|
||||
epoch: state.epoch,
|
||||
direction,
|
||||
limit,
|
||||
selectAll,
|
||||
cursor,
|
||||
minSeq,
|
||||
maxSeq,
|
||||
reset,
|
||||
staleCursor,
|
||||
gap,
|
||||
window,
|
||||
hasOlder: page.hasOlder,
|
||||
hasNewer: page.hasNewer,
|
||||
startSeq: page.startSeq,
|
||||
endSeq: page.endSeq,
|
||||
rows: page.entries.map((entry) => Object.assign({ seq: entry.seqEnd }, entry)),
|
||||
};
|
||||
|
||||
if (cursor && typeof cursor.epoch === "string" && cursor.epoch !== state.epoch) {
|
||||
return fetchReset(ctx, { staleCursor: true, gap: false });
|
||||
}
|
||||
|
||||
if (direction === "after" && cursor && state.rows.length > 0 && cursor.seq < minSeq - 1) {
|
||||
return fetchReset(ctx, { staleCursor: false, gap: true });
|
||||
}
|
||||
|
||||
if (state.rows.length === 0) {
|
||||
return {
|
||||
epoch: state.epoch,
|
||||
direction,
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
rows: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (direction === "tail") {
|
||||
return fetchTail(ctx);
|
||||
}
|
||||
if (direction === "after") {
|
||||
return fetchAfter(ctx);
|
||||
}
|
||||
return fetchBefore(ctx);
|
||||
}
|
||||
|
||||
append(
|
||||
@@ -275,34 +143,21 @@ export class InMemoryAgentTimelineStore {
|
||||
...(options?.providerMessageId ? { providerMessageId: options.providerMessageId } : {}),
|
||||
};
|
||||
state.nextSeq += 1;
|
||||
state.rows.push(row);
|
||||
if (state.minSeq === 0) state.minSeq = row.seq;
|
||||
state.projection.append(row);
|
||||
return cloneRow(row);
|
||||
}
|
||||
|
||||
getLastItem(agentId: string): AgentTimelineItem | null {
|
||||
const state = this.requireState(agentId);
|
||||
return state.rows[state.rows.length - 1]?.item ?? null;
|
||||
return state.projection.getRows().find((row) => row.seqEnd === state.nextSeq - 1)?.item ?? null;
|
||||
}
|
||||
|
||||
getLastAssistantMessage(agentId: string): string | null {
|
||||
const rows = this.requireState(agentId).rows;
|
||||
const chunks: string[] = [];
|
||||
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
||||
const item = rows[i].item;
|
||||
if (item.type !== "assistant_message") {
|
||||
if (chunks.length > 0) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
chunks.push(item.text);
|
||||
}
|
||||
|
||||
if (chunks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return chunks.toReversed().join("");
|
||||
const row = this.requireState(agentId)
|
||||
.projection.getRows()
|
||||
.findLast((candidate) => candidate.item.type === "assistant_message");
|
||||
return row?.item.type === "assistant_message" ? row.item.text : null;
|
||||
}
|
||||
|
||||
private requireState(agentId: string): AgentTimelineState {
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
import pino from "pino";
|
||||
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
||||
import { createDaemonTestContext } from "../test-utils/daemon-test-context.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createMessageCollector } from "../test-utils/message-collector.js";
|
||||
import { CodexAppServerAgentSession } from "./providers/codex-app-server-agent.js";
|
||||
import { createFakeCodexAppServer } from "./providers/codex/test-utils/fake-app-server.js";
|
||||
import type { AgentClient } from "./agent-sdk-types.js";
|
||||
|
||||
test("projects Codex child history and confines old-client degradation to the child transcript", async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), "paseo-projected-contract-"));
|
||||
const app = createFakeCodexAppServer();
|
||||
const session = new CodexAppServerAgentSession(
|
||||
{ provider: "codex", cwd },
|
||||
null,
|
||||
pino({ level: "silent" }),
|
||||
async () => app.child,
|
||||
);
|
||||
await session.connect();
|
||||
const provider: AgentClient = {
|
||||
provider: "codex",
|
||||
capabilities: session.capabilities,
|
||||
isAvailable: async () => true,
|
||||
createSession: async () => session,
|
||||
resumeSession: async () => session,
|
||||
fetchCatalog: async () => ({ models: [], modes: [] }),
|
||||
};
|
||||
const ctx = await createDaemonTestContext({
|
||||
pluginsEnabled: false,
|
||||
relayEnabled: false,
|
||||
agentClients: { codex: provider },
|
||||
});
|
||||
const legacy = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
|
||||
capabilities: { [CLIENT_CAPS.projectedSubagentTimeline]: false },
|
||||
});
|
||||
await legacy.connect();
|
||||
const messages = createMessageCollector(legacy);
|
||||
const observation = legacy.observeEvents(["agent.provider_subagents.update"]);
|
||||
const unsubscribe = observation.subscribe({ snapshot() {}, update() {} });
|
||||
await observation.ready;
|
||||
try {
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
title: "Projected contract",
|
||||
});
|
||||
app.startsTurn({ threadId: "thread-1" });
|
||||
app.startsSubAgent({ callId: "spawn-child", threadId: "child-thread", agentPath: "child" });
|
||||
for (const delta of ["A", "B", "C"]) {
|
||||
app.child.stdout.write(
|
||||
JSON.stringify({
|
||||
method: "item/agentMessage/delta",
|
||||
params: { threadId: "child-thread", itemId: "message-1", delta },
|
||||
}) + "\n",
|
||||
);
|
||||
}
|
||||
await expect
|
||||
.poll(() => ctx.daemon.daemon.agentManager.listProviderSubagents(agent.id).length)
|
||||
.toBe(1);
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
ctx.daemon.daemon.agentManager.fetchProviderSubagentTimeline(agent.id, "child-thread")
|
||||
.window.maxSeq,
|
||||
)
|
||||
.toBe(3);
|
||||
const child = await ctx.client.fetchProviderSubagentTimeline(agent.id, "child-thread", {
|
||||
limit: 1,
|
||||
});
|
||||
expect(child.projection).toBe("projected");
|
||||
expect(child.rows).toMatchObject([{ seqStart: 1, seqEnd: 3, item: { text: "ABC" } }]);
|
||||
const catchUp = await ctx.client.fetchProviderSubagentTimeline(agent.id, "child-thread", {
|
||||
direction: "after",
|
||||
cursor: { epoch: child.epoch, seq: 1 },
|
||||
});
|
||||
expect(catchUp.rows).toMatchObject([{ item: { text: "ABC" } }]);
|
||||
const oldChild = await legacy.fetchProviderSubagentTimeline(agent.id, "child-thread");
|
||||
expect(oldChild.rows).toMatchObject([
|
||||
{ item: { type: "assistant_message", text: expect.stringContaining("upgrade") } },
|
||||
]);
|
||||
expect((await legacy.listProviderSubagents(agent.id)).subagents).toHaveLength(1);
|
||||
expect(
|
||||
messages.messages.some(
|
||||
(message) =>
|
||||
message.type === "agent.provider_subagents.update" && message.payload.kind === "upsert",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
messages.messages.some(
|
||||
(message) =>
|
||||
message.type === "agent.provider_subagents.update" && message.payload.kind === "timeline",
|
||||
),
|
||||
).toBe(false);
|
||||
const root = await legacy.fetchAgentTimeline(agent.id, { projection: "canonical", limit: 0 });
|
||||
expect(root.error).toBeNull();
|
||||
expect(root.projection).toBe("projected");
|
||||
expect(root.entries).toHaveLength(1);
|
||||
expect((await legacy.buildAgentForkContext(agent.id)).attachment.text).toContain(
|
||||
"[Child] child",
|
||||
);
|
||||
app.assertNoErrors();
|
||||
} finally {
|
||||
unsubscribe();
|
||||
messages.unsubscribe();
|
||||
await legacy.close();
|
||||
await ctx.cleanup();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test("resuming Codex restores child descriptors and complete messages without historical deltas", async () => {
|
||||
const app = createFakeCodexAppServer({
|
||||
"thread/read": (params) => {
|
||||
const { threadId } = params as { threadId: string };
|
||||
return {
|
||||
thread: {
|
||||
id: threadId,
|
||||
turns: [
|
||||
{
|
||||
id: "turn",
|
||||
items:
|
||||
threadId === "root"
|
||||
? [
|
||||
{
|
||||
type: "subAgentActivity",
|
||||
id: "spawn",
|
||||
kind: "started",
|
||||
agentThreadId: "child",
|
||||
agentPath: "explore",
|
||||
},
|
||||
]
|
||||
: [{ type: "agentMessage", id: "message", text: "Complete child answer" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
const session = new CodexAppServerAgentSession(
|
||||
{ provider: "codex", cwd: tmpdir() },
|
||||
{ sessionId: "root" },
|
||||
pino({ level: "silent" }),
|
||||
async () => app.child,
|
||||
);
|
||||
try {
|
||||
await session.connect();
|
||||
const children = [];
|
||||
for await (const event of session.streamHistory()) {
|
||||
if (event.type === "provider_subagent") children.push(event.event);
|
||||
}
|
||||
expect(children).toMatchObject([
|
||||
{ type: "upsert", id: "child", status: "completed" },
|
||||
{
|
||||
type: "timeline",
|
||||
id: "child",
|
||||
item: { type: "assistant_message", text: "Complete child answer" },
|
||||
},
|
||||
]);
|
||||
app.assertNoErrors();
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("retains only the latest cumulative Pi tool progress payload", async () => {
|
||||
const { parseToolArgs, parseToolResult, mapToolDetail } =
|
||||
await import("./providers/pi/tool-call-mapper.js");
|
||||
const { InMemoryAgentTimelineStore } = await import("./agent-timeline-store.js");
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("agent");
|
||||
const tool = parseToolArgs("custom_progress", {});
|
||||
const messages: string[] = [];
|
||||
for (let index = 0; index < 20; index++) {
|
||||
messages.push(`Update ${index}: ${"x".repeat(2048)}`);
|
||||
const result = parseToolResult({
|
||||
content: [{ type: "text", text: "running" }],
|
||||
details: { results: [{ messages: [...messages] }] },
|
||||
});
|
||||
store.append("agent", {
|
||||
type: "tool_call",
|
||||
callId: "same-call",
|
||||
name: "custom_progress",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: mapToolDetail(tool, result),
|
||||
});
|
||||
}
|
||||
const history = store.fetch("agent", { limit: 0 });
|
||||
expect(history.rows).toHaveLength(1);
|
||||
expect(history.rows[0]).toMatchObject({
|
||||
seqStart: 1,
|
||||
seqEnd: 20,
|
||||
item: { detail: { output: { details: { results: [{ messages }] } } } },
|
||||
});
|
||||
expect(JSON.stringify(history.rows).length).toBeLessThan(50_000);
|
||||
});
|
||||
@@ -45,7 +45,7 @@ describe("ProviderSubagentStore", () => {
|
||||
updatedAt: "2026-07-12T10:00:02.000Z",
|
||||
}),
|
||||
]);
|
||||
expect(subagents.fetchTimeline("parent-a", "child-1").rows).toEqual([
|
||||
expect(subagents.fetchTimeline("parent-a", "child-1").rows).toMatchObject([
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: "2026-07-12T10:00:01.000Z",
|
||||
@@ -98,8 +98,11 @@ describe("ProviderSubagentStore", () => {
|
||||
direction: "tail",
|
||||
limit: 1,
|
||||
});
|
||||
expect(page.rows).toHaveLength(101);
|
||||
expect(page.rows[0]?.seq).toBe(1);
|
||||
expect(page.rows).toHaveLength(1);
|
||||
expect(page.rows[0]?.seqStart).toBe(1);
|
||||
expect(page.rows[0]?.item).toMatchObject({
|
||||
text: Array.from({ length: 101 }, (_, i) => String(i)).join(""),
|
||||
});
|
||||
expect(page.rows.at(-1)?.seq).toBe(101);
|
||||
expect(page.hasOlder).toBe(false);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
AgentTimelineFetchResult,
|
||||
AgentTimelineRow,
|
||||
} from "../agent-timeline-store-types.js";
|
||||
import { selectTimelineWindowByProjectedLimit } from "../timeline-projection.js";
|
||||
|
||||
export type ProviderSubagentStatus = "running" | "completed" | "failed" | "canceled";
|
||||
|
||||
@@ -151,30 +150,7 @@ export class ProviderSubagentStore {
|
||||
subagentId: string,
|
||||
options?: AgentTimelineFetchOptions,
|
||||
): AgentTimelineFetchResult {
|
||||
const direction = options?.direction ?? "tail";
|
||||
const limit = options?.limit === undefined ? 200 : Math.max(0, Math.floor(options.limit));
|
||||
const timeline = this.timelines.fetch(storeKey(parentAgentId, subagentId), {
|
||||
...options,
|
||||
limit: 0,
|
||||
});
|
||||
if (limit === 0 || timeline.rows.length === 0) {
|
||||
return timeline;
|
||||
}
|
||||
const selected = selectTimelineWindowByProjectedLimit({
|
||||
rows: timeline.rows,
|
||||
direction: timeline.reset ? "tail" : direction,
|
||||
limit,
|
||||
});
|
||||
const firstRow = selected.selectedRows[0];
|
||||
const lastRow = selected.selectedRows[selected.selectedRows.length - 1];
|
||||
return {
|
||||
...timeline,
|
||||
rows: selected.selectedRows,
|
||||
hasOlder:
|
||||
timeline.hasOlder || (firstRow !== undefined && firstRow.seq > timeline.window.minSeq),
|
||||
hasNewer:
|
||||
timeline.hasNewer || (lastRow !== undefined && lastRow.seq < timeline.window.maxSeq),
|
||||
};
|
||||
return this.timelines.fetch(storeKey(parentAgentId, subagentId), options);
|
||||
}
|
||||
|
||||
deleteParent(parentAgentId: string): ProviderSubagentStoreEvent[] {
|
||||
|
||||
@@ -82,10 +82,11 @@ describe("AgentManager rewind", () => {
|
||||
|
||||
test("replaces the canonical epoch without replaying reconstructed parent rows", async () => {
|
||||
const { manager, session, agentId } = await createRewindHarness();
|
||||
session.history = Array.from({ length: 250 }, (_, index) => ({
|
||||
const history = Array.from({ length: 250 }, (_, index) => ({
|
||||
type: "assistant_message" as const,
|
||||
text: `rewound ${index}`,
|
||||
}));
|
||||
session.history = history;
|
||||
const epochBefore = manager.fetchTimeline(agentId, { limit: 0 }).epoch;
|
||||
const events: string[] = [];
|
||||
const unsubscribe = manager.subscribe((event) => events.push(event.type), {
|
||||
@@ -97,7 +98,18 @@ describe("AgentManager rewind", () => {
|
||||
|
||||
const replacement = manager.fetchTimeline(agentId, { limit: 0 });
|
||||
expect(replacement.epoch).not.toBe(epochBefore);
|
||||
expect(replacement.rows).toHaveLength(250);
|
||||
expect(replacement.rows).toEqual([
|
||||
expect.objectContaining({
|
||||
seq: 250,
|
||||
seqStart: 1,
|
||||
seqEnd: 250,
|
||||
sourceSeqRanges: [{ startSeq: 1, endSeq: 250 }],
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: history.map((item) => item.text).join(""),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
expect(events.filter((type) => type === "agent_stream")).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { AgentTimelineItem, ToolCallDetail } from "./agent-sdk-types.js";
|
||||
import type { AgentTimelineRow } from "./agent-manager.js";
|
||||
import { timelineItemIdentity } from "@getpaseo/protocol/timeline-identity";
|
||||
|
||||
import type { AgentTimelineRow } from "./agent-timeline-store-types.js";
|
||||
|
||||
export type TimelineProjectionMode = "canonical" | "projected";
|
||||
|
||||
export interface TimelineSeqRange {
|
||||
@@ -19,6 +20,7 @@ export type TimelineLimitDirection = "tail" | "before" | "after";
|
||||
export interface TimelineProjectionEntry {
|
||||
item: AgentTimelineItem;
|
||||
turnId?: string;
|
||||
providerMessageId?: string;
|
||||
timestamp: string;
|
||||
seqStart: number;
|
||||
seqEnd: number;
|
||||
@@ -54,7 +56,7 @@ function appendSeqToRanges(ranges: TimelineSeqRange[], seq: number): TimelineSeq
|
||||
}
|
||||
|
||||
if (seq <= last.endSeq + 1) {
|
||||
last.endSeq = Math.max(last.endSeq, seq);
|
||||
next[next.length - 1] = { ...last, endSeq: Math.max(last.endSeq, seq) };
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -114,15 +116,19 @@ function mergeToolCallItems(
|
||||
}
|
||||
|
||||
function makeCanonicalEntries(rows: readonly AgentTimelineRow[]): WorkingEntry[] {
|
||||
return rows.map((row) => ({
|
||||
item: row.item,
|
||||
...(row.turnId ? { turnId: row.turnId } : {}),
|
||||
timestamp: row.timestamp,
|
||||
seqStart: row.seq,
|
||||
seqEnd: row.seq,
|
||||
sourceSeqRanges: [{ startSeq: row.seq, endSeq: row.seq }],
|
||||
collapsed: [],
|
||||
}));
|
||||
return rows.map((row) => {
|
||||
if ("seqStart" in row) return { ...(row as ProjectedTimelineRow) };
|
||||
return {
|
||||
item: row.item,
|
||||
timestamp: row.timestamp,
|
||||
...(row.turnId ? { turnId: row.turnId } : {}),
|
||||
...(row.providerMessageId ? { providerMessageId: row.providerMessageId } : {}),
|
||||
seqStart: row.seq,
|
||||
seqEnd: row.seq,
|
||||
sourceSeqRanges: [{ startSeq: row.seq, endSeq: row.seq }],
|
||||
collapsed: [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function mergeIdentityMetadata(
|
||||
@@ -285,6 +291,54 @@ function mergeAssistantChunks(entries: readonly WorkingEntry[]): WorkingEntry[]
|
||||
return output;
|
||||
}
|
||||
|
||||
/** A retained item. `seq` is its latest source event, not its display anchor. */
|
||||
export type ProjectedTimelineRow = AgentTimelineRow & TimelineProjectionEntry;
|
||||
|
||||
/** Owns projected state; source events are consumed, never retained. */
|
||||
export class TimelineProjection {
|
||||
private readonly rows: ProjectedTimelineRow[] = [];
|
||||
private readonly identities = new Map<string, number>();
|
||||
|
||||
append(row: AgentTimelineRow): void {
|
||||
const entry = makeCanonicalEntries([row])[0];
|
||||
const identity = timelineItemIdentity(row.item);
|
||||
const index = identity === null ? undefined : this.identities.get(identity);
|
||||
const existing = index === undefined ? undefined : this.rows[index];
|
||||
const merged = existing ? mergeIdentityEntries(existing, entry) : null;
|
||||
if (merged && index !== undefined) {
|
||||
this.rows[index] = { ...merged, seq: merged.seqEnd };
|
||||
return;
|
||||
}
|
||||
const previous = this.rows.at(-1);
|
||||
const adjacent = previous
|
||||
? mergeReasoningChunks(mergeAssistantChunks([previous, entry]))
|
||||
: [entry];
|
||||
if (previous && adjacent.length === 1) {
|
||||
const mergedText = adjacent[0];
|
||||
this.rows[this.rows.length - 1] = { ...mergedText, seq: mergedText.seqEnd };
|
||||
return;
|
||||
}
|
||||
if (identity !== null) this.identities.set(identity, this.rows.length);
|
||||
this.rows.push({ ...entry, seq: entry.seqEnd });
|
||||
}
|
||||
|
||||
getRows(): ProjectedTimelineRow[] {
|
||||
return this.rows.map((row) => ({ ...row }));
|
||||
}
|
||||
|
||||
enrichSubmittedUserMessage(
|
||||
clientMessageId: string,
|
||||
providerMessageId: string,
|
||||
): ProjectedTimelineRow | null {
|
||||
const index = this.rows.findIndex(
|
||||
(row) => row.item.type === "user_message" && row.item.clientMessageId === clientMessageId,
|
||||
);
|
||||
if (index < 0) return null;
|
||||
this.rows[index] = { ...this.rows[index], providerMessageId };
|
||||
return { ...this.rows[index] };
|
||||
}
|
||||
}
|
||||
|
||||
export function projectTimelineRows(input: {
|
||||
rows: readonly AgentTimelineRow[];
|
||||
mode: TimelineProjectionMode;
|
||||
@@ -397,7 +451,11 @@ function getTimelineBounds(
|
||||
if (!first || !last) {
|
||||
return null;
|
||||
}
|
||||
return { minSeq: first.seq, maxSeq: last.seq };
|
||||
const entries = makeCanonicalEntries(rows);
|
||||
return {
|
||||
minSeq: entries.reduce((min, e) => Math.min(min, e.seqStart), Infinity),
|
||||
maxSeq: entries.reduce((max, e) => Math.max(max, e.seqEnd), 0),
|
||||
};
|
||||
}
|
||||
|
||||
function firstSourceSeqInRange(
|
||||
@@ -420,7 +478,6 @@ interface ProjectedEntryCandidate {
|
||||
|
||||
function selectProjectedEntriesAfter(input: {
|
||||
entries: readonly TimelineProjectionEntry[];
|
||||
rows: readonly AgentTimelineRow[];
|
||||
startSeq: number;
|
||||
maxSeq: number;
|
||||
limit: number;
|
||||
@@ -445,16 +502,10 @@ function selectProjectedEntriesAfter(input: {
|
||||
// Wide projected entries can include future, discontiguous source ranges. The
|
||||
// page cursor advances only through source rows covered without a gap.
|
||||
let endSeq = input.startSeq - 1;
|
||||
let rangeIndex = 0;
|
||||
for (const row of input.rows) {
|
||||
if (row.seq < input.startSeq) continue;
|
||||
if (row.seq > input.maxSeq || row.seq !== endSeq + 1) break;
|
||||
while (selectedRanges[rangeIndex] && selectedRanges[rangeIndex].endSeq < row.seq) {
|
||||
rangeIndex += 1;
|
||||
}
|
||||
const range = selectedRanges[rangeIndex];
|
||||
if (!range || row.seq < range.startSeq || row.seq > range.endSeq) break;
|
||||
endSeq = row.seq;
|
||||
for (const range of selectedRanges) {
|
||||
if (range.endSeq <= endSeq) continue;
|
||||
if (range.startSeq > endSeq + 1) break;
|
||||
endSeq = Math.min(range.endSeq, input.maxSeq);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -484,6 +535,27 @@ function selectProjectedEntriesBefore(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function selectProjectedEntriesTail(
|
||||
projectedAll: TimelineProjectionEntry[],
|
||||
limit: number,
|
||||
bounds: { minSeq: number; maxSeq: number },
|
||||
): ProjectedTimelinePageSelection {
|
||||
let start = limit === 0 ? 0 : Math.max(0, projectedAll.length - limit);
|
||||
// Include earlier display anchors with updates in the selected source window,
|
||||
// so a tail certifies contiguous coverage even with interleaved tool updates.
|
||||
for (let i = start - 1; i >= 0; i--) {
|
||||
if (projectedAll[i].seqEnd >= projectedAll[start].seqStart) start = i;
|
||||
}
|
||||
const selected = projectedAll.slice(start);
|
||||
return {
|
||||
entries: selected,
|
||||
startSeq: selected[0]?.seqStart ?? null,
|
||||
endSeq: bounds.maxSeq,
|
||||
hasOlder: start > 0,
|
||||
hasNewer: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectProjectedTimelinePage(input: {
|
||||
rows: readonly AgentTimelineRow[];
|
||||
bounds?: { minSeq: number; maxSeq: number };
|
||||
@@ -535,18 +607,7 @@ export function selectProjectedTimelinePage(input: {
|
||||
}
|
||||
|
||||
if (input.direction === "tail") {
|
||||
const selected = selectTimelineWindowByProjectedLimit({
|
||||
rows: input.rows,
|
||||
direction: "tail",
|
||||
limit,
|
||||
});
|
||||
return {
|
||||
entries: selected.projectedEntries,
|
||||
startSeq: selected.minSeq,
|
||||
endSeq: selected.maxSeq,
|
||||
hasOlder: selected.minSeq !== null && selected.minSeq > bounds.minSeq,
|
||||
hasNewer: false,
|
||||
};
|
||||
return selectProjectedEntriesTail(projectedAll, limit, bounds);
|
||||
}
|
||||
|
||||
if (input.direction === "after") {
|
||||
@@ -554,7 +615,6 @@ export function selectProjectedTimelinePage(input: {
|
||||
const startSeq = Math.max(bounds.minSeq, cursorSeq + 1);
|
||||
const selected = selectProjectedEntriesAfter({
|
||||
entries: projectedAll,
|
||||
rows: input.rows,
|
||||
startSeq,
|
||||
maxSeq: bounds.maxSeq,
|
||||
limit,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, expect, test } from "vitest";
|
||||
import { afterEach, beforeEach, expect, onTestFinished, test } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -11,6 +11,23 @@ import {
|
||||
import { createMessageCollector } from "../test-utils/message-collector.js";
|
||||
import type { SessionOutboundMessage } from "../messages.js";
|
||||
|
||||
async function observeTimeline(
|
||||
client: DaemonClient,
|
||||
agentId: string,
|
||||
messages: SessionOutboundMessage[],
|
||||
): Promise<void> {
|
||||
const subscription = client.observeTimeline([agentId]);
|
||||
onTestFinished(
|
||||
subscription.subscribe({
|
||||
snapshot() {},
|
||||
update(message) {
|
||||
messages.push(message);
|
||||
},
|
||||
}),
|
||||
);
|
||||
await subscription.ready;
|
||||
}
|
||||
|
||||
function tmpCwd(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
|
||||
}
|
||||
@@ -32,7 +49,6 @@ async function waitFor(
|
||||
function isLiveAssistantTimeline(
|
||||
message: SessionOutboundMessage,
|
||||
agentId: string,
|
||||
epoch?: string,
|
||||
text?: string,
|
||||
): boolean {
|
||||
return (
|
||||
@@ -41,8 +57,7 @@ function isLiveAssistantTimeline(
|
||||
message.payload.event.type === "timeline" &&
|
||||
message.payload.event.item.type === "assistant_message" &&
|
||||
message.payload.seq === undefined &&
|
||||
typeof message.payload.epoch === "string" &&
|
||||
(epoch === undefined || message.payload.epoch === epoch) &&
|
||||
message.payload.epoch === undefined &&
|
||||
(text === undefined || message.payload.event.item.text === text)
|
||||
);
|
||||
}
|
||||
@@ -69,6 +84,8 @@ test("reconnect catches up committed rows without replaying a provisional seed",
|
||||
modeId: "full-access",
|
||||
});
|
||||
|
||||
await observeTimeline(ctx.client, agent.id, primaryCollector.messages);
|
||||
|
||||
for (let seq = 1; seq <= 120; seq += 1) {
|
||||
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
|
||||
type: "assistant_message",
|
||||
@@ -91,7 +108,7 @@ test("reconnect catches up committed rows without replaying a provisional seed",
|
||||
});
|
||||
await waitFor(() =>
|
||||
primaryCollector.messages.some((message) =>
|
||||
isLiveAssistantTimeline(message, agent.id, epoch, "partial before disconnect"),
|
||||
isLiveAssistantTimeline(message, agent.id, "partial before disconnect"),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -107,6 +124,7 @@ test("reconnect catches up committed rows without replaying a provisional seed",
|
||||
});
|
||||
await reconnectClient.connect();
|
||||
const reconnectCollector = createMessageCollector(reconnectClient);
|
||||
await observeTimeline(reconnectClient, agent.id, reconnectCollector.messages);
|
||||
|
||||
try {
|
||||
await reconnectClient.fetchAgents({
|
||||
@@ -114,9 +132,7 @@ test("reconnect catches up committed rows without replaying a provisional seed",
|
||||
});
|
||||
|
||||
expect(
|
||||
reconnectCollector.messages.some((message) =>
|
||||
isLiveAssistantTimeline(message, agent.id, epoch),
|
||||
),
|
||||
reconnectCollector.messages.some((message) => isLiveAssistantTimeline(message, agent.id)),
|
||||
).toBe(false);
|
||||
|
||||
const catchUp = await reconnectClient.fetchAgentTimeline(agent.id, {
|
||||
@@ -133,11 +149,14 @@ test("reconnect catches up committed rows without replaying a provisional seed",
|
||||
expect(catchUp.entries).toHaveLength(1);
|
||||
expect(catchUp.startCursor).toEqual({ epoch, seq: 121 });
|
||||
expect(catchUp.endCursor).toEqual({ epoch, seq: 121 });
|
||||
expect(catchUp.entries[0]?.seqStart).toBe(121);
|
||||
expect(catchUp.projection).toBe("projected");
|
||||
expect(catchUp.entries[0]?.seqStart).toBe(1);
|
||||
expect(catchUp.entries[0]?.seqEnd).toBe(121);
|
||||
expect(catchUp.entries[0]?.item).toEqual({
|
||||
type: "assistant_message",
|
||||
text: "finalized while disconnected",
|
||||
text:
|
||||
Array.from({ length: 120 }, (_, index) => `committed row ${index + 1}`).join("") +
|
||||
"finalized while disconnected",
|
||||
});
|
||||
} finally {
|
||||
reconnectCollector.unsubscribe();
|
||||
@@ -161,6 +180,8 @@ test("reconnect with no new committed rows resumes from future live provisional
|
||||
modeId: "full-access",
|
||||
});
|
||||
|
||||
await observeTimeline(ctx.client, agent.id, primaryCollector.messages);
|
||||
|
||||
for (let seq = 1; seq <= 120; seq += 1) {
|
||||
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
|
||||
type: "assistant_message",
|
||||
@@ -183,7 +204,7 @@ test("reconnect with no new committed rows resumes from future live provisional
|
||||
});
|
||||
await waitFor(() =>
|
||||
primaryCollector.messages.some((message) =>
|
||||
isLiveAssistantTimeline(message, agent.id, epoch, "partial before disconnect"),
|
||||
isLiveAssistantTimeline(message, agent.id, "partial before disconnect"),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -194,6 +215,7 @@ test("reconnect with no new committed rows resumes from future live provisional
|
||||
});
|
||||
await reconnectClient.connect();
|
||||
const reconnectCollector = createMessageCollector(reconnectClient);
|
||||
await observeTimeline(reconnectClient, agent.id, reconnectCollector.messages);
|
||||
|
||||
try {
|
||||
await reconnectClient.fetchAgents({
|
||||
@@ -201,9 +223,7 @@ test("reconnect with no new committed rows resumes from future live provisional
|
||||
});
|
||||
|
||||
expect(
|
||||
reconnectCollector.messages.some((message) =>
|
||||
isLiveAssistantTimeline(message, agent.id, epoch),
|
||||
),
|
||||
reconnectCollector.messages.some((message) => isLiveAssistantTimeline(message, agent.id)),
|
||||
).toBe(false);
|
||||
|
||||
const catchUp = await reconnectClient.fetchAgentTimeline(agent.id, {
|
||||
@@ -228,7 +248,7 @@ test("reconnect with no new committed rows resumes from future live provisional
|
||||
});
|
||||
await waitFor(() =>
|
||||
reconnectCollector.messages.some((message) =>
|
||||
isLiveAssistantTimeline(message, agent.id, epoch, "fresh live after reconnect"),
|
||||
isLiveAssistantTimeline(message, agent.id, "fresh live after reconnect"),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
|
||||
@@ -178,7 +178,7 @@ describe("daemon E2E - timeline window", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("canonical after fetch returns only committed rows after the cursor", async () => {
|
||||
test("legacy canonical requests return full projected items after the cursor", async () => {
|
||||
const cwd = tmpCwd();
|
||||
try {
|
||||
const agent = await ctx.client.createAgent({
|
||||
@@ -201,13 +201,16 @@ describe("daemon E2E - timeline window", () => {
|
||||
projection: "canonical",
|
||||
});
|
||||
|
||||
expect(timeline.projection).toBe("canonical");
|
||||
expect(timeline.projection).toBe("projected");
|
||||
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]?.seqStart).toBe(1);
|
||||
expect(timeline.entries[0]?.seqEnd).toBe(250);
|
||||
expect(timeline.entries[0]?.sourceSeqRanges).toEqual([{ startSeq: 250, endSeq: 250 }]);
|
||||
expect(timeline.entries[0]?.sourceSeqRanges).toEqual([
|
||||
{ startSeq: 1, endSeq: 1 },
|
||||
{ startSeq: 250, endSeq: 250 },
|
||||
]);
|
||||
expect(timeline.entries[0]?.item.type).toBe("tool_call");
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
@@ -301,7 +304,6 @@ describe("daemon E2E - timeline window", () => {
|
||||
});
|
||||
}
|
||||
|
||||
const fetchSpy = vi.spyOn(ctx.daemon.daemon.agentManager, "fetchTimeline");
|
||||
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 100,
|
||||
@@ -311,11 +313,6 @@ describe("daemon E2E - timeline window", () => {
|
||||
expect(timeline.startCursor?.seq).toBe(501);
|
||||
expect(timeline.endCursor?.seq).toBe(600);
|
||||
expect(timeline.hasOlder).toBe(true);
|
||||
expect(
|
||||
fetchSpy.mock.calls.some(
|
||||
([, options]) => options?.direction === "tail" && options.limit === 0,
|
||||
),
|
||||
).toBe(false);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
@@ -341,7 +338,6 @@ describe("daemon E2E - timeline window", () => {
|
||||
limit: 1,
|
||||
}).epoch;
|
||||
|
||||
const fetchSpy = vi.spyOn(ctx.daemon.daemon.agentManager, "fetchTimeline");
|
||||
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
|
||||
direction: "after",
|
||||
cursor: { epoch, seq: 300 },
|
||||
@@ -352,11 +348,6 @@ describe("daemon E2E - timeline window", () => {
|
||||
expect(timeline.startCursor?.seq).toBe(301);
|
||||
expect(timeline.endCursor?.seq).toBe(400);
|
||||
expect(timeline.hasNewer).toBe(true);
|
||||
expect(
|
||||
fetchSpy.mock.calls.some(
|
||||
([, options]) => options?.direction === "tail" && options.limit === 0,
|
||||
),
|
||||
).toBe(false);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
@@ -490,7 +481,6 @@ describe("daemon E2E - timeline window", () => {
|
||||
limit: 1,
|
||||
}).epoch;
|
||||
|
||||
const fetchSpy = vi.spyOn(ctx.daemon.daemon.agentManager, "fetchTimeline");
|
||||
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
|
||||
direction: "before",
|
||||
cursor: { epoch, seq: 501 },
|
||||
@@ -501,11 +491,6 @@ describe("daemon E2E - timeline window", () => {
|
||||
expect(timeline.startCursor?.seq).toBe(401);
|
||||
expect(timeline.endCursor?.seq).toBe(500);
|
||||
expect(timeline.hasOlder).toBe(true);
|
||||
expect(
|
||||
fetchSpy.mock.calls.some(
|
||||
([, options]) => options?.direction === "tail" && options.limit === 0,
|
||||
),
|
||||
).toBe(false);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -119,12 +119,6 @@ import {
|
||||
} from "./agent/timeline-append.js";
|
||||
import { assertPluginTimelineDataSize } from "./agent/agent-timeline-content.js";
|
||||
import { parsePluginClientId } from "./plugins/plugin-session-identity.js";
|
||||
import {
|
||||
projectTimelineRows,
|
||||
selectProjectedTimelinePage,
|
||||
type TimelineProjectionEntry,
|
||||
type TimelineProjectionMode,
|
||||
} from "./agent/timeline-projection.js";
|
||||
import { buildAgentForkContextAttachment } from "./agent/activity-curator.js";
|
||||
import { buildAgentPrompt } from "./agent/prompt-attachments.js";
|
||||
import type { StructuredGenerationDaemonConfig } from "./agent/structured-generation-providers.js";
|
||||
@@ -594,15 +588,6 @@ function sessionRequestId(message: SessionInboundMessage): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
interface AgentTimelineProjectionSelection {
|
||||
timeline: AgentTimelineFetchResult;
|
||||
entries: TimelineProjectionEntry[];
|
||||
startSeq: number | null;
|
||||
endSeq: number | null;
|
||||
hasOlder: boolean;
|
||||
hasNewer: boolean;
|
||||
}
|
||||
|
||||
type RegistryTransition = "created" | "unarchived" | "existing";
|
||||
|
||||
interface ArchivedRecordSnapshot {
|
||||
@@ -1798,6 +1783,17 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private supportsSubagentTimelineItem(
|
||||
item: AgentTimelineFetchResult["rows"][number]["item"],
|
||||
source?: object,
|
||||
): boolean {
|
||||
// COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gate after 2027-03-14.
|
||||
const supportsProjection = source
|
||||
? this.supportsForSource(CLIENT_CAPS.projectedSubagentTimeline, source)
|
||||
: this.supports(CLIENT_CAPS.projectedSubagentTimeline);
|
||||
return supportsProjection && this.supportsTimelineItem(item, source);
|
||||
}
|
||||
|
||||
private forwardProviderSubagentUpdate(
|
||||
update: Extract<AgentManagerEvent, { type: "provider_subagent" }>["event"],
|
||||
): void {
|
||||
@@ -1837,7 +1833,7 @@ export class Session {
|
||||
if (!subscription.events.has("agent.provider_subagents.update")) continue;
|
||||
if (
|
||||
update.type === "timeline" &&
|
||||
!this.supportsTimelineItem(update.row.item, subscription.owner.source)
|
||||
!this.supportsSubagentTimelineItem(update.row.item, subscription.owner.source)
|
||||
)
|
||||
continue;
|
||||
subscription.owner.emit(message);
|
||||
@@ -1846,7 +1842,7 @@ export class Session {
|
||||
if (this.clientSources.size === 0 || !this.onMessageToSource) {
|
||||
if (
|
||||
this.supports(CLIENT_CAPS.providerSubagents) &&
|
||||
(update.type !== "timeline" || this.supportsTimelineItem(update.row.item))
|
||||
(update.type !== "timeline" || this.supportsSubagentTimelineItem(update.row.item))
|
||||
) {
|
||||
this.emit(message);
|
||||
}
|
||||
@@ -1859,7 +1855,7 @@ export class Session {
|
||||
!capabilities.has(CLIENT_CAPS.providerSubagents)
|
||||
)
|
||||
continue;
|
||||
if (update.type === "timeline" && !this.supportsTimelineItem(update.row.item, source))
|
||||
if (update.type === "timeline" && !this.supportsSubagentTimelineItem(update.row.item, source))
|
||||
continue;
|
||||
this.onMessageToSource(source, message);
|
||||
}
|
||||
@@ -7390,105 +7386,12 @@ export class Session {
|
||||
});
|
||||
}
|
||||
|
||||
private shouldUseFullTimelineForProjectedPage(input: {
|
||||
timeline: AgentTimelineFetchResult;
|
||||
pageLimit: number;
|
||||
}): boolean {
|
||||
const { timeline } = input;
|
||||
if (timeline.rows.length === 0) return false;
|
||||
|
||||
if (timeline.rows.some((row) => row.item.type === "tool_call")) return true;
|
||||
|
||||
const firstRow = timeline.rows[0];
|
||||
if (
|
||||
timeline.hasOlder &&
|
||||
(firstRow?.item.type === "assistant_message" || firstRow?.item.type === "reasoning")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const lastRow = timeline.rows.at(-1);
|
||||
if (
|
||||
timeline.hasNewer &&
|
||||
(lastRow?.item.type === "assistant_message" || lastRow?.item.type === "reasoning")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!timeline.hasNewer || input.pageLimit === 0) return false;
|
||||
return projectTimelineRows({ rows: timeline.rows, mode: "projected" }).length < input.pageLimit;
|
||||
}
|
||||
|
||||
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;
|
||||
fullTimeline?: AgentTimelineFetchResult;
|
||||
}): AgentTimelineProjectionSelection {
|
||||
const selectedTimeline = this.shouldUseFullTimelineForProjectedPage({
|
||||
timeline: input.controlTimeline,
|
||||
pageLimit: input.pageLimit,
|
||||
})
|
||||
? (input.fullTimeline ??
|
||||
this.agentManager.fetchTimeline(input.agentId, { direction: "tail", limit: 0 }))
|
||||
: input.controlTimeline;
|
||||
const page = selectProjectedTimelinePage({
|
||||
rows: selectedTimeline.rows,
|
||||
bounds: selectedTimeline.window,
|
||||
direction: input.controlTimeline.reset ? "tail" : input.direction,
|
||||
...(input.cursor ? { cursorSeq: input.cursor.seq } : {}),
|
||||
limit: input.pageLimit,
|
||||
});
|
||||
|
||||
return {
|
||||
timeline: selectedTimeline,
|
||||
entries: page.entries,
|
||||
startSeq: page.startSeq,
|
||||
endSeq: page.endSeq,
|
||||
hasOlder:
|
||||
page.hasOlder || (page.startSeq !== null && page.startSeq > selectedTimeline.window.minSeq),
|
||||
hasNewer: page.hasNewer,
|
||||
};
|
||||
}
|
||||
|
||||
private selectTimelineProjection(input: {
|
||||
agentId: string;
|
||||
projection: TimelineProjectionMode;
|
||||
controlTimeline: AgentTimelineFetchResult;
|
||||
direction: AgentTimelineFetchDirection;
|
||||
cursor?: AgentTimelineCursor;
|
||||
pageLimit: number;
|
||||
fullTimeline?: AgentTimelineFetchResult;
|
||||
}): 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" }>,
|
||||
source?: object,
|
||||
): Promise<void> {
|
||||
const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail");
|
||||
const projection: TimelineProjectionMode = msg.projection ?? "projected";
|
||||
const projection = "projected" as const;
|
||||
const requestedLimit = msg.limit;
|
||||
const pageLimit = requestedLimit ?? (direction === "after" ? 0 : 200);
|
||||
const cursor: AgentTimelineCursor | undefined = msg.cursor
|
||||
@@ -7511,14 +7414,14 @@ export class Session {
|
||||
cursor,
|
||||
limit: pageLimit,
|
||||
});
|
||||
const selectedTimeline = this.selectTimelineProjection({
|
||||
agentId: msg.agentId,
|
||||
projection,
|
||||
controlTimeline: fetchedControlTimeline,
|
||||
direction,
|
||||
...(cursor ? { cursor } : {}),
|
||||
pageLimit,
|
||||
});
|
||||
const selectedTimeline = {
|
||||
timeline: fetchedControlTimeline,
|
||||
entries: fetchedControlTimeline.rows,
|
||||
startSeq: fetchedControlTimeline.startSeq,
|
||||
endSeq: fetchedControlTimeline.endSeq,
|
||||
hasOlder: fetchedControlTimeline.hasOlder,
|
||||
hasNewer: fetchedControlTimeline.hasNewer,
|
||||
};
|
||||
const startCursor =
|
||||
selectedTimeline.startSeq !== null
|
||||
? { epoch: selectedTimeline.timeline.epoch, seq: selectedTimeline.startSeq }
|
||||
@@ -7719,6 +7622,10 @@ export class Session {
|
||||
if (!descriptor) {
|
||||
throw new Error("Provider subagent not found");
|
||||
}
|
||||
// COMPAT(projectedSubagentTimeline): added after v0.8.0, remove after 2027-03-14.
|
||||
const supportsProjection = source
|
||||
? this.supportsForSource(CLIENT_CAPS.projectedSubagentTimeline, source)
|
||||
: this.supports(CLIENT_CAPS.projectedSubagentTimeline);
|
||||
const timeline = this.agentManager.fetchProviderSubagentTimeline(
|
||||
msg.parentAgentId,
|
||||
msg.subagentId,
|
||||
@@ -7739,17 +7646,36 @@ export class Session {
|
||||
provider: descriptor.provider,
|
||||
direction,
|
||||
epoch: timeline.epoch,
|
||||
projection: "projected",
|
||||
startCursor:
|
||||
timeline.startSeq === null ? null : { epoch: timeline.epoch, seq: timeline.startSeq },
|
||||
endCursor:
|
||||
timeline.endSeq === null ? null : { epoch: timeline.epoch, seq: timeline.endSeq },
|
||||
reset: timeline.reset,
|
||||
staleCursor: timeline.staleCursor,
|
||||
gap: timeline.gap,
|
||||
window: timeline.window,
|
||||
hasOlder: timeline.hasOlder,
|
||||
hasNewer: timeline.hasNewer,
|
||||
rows: rows.map((row) => ({
|
||||
item: row.item,
|
||||
timestamp: row.timestamp,
|
||||
seq: row.seq,
|
||||
})),
|
||||
hasOlder: supportsProjection && timeline.hasOlder,
|
||||
hasNewer: supportsProjection && timeline.hasNewer,
|
||||
rows: supportsProjection
|
||||
? rows.map((row) => ({
|
||||
item: row.item,
|
||||
timestamp: row.timestamp,
|
||||
seq: row.seqEnd,
|
||||
seqStart: row.seqStart,
|
||||
seqEnd: row.seqEnd,
|
||||
sourceSeqRanges: row.sourceSeqRanges,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
seq: timeline.window.maxSeq,
|
||||
timestamp: new Date().toISOString(),
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: "Please upgrade the Paseo app to view this subagent conversation.",
|
||||
},
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1741,6 +1741,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
agentForkContextCursor: true,
|
||||
// COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12.
|
||||
providerSubagents: true,
|
||||
// COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gates after 2027-03-14; retain advertisement.
|
||||
projectedSubagentTimeline: true,
|
||||
// COMPAT(providerSubagentNesting): added in v0.7, remove gate after 2027-03-04.
|
||||
providerSubagentNesting: true,
|
||||
// COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12.
|
||||
|
||||
Reference in New Issue
Block a user