fix(timeline): finalize canonical prompt delivery

Treat provider identity as a single stored-row transition so legacy clients receive one deliverable canonical prompt. Release identity-pending rows before later foreground output or the current terminal while preserving stale-turn isolation.
This commit is contained in:
Mohamed Boudra
2026-08-01 04:57:49 +02:00
parent 7e757468a1
commit 64be90766b
12 changed files with 520 additions and 489 deletions
+4 -7
View File
@@ -181,13 +181,10 @@ original sequence; clients apply it only to the already-covered row with the sam
Clients that understand these same-sequence updates advertise the
`canonical_submitted_prompt_revisions` client capability. For older destinations, the daemon keeps
the canonical row globally but withholds a rewind-capable foreground prompt until provider identity
arrives, then delivers the enriched row once at its original sequence. Timeline fetches apply the
same destination projection: a trailing identity-pending suffix is temporarily outside the visible
window, while daemon-handled prompts and providers without the rewind identity contract remain
immediately visible.
An immediately interrupted turn remains
canonical even when the provider never emits its echo.
the canonical row globally but withholds an identity-awaiting foreground prompt until provider identity
arrives or the turn produces later output or a terminal. Finalization optionally adds provider
identity, removes the internal delivery marker, and redispatches the same stored sequence. Timeline
fetches hide only that trailing marked row; daemon-handled prompts are never marked.
Content matching is limited to the dated compatibility path for daemon timelines created before
that field existed. Canonical ingestion may match only an explicit unreconciled local candidate;
the draft-create handoff is the one boundary that also permits the legacy canonical twin to have
+25 -30
View File
@@ -15,7 +15,6 @@ import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent";
import { queryClient } from "@/data/query-client";
import {
APP_CLIENT_CAPABILITIES,
HostRuntimeController,
HostRuntimeStore,
readInitialDaemonConnectionHint,
@@ -24,10 +23,17 @@ import {
} from "./host-runtime";
import { ReplicaCache } from "./replica-cache";
it("advertises canonical submitted prompt revision support", () => {
expect(APP_CLIENT_CAPABILITIES).toMatchObject({
[CLIENT_CAPS.canonicalSubmittedPromptRevisions]: true,
});
const capturedDefaultClientOptions = vi.hoisted(() => [] as Array<Record<string, unknown>>);
vi.mock("@getpaseo/client/internal/daemon-client", async (importOriginal) => {
const actual = await importOriginal<typeof import("@getpaseo/client/internal/daemon-client")>();
return {
...actual,
DaemonClient: function DaemonClientDouble(options: Record<string, unknown>) {
capturedDefaultClientOptions.push(options);
return new FakeDaemonClient();
},
};
});
class FakeDaemonClient {
@@ -566,36 +572,25 @@ describe("HostRuntimeController", () => {
expect(controller.getSnapshot().agentDirectoryStatus).toBe("initial_loading");
});
it("passes resolved client id into created active clients", async () => {
const host = makeHost({
connections: [
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
},
],
});
const seenClientIds: string[] = [];
const fakeClient = new FakeDaemonClient();
const controller = new HostRuntimeController({
host,
deps: {
createClient: ({ clientId }) => {
seenClientIds.push(clientId);
return fakeClient as unknown as DaemonClient;
},
connectToDaemon: async () => {
throw new Error("probe unavailable");
},
getClientId: async () => "cid_runtime_stable",
it("passes resolved client configuration into created active clients", async () => {
capturedDefaultClientOptions.length = 0;
const values = new Map([["@paseo:client-id-v1", "cid_runtime_stable"]]);
(globalThis as { window?: unknown }).window = {
localStorage: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
},
});
};
const controller = new HostRuntimeController({ host: makeHost() });
await controller.activateConnection({ connectionId: "direct:lan:6767" });
expect(seenClientIds).toEqual(["cid_runtime_stable"]);
expect(capturedDefaultClientOptions[0]).toMatchObject({
clientId: "cid_runtime_stable",
capabilities: { [CLIENT_CAPS.canonicalSubmittedPromptRevisions]: true },
});
expect(controller.getSnapshot().connectionStatus).toBe("online");
await controller.stop();
});
it("keeps browser client lifecycle tied to the active host runtime client", async () => {
+2 -6
View File
@@ -456,11 +456,6 @@ function probeIntervalForConnection(
return PROBE_MAX_BACKOFF_MS;
}
export const APP_CLIENT_CAPABILITIES = {
[CLIENT_CAPS.selectiveAgentTimeline]: true,
[CLIENT_CAPS.canonicalSubmittedPromptRevisions]: true,
} as const;
function createDefaultDeps(): HostRuntimeControllerDeps {
const browserHostAvailable =
typeof getDesktopHost()?.browser?.executeAutomationCommand === "function";
@@ -473,7 +468,8 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
}
: undefined;
const appCapabilities = {
...APP_CLIENT_CAPABILITIES,
[CLIENT_CAPS.selectiveAgentTimeline]: true,
[CLIENT_CAPS.canonicalSubmittedPromptRevisions]: true,
...browserAutomationCapabilities,
};
@@ -1,19 +0,0 @@
import { describe, expect, it } from "vitest";
import { CLIENT_CAPS } from "./client-capabilities.js";
import { WSHelloMessageSchema } from "./messages.js";
describe("client capabilities", () => {
it("accepts canonical submitted prompt revision support in hello", () => {
const hello = WSHelloMessageSchema.parse({
type: "hello",
clientId: "client-capability-test",
clientType: "mobile",
protocolVersion: 1,
capabilities: {
[CLIENT_CAPS.canonicalSubmittedPromptRevisions]: true,
},
});
expect(hello.capabilities?.[CLIENT_CAPS.canonicalSubmittedPromptRevisions]).toBe(true);
});
});
@@ -4633,6 +4633,7 @@ test("replaceAgentRun stays running when a stale old terminal arrives before the
const interruptStarted = deferred<void>();
const allowInterruptToFinish = deferred<void>();
const allowSecondStartToResolve = deferred<void>();
const allowSecondTurnToComplete = deferred<void>();
let capturedSession: StaleReplacementSession | null = null;
class StaleReplacementSession extends TestAgentSession {
@@ -4651,8 +4652,9 @@ test("replaceAgentRun stays running when a stale old terminal arrives before the
secondStartEntered.resolve();
await allowSecondStartToResolve.promise;
setTimeout(() => {
setTimeout(async () => {
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
await allowSecondTurnToComplete.promise;
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
}, 0);
return { turnId };
@@ -4709,7 +4711,9 @@ test("replaceAgentRun stays running when a stale old terminal arrives before the
{ agentId: snapshot.id, replayState: false },
);
const firstRun = manager.streamAgent(snapshot.id, "first run");
const firstRun = manager.streamAgent(snapshot.id, "first run", {
clientMessageId: "first-client-message",
});
const firstRunDrain = (async () => {
for await (const _event of firstRun) {
// Drain events so lifecycle updates are applied.
@@ -4720,7 +4724,9 @@ test("replaceAgentRun stays running when a stale old terminal arrives before the
const replaceUpdatesStart = stateUpdates.length;
const beforeReplaceUpdatedAt = manager.getAgent(snapshot.id)?.updatedAt.getTime() ?? 0;
const secondRunPromise = manager.replaceAgentRun(snapshot.id, "replacement run");
const secondRunPromise = manager.replaceAgentRun(snapshot.id, "replacement run", {
clientMessageId: "replacement-client-message",
});
await interruptStarted.promise;
const replacementUpdates = stateUpdates.slice(replaceUpdatesStart);
@@ -4766,6 +4772,18 @@ test("replaceAgentRun stays running when a stale old terminal arrives before the
allowSecondStartToResolve.resolve();
await replacementStart;
capturedSession!.pushEvent({ type: "turn_completed", provider: "codex", turnId: "turn-1" });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(
manager
.fetchTimeline(snapshot.id, { direction: "tail", limit: 0 })
.rows.find(
(row) =>
row.item.type === "user_message" &&
row.item.clientMessageId === "replacement-client-message",
)?.delivery,
).toBe("awaiting_provider_identity");
allowSecondTurnToComplete.resolve();
await firstRunDrain;
await secondRunDrain;
unsubscribe();
@@ -8154,17 +8172,19 @@ test("authoritative timeline includes provider-emitted submitted user prompt", a
const text = typeof prompt === "string" ? prompt : "";
const publishProviderIdentity = () => {
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
this.pushEvent({
type: "timeline",
provider: this.provider,
turnId,
item: {
type: "user_message",
text,
messageId: `provider-message-${this.turnCount}`,
clientMessageId: options?.clientMessageId,
},
});
if (this.turnCount !== 3 && this.turnCount !== 5) {
this.pushEvent({
type: "timeline",
provider: this.provider,
turnId,
item: {
type: "user_message",
text,
...(this.turnCount === 4 ? {} : { messageId: `provider-message-${this.turnCount}` }),
clientMessageId: options?.clientMessageId,
},
});
}
if (this.turnCount === 1) {
this.pushEvent({
type: "timeline",
@@ -8172,11 +8192,18 @@ test("authoritative timeline includes provider-emitted submitted user prompt", a
turnId,
item: { type: "assistant_message", text: "provider output after identity" },
});
} else if (this.turnCount === 3) {
this.pushEvent({
type: "timeline",
provider: this.provider,
turnId,
item: { type: "assistant_message", text: "provider output without echo" },
});
}
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
};
if (this.turnCount === 1) setTimeout(publishProviderIdentity, 0);
else publishProviderIdentity();
if (this.turnCount === 2) publishProviderIdentity();
else setTimeout(publishProviderIdentity, 0);
return { turnId };
}
}
@@ -8195,6 +8222,26 @@ test("authoritative timeline includes provider-emitted submitted user prompt", a
});
const events: AgentManagerEvent[] = [];
manager.subscribe((event) => events.push(event), { replayState: false });
const streamEvents = () =>
events.flatMap((event) => {
if (event.type !== "agent_stream") return [];
if (event.event.type !== "timeline") return [{ type: event.event.type }];
const item = event.event.item;
return [
{
type: item.type,
seq: event.seq,
delivery: event.delivery,
...(item.type === "user_message"
? {
text: item.text,
clientMessageId: item.clientMessageId,
messageId: item.messageId,
}
: {}),
},
];
});
try {
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
@@ -8216,95 +8263,91 @@ test("authoritative timeline includes provider-emitted submitted user prompt", a
{ clientMessageId: "msg-client-1" },
);
const liveEvents = events.filter(
(event): event is Extract<AgentManagerEvent, { type: "agent_stream" }> =>
event.type === "agent_stream" && event.agentId === snapshot.id,
);
expect(
liveEvents.map((event) =>
event.event.type === "timeline"
? {
type: event.event.type,
item: event.event.item,
seq: event.seq,
delivery: event.delivery,
}
: { type: event.event.type },
),
).toEqual([
expect(streamEvents()).toEqual([
{ type: "turn_started" },
{
type: "timeline",
item: {
type: "user_message",
text: "hello from composer",
clientMessageId: "msg-client-1",
},
type: "user_message",
seq: 1,
delivery: "awaiting_provider_identity",
text: "hello from composer",
clientMessageId: "msg-client-1",
messageId: undefined,
},
{
type: "timeline",
item: {
type: "user_message",
text: "hello from composer",
messageId: "provider-message-1",
clientMessageId: "msg-client-1",
},
type: "user_message",
seq: 1,
delivery: "awaiting_provider_identity",
},
{
type: "timeline",
item: { type: "assistant_message", text: "provider output after identity" },
seq: 2,
delivery: undefined,
text: "hello from composer",
clientMessageId: "msg-client-1",
messageId: "provider-message-1",
},
{ type: "assistant_message", seq: 2, delivery: undefined },
{ type: "turn_completed" },
]);
const timeline = manager.fetchTimeline(snapshot.id, { direction: "tail", limit: 20 }).rows;
expect(timeline).toEqual([
{
seq: 1,
timestamp: expect.any(String),
delivery: "awaiting_provider_identity",
item: {
expect(timeline.map((row) => [row.seq, row.delivery, row.item])).toEqual([
[
1,
undefined,
{
type: "user_message",
text: "hello from composer",
messageId: "provider-message-1",
clientMessageId: "msg-client-1",
},
},
{
seq: 2,
timestamp: expect.any(String),
item: { type: "assistant_message", text: "provider output after identity" },
},
],
[2, undefined, { type: "assistant_message", text: "provider output after identity" }],
]);
events.length = 0;
await manager.runAgent(snapshot.id, "staged identity", {
clientMessageId: "msg-client-2",
});
expect(
events.flatMap((event) =>
event.type === "agent_stream" && event.event.type === "timeline"
? [{ seq: event.seq, delivery: event.delivery, item: event.event.item }]
: [],
),
).toEqual([
expect(streamEvents().filter((event) => event.type === "user_message")).toEqual([
{
type: "user_message",
seq: 3,
delivery: undefined,
item: {
type: "user_message",
text: "staged identity",
messageId: "provider-message-2",
clientMessageId: "msg-client-2",
},
text: "staged identity",
messageId: "provider-message-2",
clientMessageId: "msg-client-2",
},
]);
events.length = 0;
await manager.runAgent(snapshot.id, "output without echo", {
clientMessageId: "msg-client-3",
});
expect(streamEvents()).toEqual([
{ type: "turn_started" },
expect.objectContaining({
type: "user_message",
seq: 4,
delivery: "awaiting_provider_identity",
}),
expect.objectContaining({ type: "user_message", seq: 4, delivery: undefined }),
{ type: "assistant_message", seq: 5, delivery: undefined },
{ type: "turn_completed" },
]);
for (const [prompt, clientMessageId, seq] of [
["echo without identity", "msg-client-4", 6],
["terminal without echo", "msg-client-5", 7],
] as const) {
events.length = 0;
await manager.runAgent(snapshot.id, prompt, { clientMessageId });
expect(streamEvents()).toEqual([
{ type: "turn_started" },
expect.objectContaining({
type: "user_message",
seq,
delivery: "awaiting_provider_identity",
}),
expect.objectContaining({ type: "user_message", seq, delivery: undefined }),
{ type: "turn_completed" },
]);
}
} finally {
await manager.flush().catch(() => undefined);
await storage.flush().catch(() => undefined);
@@ -8319,6 +8362,11 @@ test("authoritative timeline records a daemon-handled submitted prompt before it
const commandCompleted = deferred<void>();
class DaemonHandledPromptSession extends TestAgentSession {
override readonly capabilities = {
...TEST_CAPABILITIES,
supportsRewindConversation: true,
};
override tryHandleOutOfBand(prompt: AgentPromptInput) {
if (prompt !== "/handled") return null;
return {
+104 -56
View File
@@ -2081,14 +2081,13 @@ export class AgentManager {
)
: undefined;
if (options?.clientMessageId) {
this.recordSubmittedPrompt(
agent,
prompt,
options.clientMessageId,
stagedSubmittedPromptEcho?.item.type === "user_message"
? stagedSubmittedPromptEcho.item.messageId
: undefined,
);
this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, {
awaitProviderIdentity: true,
messageId:
stagedSubmittedPromptEcho?.item.type === "user_message"
? stagedSubmittedPromptEcho.item.messageId
: undefined,
});
}
for (const stagedEvent of pendingRun.stagedEvents.splice(0)) {
const isAcceptedTurnStart =
@@ -3432,6 +3431,13 @@ export class AgentManager {
return false;
}
this.finalizeSubmittedPromptBeforeTimeline(
agent,
event,
options?.fromHistory === true,
isForegroundEvent,
);
// Only update timestamp for live events, not history replay
if (!options?.fromHistory) {
this.touchUpdatedAt(agent);
@@ -3442,12 +3448,13 @@ export class AgentManager {
this.agentStreamCoalescer.flushFor(agent.id);
}
let terminalDisposition: ActiveTurnTerminalDisposition = "untracked";
if (isTurnTerminalEvent(event)) {
terminalDisposition = options?.fromHistory
? "stale"
: this.applyActiveTurnTerminal(agent, eventTurnId);
}
const terminalDisposition = this.applyStreamTerminalDisposition(
agent,
event,
eventTurnId,
options?.fromHistory === true,
isForegroundEvent,
);
const flags: StreamEventFlags = { shouldDispatchEvent: true, shouldNotifyWaiters: true };
@@ -3501,6 +3508,21 @@ export class AgentManager {
);
}
private applyStreamTerminalDisposition(
agent: ActiveManagedAgent,
event: AgentStreamEvent,
turnId: string | undefined,
fromHistory: boolean,
isForegroundEvent: boolean,
): ActiveTurnTerminalDisposition {
if (!isTurnTerminalEvent(event)) return "untracked";
const disposition = fromHistory ? "stale" : this.applyActiveTurnTerminal(agent, turnId);
if (isForegroundEvent && disposition !== "stale") {
this.finalizeSubmittedPrompt(agent, event);
}
return disposition;
}
private traceCoalescerBuffered(
agent: ActiveManagedAgent,
event: AgentStreamEvent,
@@ -3896,8 +3918,9 @@ export class AgentManager {
item: AgentTimelineItem,
provider: AgentProvider,
turnId?: string,
delivery?: AgentTimelineRow["delivery"],
): AgentStreamEvent {
const row = this.recordTimeline(agentId, item);
const row = this.recordTimeline(agentId, item, delivery ? { delivery } : undefined);
const event: AgentStreamEvent = {
type: "timeline",
item,
@@ -3908,6 +3931,7 @@ export class AgentManager {
seq: row.seq,
epoch: this.timelineStore.getEpoch(agentId),
timestamp: row.timestamp,
...(row.delivery ? { delivery: row.delivery } : {}),
});
if (
@@ -3929,7 +3953,7 @@ export class AgentManager {
agent: ActiveManagedAgent,
prompt: AgentPromptInput,
clientMessageId: string,
messageId?: string,
options?: { awaitProviderIdentity: boolean; messageId: string | undefined },
): void {
if (this.timelineStore.getSubmittedUserMessage(agent.id, clientMessageId)) {
return;
@@ -3940,27 +3964,13 @@ export class AgentManager {
type: "user_message",
text: submittedPromptText(prompt),
clientMessageId,
...(messageId ? { messageId } : {}),
...(options?.messageId ? { messageId: options.messageId } : {}),
};
const capabilities = agent.session.capabilities;
const delivery =
messageId === undefined &&
(capabilities.supportsRewindConversation ||
capabilities.supportsRewindFiles ||
capabilities.supportsRewindBoth)
options?.awaitProviderIdentity === true && options.messageId === undefined
? AWAITING_PROVIDER_IDENTITY_DELIVERY
: undefined;
const row = this.recordTimeline(agent.id, item, delivery ? { delivery } : undefined);
this.dispatchStream(
agent.id,
{ type: "timeline", item, provider: agent.provider },
{
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
...(row.delivery ? { delivery: row.delivery } : {}),
},
);
this.recordAndDispatchTimelineItem(agent.id, item, agent.provider, undefined, delivery);
}
private reconcileSubmittedPromptEcho(
@@ -3971,31 +3981,69 @@ export class AgentManager {
const { clientMessageId, messageId } = item;
if (!clientMessageId) return null;
const existing = this.timelineStore.getSubmittedUserMessage(agent.id, clientMessageId);
if (
!existing ||
existing.item.type !== "user_message" ||
!messageId ||
existing.item.messageId === messageId
) {
return existing;
if (!existing || existing.item.type !== "user_message") return null;
this.finalizeSubmittedPrompt(agent, event, clientMessageId, messageId);
return existing;
}
private finalizeSubmittedPromptBeforeTimeline(
agent: ActiveManagedAgent,
event: AgentStreamEvent,
fromHistory: boolean,
isForegroundEvent: boolean,
): void {
if (fromHistory || !isForegroundEvent || event.type !== "timeline") return;
const item = event.item;
if (item.type === "user_message") {
if (isSystemInjectedEnvelope(item.text)) return;
if (
item.clientMessageId &&
this.timelineStore.getSubmittedUserMessage(agent.id, item.clientMessageId)
) {
return;
}
}
const row = this.timelineStore.enrichSubmittedUserMessage(agent.id, clientMessageId, {
messageId,
});
if (row) {
this.enqueueDurableTimelineUpdate(agent.id, row);
this.dispatchStream(
agent.id,
{ ...event, item: row.item },
{
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
...(row.delivery ? { delivery: row.delivery } : {}),
},
this.finalizeSubmittedPrompt(agent, event);
}
private finalizeSubmittedPrompt(
agent: ActiveManagedAgent,
event: AgentStreamEvent,
correlatedClientMessageId?: string,
messageId?: string,
): void {
const pending = this.timelineStore
.getRows(agent.id)
.findLast(
(row) =>
row.delivery === AWAITING_PROVIDER_IDENTITY_DELIVERY && row.item.type === "user_message",
);
}
return row;
const clientMessageId =
correlatedClientMessageId ??
(pending?.item.type === "user_message" ? pending.item.clientMessageId : undefined);
if (!clientMessageId) return;
const row = this.timelineStore.finalizeSubmittedUserMessage(
agent.id,
clientMessageId,
messageId ? { messageId } : undefined,
);
if (!row) return;
this.enqueueDurableTimelineUpdate(agent.id, row);
const turnId = getAgentStreamEventTurnId(event);
this.dispatchStream(
agent.id,
{
type: "timeline",
item: row.item,
provider: event.provider,
...(turnId ? { turnId } : {}),
},
{
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
},
);
}
private async appendSystemErrorTimelineMessage(
@@ -2,16 +2,20 @@ import { describe, expect, it } from "vitest";
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
describe("InMemoryAgentTimelineStore", () => {
it("enriches a submitted prompt without allocating a second sequence", () => {
it("finalizes a submitted prompt without allocating a second sequence", () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("agent-1", { epoch: "epoch-1" });
store.append("agent-1", {
type: "user_message",
text: "Accepted prompt",
clientMessageId: "client-message-1",
});
store.append(
"agent-1",
{
type: "user_message",
text: "Accepted prompt",
clientMessageId: "client-message-1",
},
{ delivery: "awaiting_provider_identity" },
);
const updated = store.enrichSubmittedUserMessage("agent-1", "client-message-1", {
const updated = store.finalizeSubmittedUserMessage("agent-1", "client-message-1", {
messageId: "provider-message-1",
});
@@ -24,6 +28,7 @@ describe("InMemoryAgentTimelineStore", () => {
messageId: "provider-message-1",
},
});
expect(updated?.delivery).toBeUndefined();
expect(store.getRows("agent-1")).toEqual([updated]);
expect(store.fetch("agent-1", { direction: "tail", limit: 1 }).window).toEqual({
minSeq: 1,
@@ -176,20 +176,28 @@ export class InMemoryAgentTimelineStore {
return row ? cloneRow(row) : null;
}
enrichSubmittedUserMessage(
finalizeSubmittedUserMessage(
agentId: string,
clientMessageId: string,
enrichment: { messageId: string },
identity?: { messageId: string },
): AgentTimelineRow | null {
const state = this.requireState(agentId);
const row = state.rows.find(
const index = state.rows.findIndex(
(candidate) =>
candidate.item.type === "user_message" &&
candidate.item.clientMessageId === clientMessageId,
);
if (!row || row.item.type !== "user_message") return null;
row.item = { ...row.item, messageId: enrichment.messageId };
return cloneRow(row);
const row = state.rows[index];
if (!row || row.item.type !== "user_message" || row.delivery !== "awaiting_provider_identity") {
return null;
}
const finalized: AgentTimelineRow = {
seq: row.seq,
timestamp: row.timestamp,
item: identity ? { ...row.item, messageId: identity.messageId } : row.item,
};
state.rows[index] = finalized;
return cloneRow(finalized);
}
getEpoch(agentId: string): string {
@@ -1,63 +1,50 @@
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type {
AgentTimelineFetchResult,
AgentTimelineRow,
AgentTimelineRowDelivery,
} from "./agent-timeline-store-types.js";
export const AWAITING_PROVIDER_IDENTITY_DELIVERY = "awaiting_provider_identity" as const;
export function shouldDeliverSubmittedPromptRow(input: {
delivery: AgentTimelineRowDelivery | undefined;
item: AgentTimelineItem;
supportsRevisions: boolean;
}): boolean {
export function shouldDeliverSubmittedPromptRow(
delivery: AgentTimelineRowDelivery | undefined,
item: AgentTimelineItem,
supportsRevisions: boolean,
): boolean {
return (
input.supportsRevisions ||
input.delivery !== AWAITING_PROVIDER_IDENTITY_DELIVERY ||
input.item.type !== "user_message" ||
input.item.messageId !== undefined
supportsRevisions ||
delivery !== AWAITING_PROVIDER_IDENTITY_DELIVERY ||
item.type !== "user_message" ||
item.messageId !== undefined
);
}
export function findSubmittedPromptVisibleMaxSeq(
rows: readonly AgentTimelineRow[],
maxSeq: number,
): number {
let visibleMaxSeq = maxSeq;
for (let index = rows.length - 1; index >= 0; index -= 1) {
const row = rows[index];
if (
!row ||
row.seq !== visibleMaxSeq ||
shouldDeliverSubmittedPromptRow({
delivery: row.delivery,
item: row.item,
supportsRevisions: false,
})
) {
break;
}
visibleMaxSeq -= 1;
export function projectSubmittedPromptTimeline(
timeline: AgentTimelineFetchResult,
fullTimeline = timeline,
cursorSeq = 0,
): AgentTimelineFetchResult {
const pending = fullTimeline.rows.at(-1);
if (
!pending ||
pending.seq !== fullTimeline.window.maxSeq ||
shouldDeliverSubmittedPromptRow(pending.delivery, pending.item, false)
) {
return timeline;
}
return visibleMaxSeq;
}
export function projectSubmittedPromptTimeline(input: {
timeline: AgentTimelineFetchResult;
visibleMaxSeq: number;
cursorSeq?: number;
}): AgentTimelineFetchResult {
const rows = input.timeline.rows.filter((row) => row.seq <= input.visibleMaxSeq);
const selectedEndSeq = rows.at(-1)?.seq ?? input.cursorSeq ?? 0;
const visibleMaxSeq = pending.seq - 1;
const rows = timeline.rows.filter((row) => row.seq <= visibleMaxSeq);
const selectedEndSeq = rows.at(-1)?.seq ?? cursorSeq;
return {
...input.timeline,
...timeline,
window: {
...input.timeline.window,
maxSeq: input.visibleMaxSeq,
nextSeq: input.visibleMaxSeq + 1,
...timeline.window,
minSeq:
visibleMaxSeq === 0 || timeline.window.minSeq > visibleMaxSeq ? 0 : timeline.window.minSeq,
maxSeq: visibleMaxSeq,
nextSeq: visibleMaxSeq + 1,
},
hasNewer: input.timeline.hasNewer && selectedEndSeq < input.visibleMaxSeq,
hasNewer: timeline.hasNewer && selectedEndSeq < visibleMaxSeq,
rows,
};
}
+47 -178
View File
@@ -22,11 +22,7 @@ import { isSessionRpcAllowed, Session } from "./session.js";
import { DownloadTokenStore } from "./file-download/token-store.js";
import { StructuredAgentFallbackError } from "./agent/agent-response-loop.js";
import type { StoredAgentRecord } from "./agent/agent-storage.js";
import type {
AgentManagerEvent,
AgentTimelineFetchResult,
ManagedAgent,
} from "./agent/agent-manager.js";
import type { AgentManagerEvent } from "./agent/agent-manager.js";
import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
import { createPersistedProjectRecord } from "./workspace-registry.js";
import { deriveProjectKey } from "./project-key.js";
@@ -4887,11 +4883,9 @@ test("keeps selective delivery scoped per socket when a retained session also ha
test("projects canonical submitted prompt revisions per retained socket capability", async () => {
const targetedMessages: Array<{ source: object; message: SessionOutboundMessage }> = [];
const agentEventListeners: Array<(event: AgentManagerEvent) => void> = [];
const rewind = vi.fn().mockResolvedValue(undefined);
const session = createSessionForTest({
targetedMessages,
agentManager: {
rewind,
subscribe: vi.fn((listener: (event: AgentManagerEvent) => void) => {
agentEventListeners.push(listener);
return () => {};
@@ -4939,7 +4933,6 @@ test("projects canonical submitted prompt revisions per retained socket capabili
},
seq: 5,
epoch: "epoch-1",
delivery: "awaiting_provider_identity",
});
listener({
type: "agent_stream",
@@ -4956,7 +4949,6 @@ test("projects canonical submitted prompt revisions per retained socket capabili
},
seq: 6,
epoch: "epoch-1",
delivery: "awaiting_provider_identity",
});
listener({
type: "agent_stream",
@@ -5004,184 +4996,61 @@ test("projects canonical submitted prompt revisions per retained socket capabili
{ seq: 6, clientMessageId: "client-message-2", messageId: "provider-message-2" },
{ seq: 7, clientMessageId: undefined, messageId: undefined },
]);
await session.handleMessage(
{
type: "agent.rewind.request",
requestId: "rewind-provider-identity",
agentId: "agent-revisions",
messageId: "provider-message-1",
mode: "conversation",
},
legacySocket,
);
expect(rewind).toHaveBeenCalledWith("agent-revisions", "provider-message-1", "conversation");
});
test("projects a coherent legacy fetch window around pending provider identity", async () => {
const source = {};
const targetedMessages: Array<{ source: object; message: SessionOutboundMessage }> = [];
const timestamp = "2026-08-01T00:00:00.000Z";
const agent = {
id: "agent-fetch-revisions",
provider: "codex",
cwd: "/tmp/agent-fetch-revisions",
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
supportsRewindConversation: true,
},
config: { provider: "codex", cwd: "/tmp/agent-fetch-revisions" },
runtimeInfo: { provider: "codex", sessionId: "session-fetch-revisions" },
createdAt: new Date(timestamp),
updatedAt: new Date(timestamp),
availableModes: [],
currentModeId: null,
pendingPermissions: new Map(),
persistence: null,
historyPrimed: true,
lastUserMessageAt: new Date(timestamp),
activeTurnId: null,
activeTurnStartedAt: null,
lastUsage: undefined,
attention: { requiresAttention: false },
labels: {},
lifecycle: "idle",
activeForegroundTurnId: null,
} as unknown as ManagedAgent;
let timeline: AgentTimelineFetchResult = {
epoch: "epoch-fetch-revisions",
direction: "tail",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 1, maxSeq: 5, nextSeq: 6 },
hasOlder: true,
hasNewer: false,
rows: [
{
seq: 4,
timestamp,
item: { type: "assistant_message", text: "before submitted prompt" },
},
{
seq: 5,
timestamp,
delivery: "awaiting_provider_identity",
item: {
type: "user_message",
text: "submitted prompt",
clientMessageId: "client-message-fetch",
},
},
],
};
test.each([
[null, ["provider-message-1"]],
[{ [CLIENT_CAPS.canonicalSubmittedPromptRevisions]: true }, [undefined, "provider-message-1"]],
])("uses the negotiated fallback capability for prompt revisions", (capabilities, expectedIds) => {
const messages: SessionOutboundMessage[] = [];
let listener: ((event: AgentManagerEvent) => void) | undefined;
const session = createSessionForTest({
targetedMessages,
messages,
agentManager: {
waitForAgentClose: vi.fn().mockResolvedValue(undefined),
getAgent: vi.fn(() => agent),
fetchTimeline: vi.fn(() => timeline),
subscribe: vi.fn((next: (event: AgentManagerEvent) => void) => {
listener = next;
return () => {};
}),
},
});
session.updateClientCapabilities(null, source);
await session.handleMessage(
{
type: "fetch_agent_timeline_request",
requestId: "fetch-before-provider-identity",
agentId: agent.id,
direction: "tail",
projection: "canonical",
limit: 20,
},
source,
);
const before = targetedMessages.find(
({ message }) =>
message.type === "fetch_agent_timeline_response" &&
message.payload.requestId === "fetch-before-provider-identity",
)?.message;
expect(before).toMatchObject({
type: "fetch_agent_timeline_response",
payload: {
requestId: "fetch-before-provider-identity",
error: null,
window: { minSeq: 1, maxSeq: 4, nextSeq: 5 },
endCursor: { epoch: "epoch-fetch-revisions", seq: 4 },
hasNewer: false,
entries: [
{
seqStart: 4,
seqEnd: 4,
item: { type: "assistant_message", text: "before submitted prompt" },
},
],
},
});
timeline = {
...timeline,
rows: [
timeline.rows[0]!,
{
...timeline.rows[1]!,
item: {
type: "user_message",
text: "submitted prompt",
clientMessageId: "client-message-fetch",
messageId: "provider-message-fetch",
},
},
],
session.updateClientCapabilities(capabilities);
const item = {
type: "user_message" as const,
text: "submitted prompt",
clientMessageId: "client-message-1",
};
targetedMessages.length = 0;
await session.handleMessage(
{
type: "fetch_agent_timeline_request",
requestId: "fetch-after-provider-identity",
agentId: agent.id,
direction: "tail",
projection: "canonical",
limit: 20,
},
source,
);
const after = targetedMessages.find(
({ message }) =>
message.type === "fetch_agent_timeline_response" &&
message.payload.requestId === "fetch-after-provider-identity",
)?.message;
expect(after).toMatchObject({
type: "fetch_agent_timeline_response",
payload: {
requestId: "fetch-after-provider-identity",
error: null,
window: { minSeq: 1, maxSeq: 5, nextSeq: 6 },
endCursor: { epoch: "epoch-fetch-revisions", seq: 5 },
hasNewer: false,
entries: [
{ seqStart: 4, seqEnd: 4 },
{
seqStart: 5,
seqEnd: 5,
item: {
type: "user_message",
text: "submitted prompt",
clientMessageId: "client-message-fetch",
messageId: "provider-message-fetch",
},
},
],
},
listener?.({
type: "agent_stream",
agentId: "agent-revisions",
event: { type: "timeline", provider: "codex", item },
seq: 1,
epoch: "epoch-1",
delivery: "awaiting_provider_identity",
});
listener?.({
type: "agent_stream",
agentId: "agent-revisions",
event: {
type: "timeline",
provider: "codex",
item: { ...item, messageId: "provider-message-1" },
},
seq: 1,
epoch: "epoch-1",
});
expect(
messages.flatMap((message) =>
message.type === "agent_stream" && message.payload.event.type === "timeline"
? [
message.payload.event.item.type === "user_message"
? message.payload.event.item.messageId
: undefined,
]
: [],
),
).toEqual(expectedIds);
});
test("sends project updates only to capable sockets in a retained session", () => {
+36 -39
View File
@@ -77,7 +77,6 @@ import type {
ManagedAgent,
} from "./agent/agent-manager.js";
import {
findSubmittedPromptVisibleMaxSeq,
projectSubmittedPromptTimeline,
shouldDeliverSubmittedPromptRow,
} from "./agent/submitted-prompt-delivery.js";
@@ -1070,11 +1069,11 @@ export class Session {
if (this.clientCapabilitiesBySource.size === 0 || !this.onMessageToSource) {
if (
serializedEvent.type === "timeline" &&
!shouldDeliverSubmittedPromptRow({
delivery: event.delivery,
item: serializedEvent.item,
supportsRevisions: this.supports(CLIENT_CAPS.canonicalSubmittedPromptRevisions),
})
!shouldDeliverSubmittedPromptRow(
event.delivery,
serializedEvent.item,
this.supports(CLIENT_CAPS.canonicalSubmittedPromptRevisions),
)
) {
return;
}
@@ -1105,11 +1104,11 @@ export class Session {
const supportsSelectiveDelivery = capabilities.has(CLIENT_CAPS.selectiveAgentTimeline);
if (
serializedEvent.type === "timeline" &&
!shouldDeliverSubmittedPromptRow({
delivery: event.delivery,
item: serializedEvent.item,
supportsRevisions: capabilities.has(CLIENT_CAPS.canonicalSubmittedPromptRevisions),
})
!shouldDeliverSubmittedPromptRow(
event.delivery,
serializedEvent.item,
capabilities.has(CLIENT_CAPS.canonicalSubmittedPromptRevisions),
)
) {
continue;
}
@@ -6120,36 +6119,30 @@ export class Session {
direction: AgentTimelineFetchDirection;
cursor?: AgentTimelineCursor;
pageLimit: number;
visibleMaxSeq?: number;
fullTimeline?: AgentTimelineFetchResult;
}): AgentTimelineProjectionSelection {
const selectedTimeline = this.shouldUseFullTimelineForProjectedPage({
timeline: input.controlTimeline,
pageLimit: input.pageLimit,
})
? this.agentManager.fetchTimeline(input.agentId, { direction: "tail", limit: 0 })
? (input.fullTimeline ??
this.agentManager.fetchTimeline(input.agentId, { direction: "tail", limit: 0 }))
: input.controlTimeline;
const timeline =
input.visibleMaxSeq === undefined
? selectedTimeline
: projectSubmittedPromptTimeline({
timeline: selectedTimeline,
visibleMaxSeq: input.visibleMaxSeq,
...(input.cursor ? { cursorSeq: input.cursor.seq } : {}),
});
const page = selectProjectedTimelinePage({
rows: timeline.rows,
bounds: timeline.window,
rows: selectedTimeline.rows,
bounds: selectedTimeline.window,
direction: input.controlTimeline.reset ? "tail" : input.direction,
...(input.cursor ? { cursorSeq: input.cursor.seq } : {}),
limit: input.pageLimit,
});
return {
timeline,
timeline: selectedTimeline,
entries: page.entries,
startSeq: page.startSeq,
endSeq: page.endSeq,
hasOlder: page.hasOlder || (page.startSeq !== null && page.startSeq > timeline.window.minSeq),
hasOlder:
page.hasOlder || (page.startSeq !== null && page.startSeq > selectedTimeline.window.minSeq),
hasNewer: page.hasNewer,
};
}
@@ -6161,7 +6154,7 @@ export class Session {
direction: AgentTimelineFetchDirection;
cursor?: AgentTimelineCursor;
pageLimit: number;
visibleMaxSeq?: number;
fullTimeline?: AgentTimelineFetchResult;
}): AgentTimelineProjectionSelection {
if (input.projection === "canonical") {
return this.selectCanonicalTimelineProjection({ timeline: input.controlTimeline });
@@ -6201,20 +6194,24 @@ export class Session {
const supportsSubmittedPromptRevisions = source
? this.supportsForSource(CLIENT_CAPS.canonicalSubmittedPromptRevisions, source)
: this.supports(CLIENT_CAPS.canonicalSubmittedPromptRevisions);
const visibleMaxSeq = supportsSubmittedPromptRevisions
? undefined
: findSubmittedPromptVisibleMaxSeq(
this.agentManager.fetchTimeline(msg.agentId, { direction: "tail", limit: 0 }).rows,
fetchedControlTimeline.window.maxSeq,
);
let fetchedFullTimeline: AgentTimelineFetchResult | undefined;
if (!supportsSubmittedPromptRevisions) {
fetchedFullTimeline =
direction === "tail" && pageLimit === 0
? fetchedControlTimeline
: this.agentManager.fetchTimeline(msg.agentId, { direction: "tail", limit: 0 });
}
const controlTimeline =
visibleMaxSeq === undefined
fetchedFullTimeline === undefined
? fetchedControlTimeline
: projectSubmittedPromptTimeline({
timeline: fetchedControlTimeline,
visibleMaxSeq,
...(cursor ? { cursorSeq: cursor.seq } : {}),
});
: projectSubmittedPromptTimeline(
fetchedControlTimeline,
fetchedFullTimeline,
cursor?.seq,
);
const fullTimeline = fetchedFullTimeline
? projectSubmittedPromptTimeline(fetchedFullTimeline)
: undefined;
const selectedTimeline = this.selectTimelineProjection({
agentId: msg.agentId,
projection,
@@ -6222,7 +6219,7 @@ export class Session {
direction,
...(cursor ? { cursor } : {}),
pageLimit,
...(visibleMaxSeq !== undefined ? { visibleMaxSeq } : {}),
...(fullTimeline ? { fullTimeline } : {}),
});
const startCursor =
selectedTimeline.startSeq !== null
+121 -21
View File
@@ -16,6 +16,8 @@ import {
import { Session, type SessionOptions } from "./session.js";
import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js";
import type { AgentTimelineRow } from "./agent/agent-manager.js";
import { InMemoryAgentTimelineStore } from "./agent/agent-timeline-store.js";
import type { AgentTimelineFetchOptions } from "./agent/agent-timeline-store-types.js";
import { handleCreatePaseoWorktreeRequest } from "./worktree-session.js";
import { createPersistedProjectRecord } from "./workspace-registry.js";
@@ -87,7 +89,15 @@ interface SessionInternals {
}
class InMemoryAgentManager {
constructor(private readonly rows: AgentTimelineRow[]) {}
private readonly timeline = new InMemoryAgentTimelineStore();
constructor(rows: AgentTimelineRow[]) {
this.timeline.initialize("agent-1", {
epoch: "epoch-1",
rows,
nextSeq: (rows.at(-1)?.seq ?? 0) + 1,
});
}
getAgent() {
return {
@@ -133,17 +143,8 @@ class InMemoryAgentManager {
};
}
fetchTimeline() {
return {
epoch: "epoch-1",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 1, maxSeq: 3, nextSeq: 4 },
rows: this.rows,
hasOlder: false,
hasNewer: false,
};
fetchTimeline(_agentId: string, options?: AgentTimelineFetchOptions) {
return this.timeline.fetch("agent-1", options);
}
listAgents() {
@@ -218,6 +219,7 @@ class InMemoryWorktreeWorkflow {
function createSessionForWireCompatTest(options?: {
clientCapabilities?: Record<string, unknown> | null;
messages?: SessionOutboundMessage[];
rows?: AgentTimelineRow[];
}): Session {
const messages = options?.messages ?? [];
const rows: AgentTimelineRow[] = [
@@ -247,7 +249,9 @@ function createSessionForWireCompatTest(options?: {
downloadTokenStore: {} as SessionOptions["downloadTokenStore"],
pushTokenStore: {} as SessionOptions["pushTokenStore"],
paseoHome: "/tmp/paseo-home",
agentManager: new InMemoryAgentManager(rows) as unknown as SessionOptions["agentManager"],
agentManager: new InMemoryAgentManager(
options?.rows ?? rows,
) as unknown as SessionOptions["agentManager"],
agentStorage: new EmptyAgentStorage() as unknown as SessionOptions["agentStorage"],
projectRegistry: new EmptyProjectRegistry() as unknown as SessionOptions["projectRegistry"],
workspaceRegistry:
@@ -308,11 +312,19 @@ function createSessionForWireCompatTest(options?: {
return session;
}
async function emitTimelineResponse(
clientCapabilities?: Record<string, unknown> | null,
): Promise<Extract<SessionOutboundMessage, { type: "fetch_agent_timeline_response" }>> {
async function emitTimelineResponse(options?: {
clientCapabilities?: Record<string, unknown> | null;
rows?: AgentTimelineRow[];
request?: Partial<
Extract<z.infer<typeof SessionInboundMessageSchema>, { type: "fetch_agent_timeline_request" }>
>;
}): Promise<Extract<SessionOutboundMessage, { type: "fetch_agent_timeline_response" }>> {
const messages: SessionOutboundMessage[] = [];
const session = createSessionForWireCompatTest({ clientCapabilities, messages });
const session = createSessionForWireCompatTest({
clientCapabilities: options?.clientCapabilities,
rows: options?.rows,
messages,
});
const internals = session as unknown as SessionInternals;
await internals.handleFetchAgentTimelineRequest({
@@ -320,6 +332,7 @@ async function emitTimelineResponse(
requestId: "req-timeline",
agentId: "agent-1",
projection: "projected",
...options?.request,
});
const response = messages[0];
@@ -377,7 +390,7 @@ describe("wire compatibility", () => {
]);
});
test("hello parses with and without the project update capability", () => {
test("hello parses with and without current client capabilities", () => {
const legacy = WSHelloMessageSchema.parse({
type: "hello",
clientId: "legacy-client",
@@ -389,7 +402,10 @@ describe("wire compatibility", () => {
clientId: "capable-client",
clientType: "mobile",
protocolVersion: 1,
capabilities: { [CLIENT_CAPS.projectUpdates]: true },
capabilities: {
[CLIENT_CAPS.projectUpdates]: true,
[CLIENT_CAPS.canonicalSubmittedPromptRevisions]: true,
},
});
expect([legacy, capable]).toEqual([
@@ -404,7 +420,10 @@ describe("wire compatibility", () => {
clientId: "capable-client",
clientType: "mobile",
protocolVersion: 1,
capabilities: { project_updates: true },
capabilities: {
project_updates: true,
canonical_submitted_prompt_revisions: true,
},
},
]);
});
@@ -463,13 +482,94 @@ describe("wire compatibility", () => {
test("preserves reasoning_merge for clients that declare the capability", async () => {
const response = await emitTimelineResponse({
[CLIENT_CAPS.reasoningMergeEnum]: true,
clientCapabilities: { [CLIENT_CAPS.reasoningMergeEnum]: true },
});
const currentParsed = FetchAgentTimelineResponseMessageSchema.parse(response);
expect(currentParsed.payload.entries[0]?.collapsed).toContain("reasoning_merge");
});
test("projects identity-pending prompt rows coherently for legacy fetches", async () => {
const timestamp = "2026-08-01T00:00:00.000Z";
const prompt = (options?: { delivery?: AgentTimelineRow["delivery"]; messageId?: string }) => ({
seq: 1,
timestamp,
...(options?.delivery ? { delivery: options.delivery } : {}),
item: {
type: "user_message" as const,
text: "hello",
clientMessageId: "client-message-1",
...(options?.messageId ? { messageId: options.messageId } : {}),
},
});
const pending = await emitTimelineResponse({
rows: [prompt({ delivery: "awaiting_provider_identity" })],
request: { projection: "canonical" },
});
const pageShape = (response: typeof pending) => [
[
response.payload.window.minSeq,
response.payload.window.maxSeq,
response.payload.window.nextSeq,
],
response.payload.startCursor?.seq ?? null,
response.payload.endCursor?.seq ?? null,
response.payload.hasOlder,
response.payload.hasNewer,
response.payload.entries.map((entry) => entry.seqStart),
];
expect(pageShape(pending)).toEqual([[0, 0, 1], null, null, false, false, []]);
for (const request of [
{ projection: "canonical" as const },
{
projection: "canonical" as const,
direction: "after" as const,
cursor: { epoch: "epoch-1", seq: 0 },
},
{ projection: "projected" as const },
]) {
const finalized = await emitTimelineResponse({
rows: [prompt({ messageId: "provider-message-1" })],
request,
});
expect(pageShape(finalized)).toEqual([[1, 1, 2], 1, 1, false, false, [1]]);
expect(finalized.payload.entries[0]?.item).toMatchObject({
type: "user_message",
messageId: "provider-message-1",
});
}
const daemonHandled = await emitTimelineResponse({
rows: [prompt()],
request: { projection: "canonical" },
});
expect(daemonHandled.payload.entries).toHaveLength(1);
const pageRows: AgentTimelineRow[] = [1, 2, 3].map((seq) => ({
seq,
timestamp,
item: {
type: "user_message",
text: `prompt ${seq}`,
clientMessageId: `client-message-${seq}`,
},
}));
for (const projection of ["canonical", "projected"] as const) {
const page = await emitTimelineResponse({
rows: pageRows,
request: {
projection,
direction: "before",
cursor: { epoch: "epoch-1", seq: 3 },
limit: 1,
},
});
expect(pageShape(page)).toEqual([[1, 3, 4], 2, 2, true, true, [2]]);
}
});
test("sub_agent tool-call payload still parses against the v0.1.65-beta.3 schema", () => {
const parsed = LegacySubAgentToolCallSchema.parse({
type: "tool_call",