fix: sync dev branch with main timeline/schema changes and remove stale providers

Forward-port main's timeline epoch tracking, projected window selection,
and schema updates into dev. Remove aider/amp/gemini providers and
provider-history-compatibility layers that are no longer needed.
This commit is contained in:
Mohamed Boudra
2026-04-15 10:52:28 +07:00
parent 326dad93e4
commit 9d30b7e85a
42 changed files with 1251 additions and 2051 deletions
@@ -2032,15 +2032,24 @@ describe("DaemonClient", () => {
agentId: "agent_cli",
agent: null,
direction: "tail",
startSeq: 1,
endSeq: 1,
projection: "projected",
epoch: "epoch-1",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
startCursor: { epoch: "epoch-1", seq: 1 },
endCursor: { epoch: "epoch-1", seq: 1 },
hasOlder: false,
hasNewer: false,
entries: [
{
timestamp: "2026-02-08T20:20:00.000Z",
provider: "codex",
seq: 1,
seqStart: 1,
seqEnd: 1,
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
collapsed: [],
item: {
type: "tool_call",
callId: "call_cli_snapshot",
@@ -2117,15 +2126,24 @@ describe("DaemonClient", () => {
agentId: "agent_cli",
agent: null,
direction: "tail",
startSeq: 1,
endSeq: 1,
projection: "projected",
epoch: "epoch-1",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
startCursor: { epoch: "epoch-1", seq: 1 },
endCursor: { epoch: "epoch-1", seq: 1 },
hasOlder: false,
hasNewer: false,
entries: [
{
timestamp: "2026-02-08T20:20:00.000Z",
provider: "codex",
seq: 1,
seqStart: 1,
seqEnd: 1,
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
collapsed: [],
item: {
type: "tool_call",
callId: "call_cli_invalid",
+6 -3
View File
@@ -152,6 +152,7 @@ export type DaemonEvent =
event: AgentStreamEventPayload;
timestamp: string;
seq?: number;
epoch?: string;
}
| { type: "status"; payload: { status: string } & Record<string, unknown> }
| { type: "agent_deleted"; agentId: string }
@@ -336,13 +337,13 @@ type ScheduleDeletePayload = Extract<
export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage["payload"];
export type FetchAgentTimelineDirection = FetchAgentTimelinePayload["direction"];
export type FetchAgentTimelineCursor = NonNullable<
Extract<SessionInboundMessage, { type: "fetch_agent_timeline_request" }>["cursor"]
>;
export type FetchAgentTimelineProjection = FetchAgentTimelinePayload["projection"];
export type FetchAgentTimelineCursor = NonNullable<FetchAgentTimelinePayload["startCursor"]>;
export type FetchAgentTimelineOptions = {
direction?: FetchAgentTimelineDirection;
cursor?: FetchAgentTimelineCursor;
limit?: number;
projection?: FetchAgentTimelineProjection;
requestId?: string;
};
@@ -1673,6 +1674,7 @@ export class DaemonClient {
...(options.direction ? { direction: options.direction } : {}),
...(options.cursor ? { cursor: options.cursor } : {}),
...(typeof options.limit === "number" ? { limit: options.limit } : {}),
...(options.projection ? { projection: options.projection } : {}),
});
const payload = await this.sendRequest({
@@ -3899,6 +3901,7 @@ export class DaemonClient {
event: msg.payload.event,
timestamp: msg.payload.timestamp,
...(typeof msg.payload.seq === "number" ? { seq: msg.payload.seq } : {}),
...(typeof msg.payload.epoch === "string" ? { epoch: msg.payload.epoch } : {}),
};
case "status":
return { type: "status", payload: msg.payload };
@@ -1273,7 +1273,7 @@ describe("AgentManager", () => {
}
});
test("fetchTimeline returns committed rows after a known seq without reset metadata", async () => {
test("fetchTimeline returns full timeline with reset when cursor epoch is stale", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-stale-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
@@ -1291,42 +1291,40 @@ describe("AgentManager", () => {
cwd: workdir,
});
for (let seq = 1; seq <= 120; seq += 1) {
await manager.appendTimelineItem(snapshot.id, {
type: "assistant_message",
text: `committed row ${seq}`,
});
}
const baseline = await manager.fetchTimeline(snapshot.id, {
direction: "tail",
limit: 0,
});
expect(baseline.rows).toHaveLength(120);
await manager.emitLiveTimelineItem(snapshot.id, {
await manager.appendTimelineItem(snapshot.id, {
type: "assistant_message",
text: "partial reply",
text: "one",
});
await manager.appendTimelineItem(snapshot.id, {
type: "assistant_message",
text: "finalized reply",
text: "two",
});
await manager.appendTimelineItem(snapshot.id, {
type: "assistant_message",
text: "three",
});
const result = await manager.fetchTimeline(snapshot.id, {
const baseline = manager.fetchTimeline(snapshot.id, {
direction: "tail",
limit: 2,
});
expect(baseline.rows).toHaveLength(2);
const result = manager.fetchTimeline(snapshot.id, {
direction: "after",
cursor: {
seq: 120,
epoch: "stale-epoch",
seq: baseline.rows[baseline.rows.length - 1]!.seq,
},
limit: 0,
limit: 1,
});
expect(result.rows).toHaveLength(1);
expect(result.rows[0]?.seq).toBe(121);
expect(result.rows[0]?.item).toEqual({
type: "assistant_message",
text: "finalized reply",
});
expect(result.reset).toBe(true);
expect(result.staleCursor).toBe(true);
expect(result.gap).toBe(false);
expect(result.rows).toHaveLength(3);
expect(result.rows[0]?.seq).toBe(1);
expect(result.rows[result.rows.length - 1]?.seq).toBe(3);
});
test("getTimelineRows falls back to the in-memory timeline when no durable store is configured", async () => {
@@ -1448,6 +1446,7 @@ describe("AgentManager", () => {
const streamEvents: Array<{
seq?: number;
epoch?: string;
eventType?: string;
itemType?: string;
text?: string;
@@ -1459,6 +1458,7 @@ describe("AgentManager", () => {
}
streamEvents.push({
seq: event.seq,
epoch: event.epoch,
eventType: event.event.type,
itemType: event.event.type === "timeline" ? event.event.item.type : undefined,
text:
@@ -1487,12 +1487,14 @@ describe("AgentManager", () => {
itemType: "assistant_message",
text: "final ",
seq: 1,
epoch: expect.any(String),
});
expect(assistantTimelineEvents[1]).toMatchObject({
eventType: "timeline",
itemType: "assistant_message",
text: "reply",
seq: 2,
epoch: expect.any(String),
});
expect(manager.getTimeline(snapshot.id)).toEqual([
@@ -1510,6 +1512,8 @@ describe("AgentManager", () => {
limit: 0,
});
expect(fetched.rows).toHaveLength(2);
expect(assistantTimelineEvents[0]?.epoch).toBe(fetched.epoch);
expect(assistantTimelineEvents[1]?.epoch).toBe(fetched.epoch);
expect(fetched.rows[0]?.item).toEqual({
type: "assistant_message",
text: "final ",
@@ -63,6 +63,7 @@ export type AgentManagerEvent =
agentId: string;
event: AgentStreamEvent;
seq?: number;
epoch?: string;
};
export type AgentSubscriber = (event: AgentManagerEvent) => void;
@@ -584,18 +585,8 @@ export class AgentManager {
return this.timelineStore.getRows(id);
}
async fetchTimeline(
id: string,
options?: AgentTimelineFetchOptions,
): Promise<AgentTimelineFetchResult> {
fetchTimeline(id: string, options?: AgentTimelineFetchOptions): AgentTimelineFetchResult {
this.requireAgent(id);
const agent = this.agents.get(id);
if (agent && !agent.historyPrimed) {
await this.hydrateTimelineFromLegacyProviderHistory(agent);
}
if (this.durableTimelineStore) {
return await this.durableTimelineStore.fetchCommitted(id, options);
}
return this.timelineStore.fetch(id, options);
}
@@ -1053,6 +1044,7 @@ export class AgentManager {
},
{
seq: row.seq,
epoch: this.timelineStore.getEpoch(agentId),
},
);
if (options?.emitState !== false) {
@@ -1073,6 +1065,7 @@ export class AgentManager {
},
{
seq: row.seq,
epoch: this.timelineStore.getEpoch(agentId),
},
);
await this.persistSnapshot(agent);
@@ -2404,6 +2397,7 @@ export class AgentManager {
timelineRow
? {
seq: timelineRow.seq,
epoch: this.timelineStore.getEpoch(agent.id),
}
: undefined,
);
@@ -2445,6 +2439,7 @@ export class AgentManager {
},
{
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
},
);
}
@@ -2604,7 +2599,7 @@ export class AgentManager {
private dispatchStream(
agentId: string,
event: AgentStreamEvent,
metadata?: { seq?: number },
metadata?: { seq?: number; epoch?: string },
): void {
this.dispatch({ type: "agent_stream", agentId, event, ...metadata });
}
@@ -7,6 +7,7 @@ export type AgentTimelineRow = {
};
export type AgentTimelineCursor = {
epoch: string;
seq: number;
};
@@ -30,7 +31,11 @@ export type AgentTimelineWindow = {
};
export type AgentTimelineFetchResult = {
epoch: string;
direction: AgentTimelineFetchDirection;
reset: boolean;
staleCursor: boolean;
gap: boolean;
window: AgentTimelineWindow;
hasOlder: boolean;
hasNewer: boolean;
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type {
AgentTimelineFetchOptions,
@@ -8,11 +9,13 @@ import type {
export type SeedAgentTimelineOptions = {
items?: readonly AgentTimelineItem[];
rows?: readonly AgentTimelineRow[];
epoch?: string;
nextSeq?: number;
timestamp?: string;
};
type AgentTimelineState = {
epoch: string;
rows: AgentTimelineRow[];
nextSeq: number;
};
@@ -45,6 +48,7 @@ export class InMemoryAgentTimelineStore {
: this.buildRowsFromItems(options?.items ?? [], options?.nextSeq ?? 1, timestamp);
const nextSeq = options?.nextSeq ?? (rows.length ? rows[rows.length - 1]!.seq + 1 : 1);
this.states.set(agentId, {
epoch: options?.epoch ?? randomUUID(),
rows,
nextSeq,
});
@@ -62,6 +66,10 @@ export class InMemoryAgentTimelineStore {
return this.requireState(agentId).rows.map(cloneRow);
}
getEpoch(agentId: string): string {
return this.requireState(agentId).epoch;
}
fetch(agentId: string, options?: AgentTimelineFetchOptions): AgentTimelineFetchResult {
const state = this.requireState(agentId);
const direction = options?.direction ?? "tail";
@@ -81,9 +89,43 @@ export class InMemoryAgentTimelineStore {
nextSeq: state.nextSeq,
};
if (cursor && typeof cursor.epoch === "string" && cursor.epoch !== state.epoch) {
return {
epoch: state.epoch,
direction,
reset: true,
staleCursor: true,
gap: false,
window,
hasOlder: false,
hasNewer: false,
rows: state.rows.map(cloneRow),
};
}
const cloneRows = (items: AgentTimelineRow[]) => items.map(cloneRow);
if (direction === "after" && cursor && state.rows.length > 0 && cursor.seq < minSeq - 1) {
return {
epoch: state.epoch,
direction,
reset: true,
staleCursor: false,
gap: true,
window,
hasOlder: false,
hasNewer: false,
rows: cloneRows(state.rows),
};
}
if (state.rows.length === 0) {
return {
epoch: state.epoch,
direction,
reset: false,
staleCursor: false,
gap: false,
window,
hasOlder: false,
hasNewer: false,
@@ -97,11 +139,15 @@ export class InMemoryAgentTimelineStore {
? 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),
rows: cloneRows(selected),
};
}
@@ -110,7 +156,11 @@ export class InMemoryAgentTimelineStore {
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,
@@ -123,11 +173,15 @@ export class InMemoryAgentTimelineStore {
: 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: Boolean(lastSelected && lastSelected.seq < maxSeq),
rows: selected.map(cloneRow),
rows: cloneRows(selected),
};
}
@@ -139,11 +193,15 @@ export class InMemoryAgentTimelineStore {
? 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),
rows: cloneRows(selected),
};
}
@@ -141,27 +141,6 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
defaultModel: "gpt-5.4-mini",
},
},
{
id: "gemini",
label: "Gemini CLI",
description: "Google's coding agent CLI",
defaultModeId: null,
modes: [],
},
{
id: "amp",
label: "AMP",
description: "Sourcegraph's coding agent CLI",
defaultModeId: null,
modes: [],
},
{
id: "aider",
label: "Aider",
description: "Paul Gauthier's coding assistant CLI",
defaultModeId: null,
modes: [],
},
{
id: "copilot",
label: "Copilot",
@@ -20,13 +20,9 @@ import type {
ProviderProfileModel,
ProviderRuntimeSettings,
} from "./provider-launch-config.js";
import { AiderAgentClient } from "./providers/aider-agent.js";
import { AmpAgentClient } from "./providers/amp-agent.js";
import { ClaudeAgentClient } from "./providers/claude-agent.js";
import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js";
import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js";
import { GeminiAgentClient } from "./providers/gemini-agent.js";
import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";
import { OpenCodeAgentClient, OpenCodeServerManager } from "./providers/opencode-agent.js";
import { PiACPAgentClient } from "./providers/pi-acp-agent.js";
@@ -72,9 +68,6 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
runtimeSettings,
}),
codex: (logger, runtimeSettings) => new CodexAppServerAgentClient(logger, runtimeSettings),
gemini: (_logger, runtimeSettings) => new GeminiAgentClient(runtimeSettings),
amp: (_logger, runtimeSettings) => new AmpAgentClient(runtimeSettings),
aider: (_logger, runtimeSettings) => new AiderAgentClient(runtimeSettings),
copilot: (logger, runtimeSettings) =>
new CopilotACPAgentClient({
logger,
@@ -1,75 +0,0 @@
import { existsSync } from "node:fs";
import type {
AgentCapabilityFlags,
AgentClient,
AgentLaunchContext,
AgentModelDefinition,
AgentPersistenceHandle,
AgentSession,
AgentSessionConfig,
ListModelsOptions,
} from "../agent-sdk-types.js";
import {
findExecutable,
isProviderCommandAvailable,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
const AIDER_PROVIDER = "aider" as const;
const AIDER_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
};
function resolveAiderBinary(): string {
const found = findExecutable("aider");
if (found) {
return found;
}
throw new Error(
"Aider binary not found. Install Aider and ensure 'aider' is available in your shell PATH.",
);
}
function createUnsupportedSessionError(): Error {
return new Error("Aider does not support session-backed agents in Paseo.");
}
export class AiderAgentClient implements AgentClient {
readonly provider = AIDER_PROVIDER;
readonly capabilities = AIDER_CAPABILITIES;
constructor(private readonly runtimeSettings?: ProviderRuntimeSettings) {}
async createSession(
_config: AgentSessionConfig,
_launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
throw createUnsupportedSessionError();
}
async resumeSession(
_handle: AgentPersistenceHandle,
_overrides?: Partial<AgentSessionConfig>,
_launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
throw createUnsupportedSessionError();
}
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
return [];
}
async isAvailable(): Promise<boolean> {
if (this.runtimeSettings?.command?.mode === "replace") {
return existsSync(this.runtimeSettings.command.argv[0]);
}
return isProviderCommandAvailable(this.runtimeSettings?.command, resolveAiderBinary);
}
}
@@ -1,75 +0,0 @@
import { existsSync } from "node:fs";
import type {
AgentCapabilityFlags,
AgentClient,
AgentLaunchContext,
AgentModelDefinition,
AgentPersistenceHandle,
AgentSession,
AgentSessionConfig,
ListModelsOptions,
} from "../agent-sdk-types.js";
import {
findExecutable,
isProviderCommandAvailable,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
const AMP_PROVIDER = "amp" as const;
const AMP_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
};
function resolveAmpBinary(): string {
const found = findExecutable("amp");
if (found) {
return found;
}
throw new Error(
"AMP binary not found. Install AMP and ensure 'amp' is available in your shell PATH.",
);
}
function createUnsupportedSessionError(): Error {
return new Error("AMP does not support session-backed agents in Paseo.");
}
export class AmpAgentClient implements AgentClient {
readonly provider = AMP_PROVIDER;
readonly capabilities = AMP_CAPABILITIES;
constructor(private readonly runtimeSettings?: ProviderRuntimeSettings) {}
async createSession(
_config: AgentSessionConfig,
_launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
throw createUnsupportedSessionError();
}
async resumeSession(
_handle: AgentPersistenceHandle,
_overrides?: Partial<AgentSessionConfig>,
_launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
throw createUnsupportedSessionError();
}
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
return [];
}
async isAvailable(): Promise<boolean> {
if (this.runtimeSettings?.command?.mode === "replace") {
return existsSync(this.runtimeSettings.command.argv[0]);
}
return isProviderCommandAvailable(this.runtimeSettings?.command, resolveAmpBinary);
}
}
@@ -1,75 +0,0 @@
import { existsSync } from "node:fs";
import type {
AgentCapabilityFlags,
AgentClient,
AgentLaunchContext,
AgentModelDefinition,
AgentPersistenceHandle,
AgentSession,
AgentSessionConfig,
ListModelsOptions,
} from "../agent-sdk-types.js";
import {
findExecutable,
isProviderCommandAvailable,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
const GEMINI_PROVIDER = "gemini" as const;
const GEMINI_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
};
function resolveGeminiBinary(): string {
const found = findExecutable("gemini");
if (found) {
return found;
}
throw new Error(
"Gemini CLI binary not found. Install Gemini CLI and ensure 'gemini' is available in your shell PATH.",
);
}
function createUnsupportedSessionError(): Error {
return new Error("Gemini CLI does not support session-backed agents in Paseo.");
}
export class GeminiAgentClient implements AgentClient {
readonly provider = GEMINI_PROVIDER;
readonly capabilities = GEMINI_CAPABILITIES;
constructor(private readonly runtimeSettings?: ProviderRuntimeSettings) {}
async createSession(
_config: AgentSessionConfig,
_launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
throw createUnsupportedSessionError();
}
async resumeSession(
_handle: AgentPersistenceHandle,
_overrides?: Partial<AgentSessionConfig>,
_launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
throw createUnsupportedSessionError();
}
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
return [];
}
async isAvailable(): Promise<boolean> {
if (this.runtimeSettings?.command?.mode === "replace") {
return existsSync(this.runtimeSettings.command.argv[0]);
}
return isProviderCommandAvailable(this.runtimeSettings?.command, resolveGeminiBinary);
}
}
@@ -29,9 +29,10 @@ async function waitFor(
}
}
function isSeqLessAssistantTimeline(
function isLiveAssistantTimeline(
message: SessionOutboundMessage,
agentId: string,
epoch?: string,
text?: string,
): boolean {
return (
@@ -40,6 +41,8 @@ function isSeqLessAssistantTimeline(
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) &&
(text === undefined || message.payload.event.item.text === text)
);
}
@@ -73,6 +76,14 @@ describe("daemon E2E - timeline reconnect contract", () => {
text: `committed row ${seq}`,
});
}
const baseline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const epoch = baseline.epoch;
expect(epoch).not.toBe("");
expect(baseline.endCursor?.epoch).toBe(epoch);
primaryCollector.clear();
await ctx.daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, {
@@ -81,7 +92,7 @@ describe("daemon E2E - timeline reconnect contract", () => {
});
await waitFor(() =>
primaryCollector.messages.some((message) =>
isSeqLessAssistantTimeline(message, agent.id, "partial before disconnect"),
isLiveAssistantTimeline(message, agent.id, epoch, "partial before disconnect"),
),
);
@@ -105,18 +116,26 @@ describe("daemon E2E - timeline reconnect contract", () => {
expect(
reconnectCollector.messages.some((message) =>
isSeqLessAssistantTimeline(message, agent.id),
isLiveAssistantTimeline(message, agent.id, epoch),
),
).toBe(false);
const catchUp = await reconnectClient.fetchAgentTimeline(agent.id, {
direction: "after",
cursor: { seq: 120 },
cursor: { epoch, seq: 120 },
limit: 0,
projection: "canonical",
});
expect(catchUp.epoch).toBe(epoch);
expect(catchUp.reset).toBe(false);
expect(catchUp.staleCursor).toBe(false);
expect(catchUp.gap).toBe(false);
expect(catchUp.entries).toHaveLength(1);
expect(catchUp.entries[0]?.seq).toBe(121);
expect(catchUp.startCursor).toEqual({ epoch, seq: 121 });
expect(catchUp.endCursor).toEqual({ epoch, seq: 121 });
expect(catchUp.entries[0]?.seqStart).toBe(121);
expect(catchUp.entries[0]?.seqEnd).toBe(121);
expect(catchUp.entries[0]?.item).toEqual({
type: "assistant_message",
text: "finalized while disconnected",
@@ -149,6 +168,14 @@ describe("daemon E2E - timeline reconnect contract", () => {
text: `committed row ${seq}`,
});
}
const baseline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const epoch = baseline.epoch;
expect(epoch).not.toBe("");
expect(baseline.endCursor?.epoch).toBe(epoch);
primaryCollector.clear();
await ctx.daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, {
@@ -157,7 +184,7 @@ describe("daemon E2E - timeline reconnect contract", () => {
});
await waitFor(() =>
primaryCollector.messages.some((message) =>
isSeqLessAssistantTimeline(message, agent.id, "partial before disconnect"),
isLiveAssistantTimeline(message, agent.id, epoch, "partial before disconnect"),
),
);
@@ -176,17 +203,24 @@ describe("daemon E2E - timeline reconnect contract", () => {
expect(
reconnectCollector.messages.some((message) =>
isSeqLessAssistantTimeline(message, agent.id),
isLiveAssistantTimeline(message, agent.id, epoch),
),
).toBe(false);
const catchUp = await reconnectClient.fetchAgentTimeline(agent.id, {
direction: "after",
cursor: { seq: 120 },
cursor: { epoch, seq: 120 },
limit: 0,
projection: "canonical",
});
expect(catchUp.epoch).toBe(epoch);
expect(catchUp.reset).toBe(false);
expect(catchUp.staleCursor).toBe(false);
expect(catchUp.gap).toBe(false);
expect(catchUp.entries).toHaveLength(0);
expect(catchUp.startCursor).toBeNull();
expect(catchUp.endCursor).toBeNull();
reconnectCollector.clear();
await ctx.daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, {
@@ -195,7 +229,7 @@ describe("daemon E2E - timeline reconnect contract", () => {
});
await waitFor(() =>
reconnectCollector.messages.some((message) =>
isSeqLessAssistantTimeline(message, agent.id, "fresh live after reconnect"),
isLiveAssistantTimeline(message, agent.id, epoch, "fresh live after reconnect"),
),
);
} finally {
@@ -38,6 +38,7 @@ describe("daemon E2E - timeline window", () => {
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 1,
projection: "canonical",
});
const assistantTexts = timeline.entries
@@ -45,7 +46,7 @@ describe("daemon E2E - timeline window", () => {
.map((entry) => entry.item.text);
expect(assistantTexts).toEqual([expected]);
expect(timeline.startSeq).toBe(timeline.endSeq);
expect(timeline.startCursor?.seq).toBe(timeline.endCursor?.seq);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
@@ -71,6 +72,7 @@ describe("daemon E2E - timeline window", () => {
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 1,
projection: "canonical",
});
const assistantTexts = timeline.entries
@@ -79,7 +81,7 @@ describe("daemon E2E - timeline window", () => {
expect(assistantTexts.join("")).toBe(expected);
expect(timeline.hasOlder).toBe(true);
expect(timeline.startSeq).toBeGreaterThan(1);
expect(timeline.startCursor?.seq).toBeGreaterThan(1);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
@@ -1,4 +1,5 @@
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import type { ManagedAgent } from "./agent/agent-manager.js";
import type { StoredAgentRecord } from "./agent/agent-storage.js";
import {
attachAgentStoragePersistence,
@@ -173,16 +174,16 @@ describe("persistence hooks", () => {
test("buildSessionConfig accepts providers from the canonical manifest", () => {
const record = createRecord({
provider: "gemini",
provider: "claude",
persistence: {
provider: "gemini",
provider: "claude",
sessionId: "session-123",
},
config: {},
});
expect(buildSessionConfig(record)).toMatchObject({
provider: "gemini",
provider: "claude",
cwd: "/tmp/project",
});
});
@@ -1,15 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, test } from "vitest";
describe("agent loading boundary", () => {
test("session runtime code does not directly hydrate provider history", () => {
const sessionSource = readFileSync(new URL("./session.ts", import.meta.url), "utf8");
const agentLoadingSource = readFileSync(
new URL("./agent-loading-service.ts", import.meta.url),
"utf8",
);
expect(sessionSource).not.toMatch(/hydrateTimelineFromProvider\s*\(/);
expect(agentLoadingSource).not.toMatch(/hydrateTimelineFromProvider\s*\(/);
});
});
@@ -1,333 +0,0 @@
import { mkdtempSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import pino from "pino";
import { describe, expect, test, vi } from "vitest";
import { AgentLoadingService } from "./agent-loading-service.js";
import { AgentManager } from "./agent/agent-manager.js";
import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js";
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
function createStoredAgentRecord(overrides?: Partial<StoredAgentRecord>): StoredAgentRecord {
const now = "2026-03-25T00:00:00.000Z";
return {
id: "agent-compat-1",
provider: "codex",
cwd: "/tmp/project",
createdAt: now,
updatedAt: now,
title: null,
labels: {},
lastStatus: "idle",
config: {
model: "gpt-5.1-codex-mini",
},
persistence: {
provider: "codex",
sessionId: "provider-session-1",
},
...overrides,
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function createCompatibilitySnapshot(overrides?: Partial<Record<string, unknown>>) {
return {
id: "agent-compat-1",
provider: "codex",
cwd: "/tmp/project",
persistence: {
provider: "codex",
sessionId: "provider-session-1",
},
...overrides,
};
}
describe("AgentLoadingService", () => {
test("ensureAgentLoaded succeeds when provider history is absent", async () => {
const workspaceRoot = mkdtempSync(path.join(os.tmpdir(), "provider-history-compat-empty-"));
const logger = pino({ level: "silent" });
try {
const storage = new AgentStorage(path.join(workspaceRoot, "agents"), logger);
const manager = new AgentManager({
clients: createTestAgentClients(),
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000302",
});
const service = new AgentLoadingService({
agentManager: manager as any,
agentStorage: storage as any,
logger,
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workspaceRoot,
model: "gpt-5.1-codex-mini",
});
await manager.flush();
await storage.flush();
await manager.closeAgent(snapshot.id);
const loaded = await service.ensureAgentLoaded({ agentId: snapshot.id });
expect(loaded.id).toBe(snapshot.id);
expect(manager.getTimeline(snapshot.id)).toEqual([]);
} finally {
rmSync(workspaceRoot, { recursive: true, force: true });
}
});
test("ensureAgentLoaded dedupes concurrent cold-load bootstrap", async () => {
const deferred = createDeferred<any>();
let currentAgent: any = null;
const snapshot = createCompatibilitySnapshot({ id: "agent-compat-dedupe" });
const agentStorage = {
get: vi.fn(async () =>
createStoredAgentRecord({
id: "agent-compat-dedupe",
cwd: "/tmp/dedupe",
persistence: {
provider: "codex",
sessionId: "provider-session-dedupe",
},
}),
),
};
const agentManager = {
getAgent: vi.fn(() => currentAgent),
resumeAgentFromPersistence: vi.fn(async () => deferred.promise),
createAgent: vi.fn(),
reloadAgentSession: vi.fn(),
};
const logger = {
child: () => logger,
info: vi.fn(),
warn: vi.fn(),
};
const service = new AgentLoadingService({
agentManager: agentManager as any,
agentStorage: agentStorage as any,
logger: logger as any,
});
const firstLoad = service.ensureAgentLoaded({ agentId: "agent-compat-dedupe" });
const secondLoad = service.ensureAgentLoaded({ agentId: "agent-compat-dedupe" });
deferred.resolve(snapshot);
const [firstResult, secondResult] = await Promise.all([firstLoad, secondLoad]);
expect(firstResult).toEqual(snapshot);
expect(secondResult).toEqual(snapshot);
expect(agentStorage.get).toHaveBeenCalledTimes(1);
expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledTimes(1);
});
test("resumeAgent delegates to manager resume", async () => {
const snapshot = createCompatibilitySnapshot({ id: "agent-compat-resume" });
const agentManager = {
getAgent: vi.fn(() => null),
resumeAgentFromPersistence: vi.fn(async () => snapshot),
createAgent: vi.fn(),
reloadAgentSession: vi.fn(),
};
const logger = {
child: () => logger,
info: vi.fn(),
warn: vi.fn(),
};
const service = new AgentLoadingService({
agentManager: agentManager as any,
agentStorage: {
get: async () => null,
} as any,
logger: logger as any,
});
const result = await service.resumeAgent({
handle: {
provider: "codex",
sessionId: "provider-session-resume",
},
overrides: {
model: "gpt-5.4",
},
});
expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledWith(
{
provider: "codex",
sessionId: "provider-session-resume",
},
{
model: "gpt-5.4",
},
);
expect(result).toEqual(snapshot);
});
test("refreshAgent reloads loaded persisted agents", async () => {
const existing = createCompatibilitySnapshot({ id: "agent-compat-refresh-loaded" });
const reloaded = createCompatibilitySnapshot({ id: "agent-compat-refresh-loaded" });
let currentAgent: any = existing;
const agentManager = {
getAgent: vi.fn(() => currentAgent),
resumeAgentFromPersistence: vi.fn(),
createAgent: vi.fn(),
reloadAgentSession: vi.fn(async () => {
currentAgent = reloaded;
return reloaded;
}),
};
const logger = {
child: () => logger,
info: vi.fn(),
warn: vi.fn(),
};
const service = new AgentLoadingService({
agentManager: agentManager as any,
agentStorage: {
get: async () => null,
} as any,
logger: logger as any,
});
const result = await service.refreshAgent({ agentId: "agent-compat-refresh-loaded" });
expect(agentManager.reloadAgentSession).toHaveBeenCalledWith("agent-compat-refresh-loaded");
expect(result).toEqual(reloaded);
});
test("refreshAgent keeps loaded non-persisted agents without reloading", async () => {
const existing = createCompatibilitySnapshot({
id: "agent-compat-refresh-live",
persistence: null,
});
const agentManager = {
getAgent: vi.fn(() => existing),
resumeAgentFromPersistence: vi.fn(),
createAgent: vi.fn(),
reloadAgentSession: vi.fn(),
};
const logger = {
child: () => logger,
info: vi.fn(),
warn: vi.fn(),
};
const service = new AgentLoadingService({
agentManager: agentManager as any,
agentStorage: {
get: async () => null,
} as any,
logger: logger as any,
});
const result = await service.refreshAgent({ agentId: "agent-compat-refresh-live" });
expect(agentManager.reloadAgentSession).not.toHaveBeenCalled();
expect(result).toEqual(existing);
});
test("refreshAgent resumes unloaded persisted agents", async () => {
const snapshot = createCompatibilitySnapshot({ id: "agent-compat-refresh-cold" });
const record = createStoredAgentRecord({
id: "agent-compat-refresh-cold",
cwd: "/tmp/refresh-cold",
persistence: {
provider: "codex",
sessionId: "provider-session-refresh-cold",
},
});
const agentManager = {
getAgent: vi.fn(() => null),
resumeAgentFromPersistence: vi.fn(async () => snapshot),
createAgent: vi.fn(),
reloadAgentSession: vi.fn(),
};
const logger = {
child: () => logger,
info: vi.fn(),
warn: vi.fn(),
};
const service = new AgentLoadingService({
agentManager: agentManager as any,
agentStorage: {
get: vi.fn(async () => record),
} as any,
logger: logger as any,
});
const result = await service.refreshAgent({ agentId: "agent-compat-refresh-cold" });
expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledWith(
{
provider: "codex",
sessionId: "provider-session-refresh-cold",
nativeHandle: undefined,
metadata: undefined,
},
{
cwd: "/tmp/refresh-cold",
modeId: undefined,
model: "gpt-5.1-codex-mini",
thinkingOptionId: undefined,
title: undefined,
extra: undefined,
systemPrompt: undefined,
mcpServers: undefined,
},
"agent-compat-refresh-cold",
{
createdAt: new Date("2026-03-25T00:00:00.000Z"),
updatedAt: new Date("2026-03-25T00:00:00.000Z"),
lastUserMessageAt: null,
labels: {},
},
);
expect(result).toEqual(snapshot);
});
test("refreshAgent preserves the unloaded no-persistence error", async () => {
const service = new AgentLoadingService({
agentManager: {
getAgent: vi.fn(() => null),
resumeAgentFromPersistence: vi.fn(),
createAgent: vi.fn(),
reloadAgentSession: vi.fn(),
} as any,
agentStorage: {
get: async () =>
createStoredAgentRecord({
id: "agent-compat-no-persistence",
persistence: null,
}),
} as any,
logger: {
child: () => ({
child: () => null,
info: vi.fn(),
warn: vi.fn(),
}),
info: vi.fn(),
warn: vi.fn(),
} as any,
});
await expect(service.refreshAgent({ agentId: "agent-compat-no-persistence" })).rejects.toThrow(
"Agent agent-compat-no-persistence cannot be refreshed because it lacks persistence",
);
});
});
@@ -1,389 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import { Session } from "./session.js";
function createStoredAgentRecord(overrides?: Partial<Record<string, unknown>>) {
return {
id: "agent-1",
provider: "codex",
cwd: "/tmp/project",
createdAt: "2026-03-24T00:00:00.000Z",
updatedAt: "2026-03-24T00:00:00.000Z",
title: null,
labels: {},
lastStatus: "idle",
config: null,
persistence: {
provider: "codex",
sessionId: "provider-session-1",
},
archivedAt: null,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
...overrides,
};
}
function createCompatibilitySnapshot(overrides?: Partial<Record<string, unknown>>) {
return {
id: "agent-1",
provider: "codex",
cwd: "/tmp/project",
persistence: {
provider: "codex",
sessionId: "provider-session-1",
},
...overrides,
};
}
function createSessionForOwnershipTests(options?: {
agentLoadingService?: {
ensureAgentLoaded?: (options: { agentId: string }) => Promise<any>;
resumeAgent?: (options: {
handle: { provider: string; sessionId: string };
overrides?: Record<string, unknown>;
}) => Promise<any>;
refreshAgent?: (options: { agentId: string }) => Promise<any>;
};
storedRecord?: Record<string, unknown> | null;
loadedAgent?: Record<string, unknown> | null;
timelineRows?: Array<{ seq: number; item: Record<string, unknown>; timestamp: Date }>;
}) {
const emitted: Array<{ type: string; payload: unknown }> = [];
const logger = {
child: () => logger,
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const agentManager = {
subscribe: () => () => {},
listAgents: () => [],
getAgent: vi.fn(() => options?.loadedAgent ?? null),
createAgent: vi.fn(async () => {
throw new Error("Session should delegate unloaded bootstrap to AgentLoadingService");
}),
resumeAgentFromPersistence: vi.fn(async () => {
throw new Error("Session should delegate persistence resume to AgentLoadingService");
}),
reloadAgentSession: vi.fn(async () => {
throw new Error("Session should delegate refresh reload to AgentLoadingService");
}),
hydrateTimelineFromProvider: vi.fn(async () => {
throw new Error("Session should not call hydrateTimelineFromProvider directly");
}),
fetchTimeline: vi.fn(async () => ({
rows: options?.timelineRows ?? [],
hasOlder: false,
hasNewer: false,
})),
recordUserMessage: vi.fn(),
waitForAgentRunStart: vi.fn(async () => undefined),
getTimeline: vi.fn(() => []),
};
const session = new Session({
clientId: "test-client",
onMessage: (message) => emitted.push(message as any),
logger: logger as any,
downloadTokenStore: {} as any,
pushTokenStore: {} as any,
paseoHome: "/tmp/paseo-test",
agentManager: agentManager as any,
agentStorage: {
list: async () => (options?.storedRecord ? [options.storedRecord as any] : []),
get: async () => (options?.storedRecord as any) ?? null,
} as any,
projectRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
} as any,
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
} as any,
createAgentMcpTransport: async () => {
throw new Error("not used");
},
stt: null,
tts: null,
terminalManager: null,
agentLoadingService: options?.agentLoadingService,
} as any) as any;
return { session, emitted, agentManager };
}
describe("provider history compatibility ownership", () => {
test("fetch_agent_timeline_request delegates unloaded bootstrap through the compatibility seam", async () => {
const ensureAgentLoaded = vi.fn(async () => createCompatibilitySnapshot());
const { session, emitted } = createSessionForOwnershipTests({
storedRecord: createStoredAgentRecord(),
timelineRows: [
{
seq: 1,
item: { type: "assistant_message", text: "rehydrated from provider history" },
timestamp: new Date("2026-03-24T00:00:01.000Z"),
},
],
agentLoadingService: {
ensureAgentLoaded,
},
});
session.buildAgentPayload = vi.fn(async () => ({ id: "agent-1" }));
await session.handleMessage({
type: "fetch_agent_timeline_request",
requestId: "req-fetch",
agentId: "agent-1",
});
expect(ensureAgentLoaded).toHaveBeenCalledWith({ agentId: "agent-1" });
expect(emitted).toContainEqual({
type: "fetch_agent_timeline_response",
payload: expect.objectContaining({
requestId: "req-fetch",
agentId: "agent-1",
error: null,
entries: [
expect.objectContaining({
seq: 1,
}),
],
}),
});
});
test("fetch_agent_timeline_request emits legacy compatibility fields for older clients", async () => {
const ensureAgentLoaded = vi.fn(async () => createCompatibilitySnapshot());
const { session, emitted } = createSessionForOwnershipTests({
storedRecord: createStoredAgentRecord(),
timelineRows: [
{
seq: 7,
item: { type: "assistant_message", text: "compat row" },
timestamp: new Date("2026-03-24T00:00:07.000Z"),
},
],
agentLoadingService: {
ensureAgentLoaded,
},
});
session.buildAgentPayload = vi.fn(async () => ({ id: "agent-1" }));
await session.handleMessage({
type: "fetch_agent_timeline_request",
requestId: "req-compat",
agentId: "agent-1",
direction: "tail",
});
expect(emitted).toContainEqual({
type: "fetch_agent_timeline_response",
payload: expect.objectContaining({
requestId: "req-compat",
agentId: "agent-1",
epoch: "compat:agent-1",
reset: false,
staleCursor: false,
gap: false,
projection: "projected",
startCursor: { seq: 7, epoch: "compat:agent-1" },
endCursor: { seq: 7, epoch: "compat:agent-1" },
window: {
minSeq: 7,
maxSeq: 7,
nextSeq: 8,
startSeq: 7,
endSeq: 7,
hasOlder: false,
hasNewer: false,
},
entries: [
expect.objectContaining({
seq: 7,
seqStart: 7,
seqEnd: 7,
sourceSeqRanges: [{ startSeq: 7, endSeq: 7 }],
collapsed: [],
}),
],
}),
});
});
test("send_agent_message_request delegates unloaded bootstrap before recording and streaming", async () => {
const ensureAgentLoaded = vi.fn(async () => createCompatibilitySnapshot());
const { session, agentManager, emitted } = createSessionForOwnershipTests({
storedRecord: createStoredAgentRecord(),
agentLoadingService: {
ensureAgentLoaded,
},
});
session.resolveAgentIdentifier = vi.fn(async () => ({ ok: true, agentId: "agent-1" }));
session.unarchiveAgentState = vi.fn(async () => true);
session.buildAgentPrompt = vi.fn((text: string) => text);
session.startAgentStream = vi.fn(() => ({ ok: true }));
await session.handleMessage({
type: "send_agent_message_request",
requestId: "req-send",
agentId: "agent-1",
text: "hello",
images: [],
messageId: "msg-1",
});
expect(ensureAgentLoaded).toHaveBeenCalledWith({ agentId: "agent-1" });
expect(ensureAgentLoaded.mock.invocationCallOrder[0]).toBeLessThan(
agentManager.recordUserMessage.mock.invocationCallOrder[0],
);
expect(agentManager.recordUserMessage).toHaveBeenCalledWith("agent-1", "hello", {
messageId: "msg-1",
emitState: false,
});
expect(session.startAgentStream).toHaveBeenCalledWith("agent-1", "hello");
expect(emitted).toContainEqual({
type: "send_agent_message_response",
payload: {
requestId: "req-send",
agentId: "agent-1",
accepted: true,
error: null,
},
});
});
test("resume_agent_request delegates persistence bootstrap through the compatibility seam", async () => {
const resumeAgent = vi.fn(async () => createCompatibilitySnapshot());
const { session, emitted } = createSessionForOwnershipTests({
agentLoadingService: {
resumeAgent,
},
});
session.unarchiveAgentByHandle = vi.fn(async () => undefined);
session.unarchiveAgentState = vi.fn(async () => true);
session.forwardAgentUpdate = vi.fn(async () => undefined);
session.getAgentPayloadById = vi.fn(async () => ({ id: "agent-1" }));
await session.handleMessage({
type: "resume_agent_request",
requestId: "req-resume",
handle: {
provider: "codex",
sessionId: "provider-session-1",
},
overrides: {
model: "gpt-5.4",
},
});
expect(resumeAgent).toHaveBeenCalledWith({
handle: {
provider: "codex",
sessionId: "provider-session-1",
},
overrides: {
model: "gpt-5.4",
},
});
expect(emitted).toContainEqual({
type: "status",
payload: expect.objectContaining({
status: "agent_resumed",
requestId: "req-resume",
agentId: "agent-1",
}),
});
});
test("refresh_agent_request delegates loaded persisted refresh through the compatibility seam", async () => {
const refreshAgent = vi.fn(async () =>
createCompatibilitySnapshot({
persistence: {
provider: "codex",
sessionId: "provider-session-1",
},
}),
);
const { session, emitted } = createSessionForOwnershipTests({
loadedAgent: createCompatibilitySnapshot(),
agentLoadingService: {
refreshAgent,
},
});
session.unarchiveAgentState = vi.fn(async () => true);
session.interruptAgentIfRunning = vi.fn(async () => undefined);
session.forwardAgentUpdate = vi.fn(async () => undefined);
await session.handleMessage({
type: "refresh_agent_request",
requestId: "req-refresh-loaded",
agentId: "agent-1",
});
expect(session.interruptAgentIfRunning).toHaveBeenCalledWith("agent-1");
expect(refreshAgent).toHaveBeenCalledWith({ agentId: "agent-1" });
expect(emitted).toContainEqual({
type: "status",
payload: {
status: "agent_refreshed",
requestId: "req-refresh-loaded",
agentId: "agent-1",
timelineSize: 0,
},
});
});
test("refresh_agent_request delegates unloaded persisted refresh through the compatibility seam", async () => {
const refreshAgent = vi.fn(async () => createCompatibilitySnapshot());
const { session, emitted } = createSessionForOwnershipTests({
storedRecord: createStoredAgentRecord(),
agentLoadingService: {
refreshAgent,
},
});
session.unarchiveAgentState = vi.fn(async () => true);
session.interruptAgentIfRunning = vi.fn(async () => undefined);
session.forwardAgentUpdate = vi.fn(async () => undefined);
await session.handleMessage({
type: "refresh_agent_request",
requestId: "req-refresh-unloaded",
agentId: "agent-1",
});
expect(session.interruptAgentIfRunning).not.toHaveBeenCalled();
expect(refreshAgent).toHaveBeenCalledWith({ agentId: "agent-1" });
expect(emitted).toContainEqual({
type: "status",
payload: {
status: "agent_refreshed",
requestId: "req-refresh-unloaded",
agentId: "agent-1",
timelineSize: 0,
},
});
});
});
+145 -72
View File
@@ -92,6 +92,11 @@ import {
appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown,
} from "./agent/timeline-append.js";
import {
projectTimelineRows,
selectTimelineWindowByProjectedLimit,
type TimelineProjectionMode,
} from "./agent/timeline-projection.js";
import {
DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
StructuredAgentFallbackError,
@@ -1143,6 +1148,7 @@ export class Session {
event: serializedEvent,
timestamp: new Date().toISOString(),
...(typeof event.seq === "number" ? { seq: event.seq } : {}),
...(typeof event.epoch === "string" ? { epoch: event.epoch } : {}),
} as const;
this.emit({
@@ -1705,7 +1711,7 @@ export class Session {
break;
case "cancel_agent_request":
await this.handleCancelAgentRequest(msg.agentId);
await this.handleCancelAgentRequest(msg.agentId, msg.requestId);
break;
case "restart_server_request":
@@ -1886,7 +1892,7 @@ export class Session {
break;
case "clear_agent_attention":
await this.handleClearAgentAttention(msg.agentId);
await this.handleClearAgentAttention(msg.agentId, msg.requestId);
break;
case "client_heartbeat":
@@ -3337,11 +3343,23 @@ export class Session {
}
}
private async handleCancelAgentRequest(agentId: string): Promise<void> {
private async handleCancelAgentRequest(agentId: string, requestId?: string): Promise<void> {
this.sessionLogger.info({ agentId }, `Cancel request received for agent ${agentId}`);
try {
await this.interruptAgentIfRunning(agentId);
if (requestId) {
const agent = this.agentManager.getAgent(agentId);
const payload = agent ? await this.buildAgentPayload(agent) : null;
this.emit({
type: "cancel_agent_response",
payload: {
requestId,
agentId,
agent: payload,
},
});
}
} catch (error) {
this.handleAgentRunError(agentId, error, "Failed to cancel running agent on request");
}
@@ -4005,11 +4023,32 @@ export class Session {
/**
* Handle clearing agent attention flag
*/
private async handleClearAgentAttention(agentId: string | string[]): Promise<void> {
private async handleClearAgentAttention(
agentId: string | string[],
requestId?: string,
): Promise<void> {
const agentIds = Array.isArray(agentId) ? agentId : [agentId];
try {
await Promise.all(agentIds.map((id) => this.agentManager.clearAgentAttention(id)));
if (requestId) {
const agents = (
await Promise.all(
agentIds.map(async (id) => {
const agent = this.agentManager.getAgent(id);
return agent ? this.buildAgentPayload(agent) : null;
}),
)
).filter((payload): payload is NonNullable<typeof payload> => payload !== null);
this.emit({
type: "clear_agent_attention_response",
payload: {
requestId,
agentId,
agents,
},
});
}
} catch (error: any) {
this.sessionLogger.error({ err: error, agentIds }, "Failed to clear agent attention");
// Don't throw - this is not critical
@@ -6884,10 +6923,17 @@ export class Session {
msg: Extract<SessionInboundMessage, { type: "fetch_agent_timeline_request" }>,
): Promise<void> {
const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail");
const projection: TimelineProjectionMode = msg.projection ?? "projected";
const requestedLimit = msg.limit;
const limit = requestedLimit ?? (direction === "after" ? 0 : undefined);
const shouldLimitByProjectedWindow =
projection === "canonical" &&
direction === "tail" &&
typeof requestedLimit === "number" &&
requestedLimit > 0;
const cursor: AgentTimelineCursor | undefined = msg.cursor
? {
epoch: msg.cursor.epoch,
seq: msg.cursor.seq,
}
: undefined;
@@ -6895,46 +6941,86 @@ export class Session {
try {
const snapshot = await this.ensureAgentLoaded(msg.agentId);
const agentPayload = await this.buildAgentPayload(snapshot);
const timeline = await this.agentManager.fetchTimeline(msg.agentId, {
let timeline = this.agentManager.fetchTimeline(msg.agentId, {
direction,
cursor,
limit,
limit:
shouldLimitByProjectedWindow && typeof requestedLimit === "number"
? Math.max(1, Math.floor(requestedLimit))
: limit,
});
const firstRow = timeline.rows[0];
const lastRow = timeline.rows[timeline.rows.length - 1];
const timelineWindow =
timeline.window ??
(() => {
const minSeq = firstRow?.seq ?? 0;
const maxSeq = lastRow?.seq ?? 0;
return {
minSeq,
maxSeq,
nextSeq: maxSeq > 0 ? maxSeq + 1 : 0,
};
})();
const epoch = `compat:${msg.agentId}`;
const startCursor = firstRow ? { seq: firstRow.seq, epoch } : null;
const endCursor = lastRow ? { seq: lastRow.seq, epoch } : null;
const window = {
minSeq: timelineWindow.minSeq,
maxSeq: timelineWindow.maxSeq,
nextSeq: timelineWindow.nextSeq,
startSeq: firstRow?.seq ?? null,
endSeq: lastRow?.seq ?? null,
hasOlder: timeline.hasOlder,
hasNewer: timeline.hasNewer,
};
const entries = timeline.rows.map((row) => ({
provider: snapshot.provider,
item: row.item,
timestamp: row.timestamp,
seq: row.seq,
seqStart: row.seq,
seqEnd: row.seq,
sourceSeqRanges: [{ startSeq: row.seq, endSeq: row.seq }],
collapsed: [],
}));
let hasOlder = timeline.hasOlder;
let hasNewer = timeline.hasNewer;
let startCursor: { epoch: string; seq: number } | null = null;
let endCursor: { epoch: string; seq: number } | null = null;
let entries: ReturnType<typeof projectTimelineRows>;
if (shouldLimitByProjectedWindow) {
const projectedLimit = Math.max(1, Math.floor(requestedLimit));
let fetchLimit = projectedLimit;
let projectedWindow = selectTimelineWindowByProjectedLimit({
rows: timeline.rows,
provider: snapshot.provider,
direction,
limit: projectedLimit,
collapseToolLifecycle: false,
});
while (timeline.hasOlder) {
const needsMoreProjectedEntries =
projectedWindow.projectedEntries.length < projectedLimit;
const firstLoadedRow = timeline.rows[0];
const firstSelectedRow = projectedWindow.selectedRows[0];
const startsAtLoadedBoundary =
firstLoadedRow != null &&
firstSelectedRow != null &&
firstSelectedRow.seq === firstLoadedRow.seq;
const boundaryIsAssistantChunk =
startsAtLoadedBoundary && firstLoadedRow.item.type === "assistant_message";
if (!needsMoreProjectedEntries && !boundaryIsAssistantChunk) {
break;
}
const maxRows = Math.max(0, timeline.window.maxSeq - timeline.window.minSeq + 1);
const nextFetchLimit = Math.min(maxRows, fetchLimit * 2);
if (nextFetchLimit <= fetchLimit) {
break;
}
fetchLimit = nextFetchLimit;
timeline = this.agentManager.fetchTimeline(msg.agentId, {
direction,
cursor,
limit: fetchLimit,
});
projectedWindow = selectTimelineWindowByProjectedLimit({
rows: timeline.rows,
provider: snapshot.provider,
direction,
limit: projectedLimit,
collapseToolLifecycle: false,
});
}
const selectedRows = projectedWindow.selectedRows;
entries = projectTimelineRows(selectedRows, snapshot.provider, projection);
if (projectedWindow.minSeq !== null && projectedWindow.maxSeq !== null) {
startCursor = { epoch: timeline.epoch, seq: projectedWindow.minSeq };
endCursor = { epoch: timeline.epoch, seq: projectedWindow.maxSeq };
hasOlder = projectedWindow.minSeq > timeline.window.minSeq;
hasNewer = false;
}
} else {
const firstRow = timeline.rows[0];
const lastRow = timeline.rows[timeline.rows.length - 1];
startCursor = firstRow ? { epoch: timeline.epoch, seq: firstRow.seq } : null;
endCursor = lastRow ? { epoch: timeline.epoch, seq: lastRow.seq } : null;
entries = projectTimelineRows(timeline.rows, snapshot.provider, projection);
}
this.emit({
type: "fetch_agent_timeline_response",
@@ -6943,24 +7029,21 @@ export class Session {
agentId: msg.agentId,
agent: agentPayload,
direction,
startSeq: firstRow?.seq ?? null,
endSeq: lastRow?.seq ?? null,
hasOlder: timeline.hasOlder,
hasNewer: timeline.hasNewer,
entries,
error: null,
epoch,
reset: false,
staleCursor: false,
gap: false,
window,
projection,
epoch: timeline.epoch,
reset: timeline.reset,
staleCursor: timeline.staleCursor,
gap: timeline.gap,
window: timeline.window,
startCursor,
endCursor,
projection: msg.projection ?? "projected",
hasOlder,
hasNewer,
entries,
error: null,
},
});
} catch (error) {
const epoch = `compat:${msg.agentId}`;
this.sessionLogger.error(
{ err: error, agentId: msg.agentId },
"Failed to handle fetch_agent_timeline_request",
@@ -6972,28 +7055,18 @@ export class Session {
agentId: msg.agentId,
agent: null,
direction,
startSeq: null,
endSeq: null,
projection,
epoch: "",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 0, maxSeq: 0, nextSeq: 0 },
startCursor: null,
endCursor: null,
hasOlder: false,
hasNewer: false,
entries: [],
error: error instanceof Error ? error.message : String(error),
epoch,
reset: false,
staleCursor: false,
gap: false,
window: {
minSeq: 0,
maxSeq: 0,
nextSeq: 0,
startSeq: null,
endSeq: null,
hasOlder: false,
hasNewer: false,
},
startCursor: null,
endCursor: null,
projection: msg.projection ?? "projected",
},
});
}
@@ -935,9 +935,6 @@ export function createTestAgentClients(): Record<string, AgentClient> {
return {
claude: new FakeAgentClient("claude"),
codex: new FakeAgentClient("codex"),
gemini: new FakeAgentClient("gemini"),
amp: new FakeAgentClient("amp"),
aider: new FakeAgentClient("aider"),
opencode: new FakeAgentClient("opencode"),
};
}
@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
import {
AgentStreamMessageSchema,
FetchAgentTimelineRequestMessageSchema,
FetchAgentTimelineResponseMessageSchema,
SessionInboundMessageSchema,
SessionOutboundMessageSchema,
@@ -18,55 +17,16 @@ describe("shared messages stream parsing", () => {
agentId: "agent_live",
agent: null,
direction: "tail",
startSeq: 1,
endSeq: 2,
projection: "projected",
epoch: "epoch-1",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 1, maxSeq: 2, nextSeq: 3 },
startCursor: { epoch: "epoch-1", seq: 1 },
endCursor: { epoch: "epoch-1", seq: 2 },
hasOlder: false,
hasNewer: false,
entries: [
{
provider: "codex",
item: { type: "assistant_message", text: "hello" },
timestamp: "2026-02-08T20:10:00.000Z",
seq: 2,
},
],
error: null,
},
});
expect(parsed.payload.entries).toHaveLength(1);
expect(parsed.payload.entries[0]?.item.type).toBe("assistant_message");
});
it("parses legacy fetch timeline request baggage at the parser boundary", () => {
const parsed = FetchAgentTimelineRequestMessageSchema.parse({
type: "fetch_agent_timeline_request",
agentId: "agent_live",
requestId: "req-legacy",
direction: "after",
cursor: {
seq: 12,
epoch: "legacy-epoch",
},
projection: "canonical",
});
expect(parsed.cursor?.epoch).toBe("legacy-epoch");
expect(parsed.projection).toBe("canonical");
});
it("parses legacy fetch timeline response baggage at the parser boundary", () => {
const parsed = FetchAgentTimelineResponseMessageSchema.parse({
type: "fetch_agent_timeline_response",
payload: {
requestId: "req-1",
agentId: "agent_live",
agent: null,
direction: "tail",
reset: false,
startCursor: { seq: 1, epoch: "legacy-epoch" },
endCursor: { seq: 2, epoch: "legacy-epoch" },
projection: "canonical",
entries: [
{
provider: "codex",
@@ -82,10 +42,8 @@ describe("shared messages stream parsing", () => {
},
});
expect(parsed.payload.startSeq).toBe(1);
expect(parsed.payload.endSeq).toBe(2);
expect(parsed.payload.projection).toBe("canonical");
expect(parsed.payload.entries[0]?.seq).toBe(2);
expect(parsed.payload.entries).toHaveLength(1);
expect(parsed.payload.entries[0]?.item.type).toBe("assistant_message");
});
it("parses explicit shutdown and restart lifecycle request payloads as distinct message types", () => {
@@ -112,7 +70,6 @@ describe("shared messages stream parsing", () => {
payload: {
agentId: "agent_live",
timestamp: "2026-02-08T20:10:00.000Z",
seq: 12,
event: {
type: "timeline",
provider: "claude",
@@ -140,27 +97,6 @@ describe("shared messages stream parsing", () => {
}
});
it("parses legacy agent_stream baggage at the parser boundary", () => {
const parsed = AgentStreamMessageSchema.parse({
type: "agent_stream",
payload: {
agentId: "agent_live",
timestamp: "2026-02-08T20:10:00.000Z",
epoch: "legacy-epoch",
event: {
type: "timeline",
provider: "claude",
item: {
type: "assistant_message",
text: "hello",
},
},
},
});
expect(parsed.payload.epoch).toBe("legacy-epoch");
});
it("parses representative sub_agent tool_call event", () => {
const parsed = AgentStreamMessageSchema.parse({
type: "agent_stream",
+62 -65
View File
@@ -908,6 +908,7 @@ export const RefreshAgentRequestMessageSchema = z.object({
export const CancelAgentRequestMessageSchema = z.object({
type: z.literal("cancel_agent_request"),
agentId: z.string(),
requestId: z.string().optional(),
});
export const RestartServerRequestMessageSchema = z.object({
@@ -922,9 +923,8 @@ export const ShutdownServerRequestMessageSchema = z.object({
});
export const AgentTimelineCursorSchema = z.object({
epoch: z.string(),
seq: z.number().int().nonnegative(),
// COMPAT(timeline): retain legacy cursor epoch for older clients.
epoch: z.string().optional(),
});
export const FetchAgentTimelineRequestMessageSchema = z.object({
@@ -935,8 +935,8 @@ export const FetchAgentTimelineRequestMessageSchema = z.object({
cursor: AgentTimelineCursorSchema.optional(),
// 0 means "all matching rows for this query window".
limit: z.number().int().nonnegative().optional(),
// COMPAT(timeline): retain removed projection so older clients can still send it.
projection: z.enum(["canonical", "projected"]).optional(),
// Default should be projected for app timeline loading.
projection: z.enum(["projected", "canonical"]).optional(),
});
export const SetAgentModeRequestMessageSchema = z.object({
@@ -1367,6 +1367,7 @@ export const FileDownloadTokenRequestSchema = z.object({
export const ClearAgentAttentionMessageSchema = z.object({
type: z.literal("clear_agent_attention"),
agentId: z.union([z.string(), z.array(z.string())]),
requestId: z.string().optional(),
});
export const ClientHeartbeatMessageSchema = z.object({
@@ -2036,9 +2037,8 @@ export const AgentStreamMessageSchema = z.object({
agentId: z.string(),
event: AgentStreamEventPayloadSchema,
timestamp: z.string(),
// Present only for committed timeline events.
// Present for timeline events. Maps 1:1 to canonical in-memory timeline rows.
seq: z.number().int().nonnegative().optional(),
// COMPAT(timeline): retain removed epoch for older clients.
epoch: z.string().optional(),
}),
});
@@ -2196,72 +2196,63 @@ export const FetchAgentResponseMessageSchema = z.object({
}),
});
const TimelineProjectionKindSchema = z.enum(["assistant_merge", "tool_lifecycle"]);
const TimelineSeqRangeSchema = z.object({
const AgentTimelineSeqRangeSchema = z.object({
startSeq: z.number().int().nonnegative(),
endSeq: z.number().int().nonnegative(),
});
export const AgentTimelineEntryPayloadSchema = z
.object({
provider: AgentProviderSchema,
item: AgentTimelineItemPayloadSchema,
timestamp: z.string(),
seq: z.number().int().nonnegative().optional(),
// COMPAT(timeline): retain legacy projected timeline fields.
seqStart: z.number().int().nonnegative().optional(),
seqEnd: z.number().int().nonnegative().optional(),
sourceSeqRanges: z.array(TimelineSeqRangeSchema).optional(),
collapsed: z.array(TimelineProjectionKindSchema).optional(),
})
.transform((entry, ctx) => {
const seq = entry.seq ?? entry.seqEnd ?? entry.seqStart;
if (typeof seq !== "number") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Agent timeline entry must include seq or legacy seqStart/seqEnd",
});
return z.NEVER;
}
return {
...entry,
seq,
};
});
export const AgentTimelineEntryPayloadSchema = z.object({
provider: AgentProviderSchema,
item: AgentTimelineItemPayloadSchema,
timestamp: z.string(),
seqStart: z.number().int().nonnegative(),
seqEnd: z.number().int().nonnegative(),
sourceSeqRanges: z.array(AgentTimelineSeqRangeSchema),
collapsed: z.array(z.enum(["assistant_merge", "tool_lifecycle"])),
});
export const FetchAgentTimelineResponseMessageSchema = z.object({
type: z.literal("fetch_agent_timeline_response"),
payload: z
.object({
requestId: z.string(),
agentId: z.string(),
agent: AgentSnapshotPayloadSchema.nullable(),
direction: z.enum(["tail", "before", "after"]),
startSeq: z.number().int().nonnegative().nullable().optional(),
endSeq: z.number().int().nonnegative().nullable().optional(),
hasOlder: z.boolean().optional(),
hasNewer: z.boolean().optional(),
entries: z.array(AgentTimelineEntryPayloadSchema),
error: z.string().nullable(),
// COMPAT(timeline): retain legacy response baggage for older clients.
epoch: z.string().optional(),
reset: z.boolean().optional(),
staleCursor: z.boolean().optional(),
gap: z.unknown().optional(),
window: z.unknown().optional(),
startCursor: AgentTimelineCursorSchema.nullable().optional(),
endCursor: AgentTimelineCursorSchema.nullable().optional(),
projection: z.enum(["canonical", "projected"]).optional(),
})
.transform((payload) => ({
...payload,
startSeq: payload.startSeq ?? payload.startCursor?.seq ?? null,
endSeq: payload.endSeq ?? payload.endCursor?.seq ?? null,
hasOlder: payload.hasOlder ?? false,
hasNewer: payload.hasNewer ?? false,
})),
payload: z.object({
requestId: z.string(),
agentId: z.string(),
agent: AgentSnapshotPayloadSchema.nullable(),
direction: z.enum(["tail", "before", "after"]),
projection: z.enum(["projected", "canonical"]),
epoch: z.string(),
reset: z.boolean(),
staleCursor: z.boolean(),
gap: z.boolean(),
window: z.object({
minSeq: z.number().int().nonnegative(),
maxSeq: z.number().int().nonnegative(),
nextSeq: z.number().int().nonnegative(),
}),
startCursor: AgentTimelineCursorSchema.nullable(),
endCursor: AgentTimelineCursorSchema.nullable(),
hasOlder: z.boolean(),
hasNewer: z.boolean(),
entries: z.array(AgentTimelineEntryPayloadSchema),
error: z.string().nullable(),
}),
});
export const CancelAgentResponseMessageSchema = z.object({
type: z.literal("cancel_agent_response"),
payload: z.object({
requestId: z.string(),
agentId: z.string(),
agent: AgentSnapshotPayloadSchema.nullable(),
}),
});
export const ClearAgentAttentionResponseMessageSchema = z.object({
type: z.literal("clear_agent_attention_response"),
payload: z.object({
requestId: z.string(),
agentId: z.string().or(z.array(z.string())),
agents: z.array(AgentSnapshotPayloadSchema),
}),
});
export const SendAgentMessageResponseMessageSchema = z.object({
@@ -2960,6 +2951,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ArchiveWorkspaceResponseMessageSchema,
FetchAgentResponseMessageSchema,
FetchAgentTimelineResponseMessageSchema,
CancelAgentResponseMessageSchema,
ClearAgentAttentionResponseMessageSchema,
SendAgentMessageResponseMessageSchema,
SetVoiceModeResponseMessageSchema,
GetDaemonConfigResponseMessageSchema,
@@ -3085,6 +3078,7 @@ export type FetchAgentResponseMessage = z.infer<typeof FetchAgentResponseMessage
export type FetchAgentTimelineResponseMessage = z.infer<
typeof FetchAgentTimelineResponseMessageSchema
>;
export type CancelAgentResponseMessage = z.infer<typeof CancelAgentResponseMessageSchema>;
export type SendAgentMessageResponseMessage = z.infer<typeof SendAgentMessageResponseMessageSchema>;
export type SetVoiceModeResponseMessage = z.infer<typeof SetVoiceModeResponseMessageSchema>;
export type SetAgentModeResponseMessage = z.infer<typeof SetAgentModeResponseMessageSchema>;
@@ -3256,6 +3250,9 @@ export type FileDownloadTokenResponse = z.infer<typeof FileDownloadTokenResponse
export type RestartServerRequestMessage = z.infer<typeof RestartServerRequestMessageSchema>;
export type ShutdownServerRequestMessage = z.infer<typeof ShutdownServerRequestMessageSchema>;
export type ClearAgentAttentionMessage = z.infer<typeof ClearAgentAttentionMessageSchema>;
export type ClearAgentAttentionResponseMessage = z.infer<
typeof ClearAgentAttentionResponseMessageSchema
>;
export type ClientHeartbeatMessage = z.infer<typeof ClientHeartbeatMessageSchema>;
export type ListCommandsRequest = z.infer<typeof ListCommandsRequestSchema>;
export type ListCommandsResponse = z.infer<typeof ListCommandsResponseSchema>;