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:
Generated
+11
-11
@@ -32,8 +32,8 @@
|
||||
"get-port-cli": "^3.0.0",
|
||||
"knip": "^5.82.1",
|
||||
"patch-package": "^8.0.1",
|
||||
"react": "19.1.4",
|
||||
"react-dom": "19.1.4",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
},
|
||||
@@ -29122,9 +29122,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.1.4",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.4.tgz",
|
||||
"integrity": "sha512-DHINL3PAmPUiK1uszfbKiXqfE03eszdt5BpVSuEAHb5nfmNPwnsy7g39h2t8aXFc/Bv99GH81s+j8dobtD+jOw==",
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -29162,15 +29162,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.1.4",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.4.tgz",
|
||||
"integrity": "sha512-s2868ab/xo2SI6H4106A7aFI8Mrqa4xC6HZT/pBzYyQ3cBLqa88hu47xYD8xf+uECleN698Awn7RCWlkTiKnqQ==",
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.1.4"
|
||||
"react": "^19.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-error-boundary": {
|
||||
@@ -34911,8 +34911,8 @@
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"lucide-react-native": "^0.546.0",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"react": "19.1.4",
|
||||
"react-dom": "19.1.4",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "^0.81.5",
|
||||
"react-native-css": "^3.0.1",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
|
||||
+4
-4
@@ -69,8 +69,8 @@
|
||||
"get-port-cli": "^3.0.0",
|
||||
"knip": "^5.82.1",
|
||||
"patch-package": "^8.0.1",
|
||||
"react": "19.1.4",
|
||||
"react-dom": "19.1.4",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
@@ -94,8 +94,8 @@
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"overrides": {
|
||||
"lightningcss": "1.30.1",
|
||||
"react": "19.1.4",
|
||||
"react-dom": "19.1.4"
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
|
||||
@@ -81,8 +81,8 @@
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"lucide-react-native": "^0.546.0",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"react": "19.1.4",
|
||||
"react-dom": "19.1.4",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "^0.81.5",
|
||||
"react-native-css": "^3.0.1",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface AiderIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function AiderIcon({ size = 16, color = "currentColor" }: AiderIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
|
||||
<Path d="M9 3.75h5.25V4.5H9V3.75Zm-.75.75h6v2.25h-6V4.5Zm6 3h2.25v3h-2.25v-3Zm-6 3h8.25v3h-8.25v-3Zm-1.5 3h1.5v3h-1.5v-3Zm7.5 0h2.25v3h-2.25v-3Zm-6 3h6v3h-6v-3Zm8.25 0H18v3h-1.5v-3Z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface AmpIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function AmpIcon({ size = 16, color = "currentColor" }: AmpIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<Path
|
||||
fill={color}
|
||||
d="m9.686 6.949 2.907.775-1.98-7.404-3.353.903 1.194 4.49a1.94 1.94 0 0 0 1.232 1.236Z"
|
||||
/>
|
||||
<Path
|
||||
fill={color}
|
||||
d="m4.771 22 6.34-6.327 2.307 8.62 3.352-.903L13.432 10.87.912 7.533 0 10.906l8.61 2.3-6.31 6.317L4.77 22Z"
|
||||
/>
|
||||
<Path
|
||||
fill={color}
|
||||
d="m13.254 11.707.778 2.917a1.937 1.937 0 0 0 1.23 1.234l4.511 1.199.89-3.37-7.409-1.98Z"
|
||||
/>
|
||||
<Path
|
||||
fill={color}
|
||||
d="m15.916 2.484-2.883 2.88a2.063 2.063 0 0 0-.512 1.193l-.046 1.825 1.69.06-.022-.001c.463 0 .898-.181 1.225-.507L18.35 4.95l-2.434-2.467Z"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface GeminiIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function GeminiIcon({ size = 16, color = "currentColor" }: GeminiIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<Path
|
||||
fill={color}
|
||||
d="M20.616 10.835a14.147 14.147 0 0 1-4.45-3.001 14.111 14.111 0 0 1-3.678-6.452.503.503 0 0 0-.975 0 14.134 14.134 0 0 1-3.679 6.452 14.155 14.155 0 0 1-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 0 0 0 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 0 1 4.45 3.001 14.112 14.112 0 0 1 3.679 6.453.502.502 0 0 0 .975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 0 1 3.001-4.45 14.113 14.113 0 0 1 6.453-3.678.503.503 0 0 0 0-.975 13.245 13.245 0 0 1-2.003-.678Z"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import { Bot } from "lucide-react-native";
|
||||
import { AiderIcon } from "@/components/icons/aider-icon";
|
||||
import { AmpIcon } from "@/components/icons/amp-icon";
|
||||
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||
import { CodexIcon } from "@/components/icons/codex-icon";
|
||||
import { GeminiIcon } from "@/components/icons/gemini-icon";
|
||||
import { CopilotIcon } from "@/components/icons/copilot-icon";
|
||||
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
|
||||
import { PiIcon } from "@/components/icons/pi-icon";
|
||||
@@ -11,9 +8,6 @@ import { PiIcon } from "@/components/icons/pi-icon";
|
||||
const PROVIDER_ICONS: Record<string, typeof Bot> = {
|
||||
claude: ClaudeIcon as unknown as typeof Bot,
|
||||
codex: CodexIcon as unknown as typeof Bot,
|
||||
gemini: GeminiIcon as unknown as typeof Bot,
|
||||
amp: AmpIcon as unknown as typeof Bot,
|
||||
aider: AiderIcon as unknown as typeof Bot,
|
||||
copilot: CopilotIcon as unknown as typeof Bot,
|
||||
opencode: OpenCodeIcon as unknown as typeof Bot,
|
||||
pi: PiIcon as unknown as typeof Bot,
|
||||
|
||||
@@ -591,8 +591,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
void client
|
||||
.fetchAgentTimeline(agentId, {
|
||||
direction: "after",
|
||||
cursor: { seq: cursor.endSeq },
|
||||
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("[Session] failed to fetch catch-up timeline on resume", agentId, error);
|
||||
@@ -853,12 +854,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
);
|
||||
|
||||
const requestCanonicalCatchUp = useCallback(
|
||||
(agentId: string, cursor: { endSeq: number }) => {
|
||||
(agentId: string, cursor: { epoch: string; endSeq: number }) => {
|
||||
void client
|
||||
.fetchAgentTimeline(agentId, {
|
||||
direction: "after",
|
||||
cursor: { seq: cursor.endSeq },
|
||||
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("[Session] failed to fetch canonical catch-up timeline", agentId, error);
|
||||
@@ -961,6 +963,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
if (
|
||||
current &&
|
||||
current.epoch === result.cursor.epoch &&
|
||||
current.startSeq === result.cursor.startSeq &&
|
||||
current.endSeq === result.cursor.endSeq
|
||||
) {
|
||||
@@ -1065,7 +1068,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
|
||||
const unsubAgentStream = client.on("agent_stream", (message) => {
|
||||
if (message.type !== "agent_stream") return;
|
||||
const { agentId, event, timestamp, seq } = message.payload;
|
||||
const { agentId, event, timestamp, seq, epoch } = message.payload;
|
||||
const parsedTimestamp = new Date(timestamp);
|
||||
const streamEvent = event as AgentStreamEventPayload;
|
||||
if (
|
||||
@@ -1107,6 +1110,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const result = processAgentStreamEvent({
|
||||
event: streamEvent,
|
||||
seq,
|
||||
epoch,
|
||||
currentTail,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
@@ -1130,6 +1134,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
if (
|
||||
current &&
|
||||
typeof seq === "number" &&
|
||||
typeof epoch === "string" &&
|
||||
current.epoch === epoch &&
|
||||
seq >= current.startSeq &&
|
||||
seq <= current.endSeq
|
||||
) {
|
||||
@@ -1138,6 +1144,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
if (
|
||||
current &&
|
||||
current.epoch === nextCursor.epoch &&
|
||||
current.startSeq === nextCursor.startSeq &&
|
||||
current.endSeq === nextCursor.endSeq
|
||||
) {
|
||||
|
||||
@@ -9,9 +9,13 @@ import {
|
||||
type TimelineCursor,
|
||||
} from "./session-stream-reducers";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeTimelineEntry(seq: number, text: string, type: string = "assistant_message") {
|
||||
return {
|
||||
seq,
|
||||
seqStart: seq,
|
||||
provider: "claude",
|
||||
item: { type, text },
|
||||
timestamp: new Date(1000 + seq).toISOString(),
|
||||
@@ -29,30 +33,22 @@ function makeTimelineEvent(
|
||||
} as AgentStreamEventPayload;
|
||||
}
|
||||
|
||||
function makeToolCallEvent(status: "running" | "completed"): AgentStreamEventPayload {
|
||||
function makeUserTimelineEvent(text: string): AgentStreamEventPayload {
|
||||
return {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "call-1",
|
||||
name: "shell",
|
||||
status,
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "pwd",
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
item: { type: "user_message", text },
|
||||
} as AgentStreamEventPayload;
|
||||
}
|
||||
|
||||
const baseTimelineInput: ProcessTimelineResponseInput = {
|
||||
payload: {
|
||||
agentId: "agent-1",
|
||||
direction: "after",
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
reset: false,
|
||||
epoch: "epoch-1",
|
||||
startCursor: null,
|
||||
endCursor: null,
|
||||
entries: [],
|
||||
error: null,
|
||||
},
|
||||
@@ -67,6 +63,7 @@ const baseTimelineInput: ProcessTimelineResponseInput = {
|
||||
const baseStreamInput: ProcessAgentStreamEventInput = {
|
||||
event: makeTimelineEvent("hello"),
|
||||
seq: undefined,
|
||||
epoch: undefined,
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
@@ -74,6 +71,10 @@ const baseStreamInput: ProcessAgentStreamEventInput = {
|
||||
timestamp: new Date(2000),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// processTimelineResponse
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("processTimelineResponse", () => {
|
||||
it("returns error path when payload.error is set", () => {
|
||||
const result = processTimelineResponse({
|
||||
@@ -92,10 +93,35 @@ describe("processTimelineResponse", () => {
|
||||
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
||||
expect(result.head).toBe(baseTimelineInput.currentHead);
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.sideEffects).toEqual([]);
|
||||
});
|
||||
|
||||
it("replaces tail during bootstrap tail init and schedules committed catch-up", () => {
|
||||
const provisionalHead: StreamItem[] = [
|
||||
it("returns error with no init resolution when no deferred exists", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: false,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
error: "timeout",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.error).toBe("timeout");
|
||||
expect(result.initResolution).toBe(null);
|
||||
expect(result.clearInitializing).toBe(true);
|
||||
});
|
||||
|
||||
it("replaces tail and clears head when reset=true", () => {
|
||||
const existingTail: StreamItem[] = [
|
||||
{
|
||||
kind: "user_message",
|
||||
id: "old",
|
||||
text: "old message",
|
||||
timestamp: new Date(500),
|
||||
},
|
||||
];
|
||||
const existingHead: StreamItem[] = [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "head-1",
|
||||
@@ -106,325 +132,518 @@ describe("processTimelineResponse", () => {
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentHead: provisionalHead,
|
||||
currentTail: existingTail,
|
||||
currentHead: existingHead,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
reset: true,
|
||||
startCursor: { seq: 1 },
|
||||
endCursor: { seq: 3 },
|
||||
entries: [
|
||||
makeTimelineEntry(1, "first"),
|
||||
makeTimelineEntry(2, "second"),
|
||||
makeTimelineEntry(3, "third"),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.tail).not.toBe(existingTail);
|
||||
expect(result.tail.length).toBeGreaterThan(0);
|
||||
expect(result.head).toEqual([]);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 3,
|
||||
});
|
||||
expect(result.error).toBe(null);
|
||||
expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true);
|
||||
});
|
||||
|
||||
it("sets cursor to null when reset=true but no cursors in payload", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 5 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
reset: true,
|
||||
entries: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.cursor).toBe(null);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
});
|
||||
|
||||
it("performs bootstrap tail init with catch-up side effect", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: true,
|
||||
initRequestDirection: "tail",
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "tail",
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
epoch: "epoch-1",
|
||||
startCursor: { seq: 1 },
|
||||
endCursor: { seq: 5 },
|
||||
entries: [makeTimelineEntry(1, "first"), makeTimelineEntry(5, "last")],
|
||||
},
|
||||
});
|
||||
|
||||
// Bootstrap tail replaces
|
||||
expect(result.tail.length).toBeGreaterThan(0);
|
||||
expect(result.head).toEqual([]);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
});
|
||||
|
||||
const catchUp = result.sideEffects.find((effect) => effect.type === "catch_up");
|
||||
expect(catchUp).toEqual({
|
||||
type: "catch_up",
|
||||
cursor: { endSeq: 5 },
|
||||
// Should have catch-up side effect
|
||||
const catchUp = result.sideEffects.find((e) => e.type === "catch_up");
|
||||
expect(catchUp).toBeDefined();
|
||||
expect(catchUp!.type === "catch_up" && catchUp!.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
endSeq: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it("prepends older committed history for before pagination", () => {
|
||||
const currentTail: StreamItem[] = [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "tail-3",
|
||||
text: "newer",
|
||||
timestamp: new Date(3000),
|
||||
},
|
||||
];
|
||||
const currentCursor: TimelineCursor = { startSeq: 3, endSeq: 4 };
|
||||
it("appends incrementally for contiguous seqs", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 3,
|
||||
};
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail,
|
||||
currentCursor,
|
||||
currentCursor: existingCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "before",
|
||||
startSeq: 1,
|
||||
endSeq: 2,
|
||||
entries: [makeTimelineEntry(1, "hello", "user_message"), makeTimelineEntry(2, "older")],
|
||||
epoch: "epoch-1",
|
||||
entries: [makeTimelineEntry(4, "next-1"), makeTimelineEntry(5, "next-2")],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.tail.length).toBeGreaterThan(0);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 4,
|
||||
endSeq: 5,
|
||||
});
|
||||
expect(result.tail).toHaveLength(3);
|
||||
expect(result.tail[0]?.kind).toBe("user_message");
|
||||
expect(result.tail[1]?.kind).toBe("assistant_message");
|
||||
expect(result.tail[2]).toBe(currentTail[0]);
|
||||
expect(result.error).toBe(null);
|
||||
});
|
||||
|
||||
it("replaces stale provisional assistant UI when fetch-after returns committed row 121", () => {
|
||||
const currentHead: StreamItem[] = [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "head-assistant",
|
||||
text: "partial",
|
||||
timestamp: new Date(120000),
|
||||
},
|
||||
];
|
||||
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
|
||||
it("detects gap and emits catch-up side effect", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 3,
|
||||
};
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
currentCursor: existingCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-1",
|
||||
entries: [makeTimelineEntry(10, "far ahead")],
|
||||
},
|
||||
});
|
||||
|
||||
// Gap should trigger catch-up
|
||||
const catchUp = result.sideEffects.find((e) => e.type === "catch_up");
|
||||
expect(catchUp).toBeDefined();
|
||||
expect(catchUp!.type === "catch_up" && catchUp!.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
endSeq: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("drops stale entries silently", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 8,
|
||||
};
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor: existingCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-1",
|
||||
entries: [makeTimelineEntry(5, "old"), makeTimelineEntry(7, "also old")],
|
||||
},
|
||||
});
|
||||
|
||||
// No new items appended (all dropped as stale)
|
||||
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
});
|
||||
|
||||
it("drops entries with epoch mismatch", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
};
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor: existingCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-2",
|
||||
entries: [makeTimelineEntry(6, "different epoch")],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves init when deferred matches direction", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: true,
|
||||
initRequestDirection: "after",
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
startSeq: 121,
|
||||
endSeq: 121,
|
||||
entries: [makeTimelineEntry(121, "finalized reply")],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.head).toEqual([]);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
startSeq: 1,
|
||||
endSeq: 121,
|
||||
});
|
||||
expect(result.tail[result.tail.length - 1]).toMatchObject({
|
||||
kind: "assistant_message",
|
||||
text: "finalized reply",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps provisional head when reconnect catch-up has no new committed rows yet", () => {
|
||||
const currentHead: StreamItem[] = [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "head-assistant",
|
||||
text: "still streaming",
|
||||
timestamp: new Date(120000),
|
||||
},
|
||||
];
|
||||
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
entries: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.head).toBe(currentHead);
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
||||
expect(result.initResolution).toBe("resolve");
|
||||
expect(result.clearInitializing).toBe(true);
|
||||
});
|
||||
|
||||
it("requests catch-up when committed rows arrive with a forward gap", () => {
|
||||
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
|
||||
|
||||
it("does not resolve init when directions differ (before vs after)", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: true,
|
||||
initRequestDirection: "after",
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "before",
|
||||
entries: [],
|
||||
},
|
||||
});
|
||||
|
||||
// "before" direction doesn't match "after" initRequestDirection,
|
||||
// and "before" is not a bootstrap tail path, so init should NOT resolve
|
||||
expect(result.initResolution).toBe(null);
|
||||
expect(result.clearInitializing).toBe(false);
|
||||
});
|
||||
|
||||
it("clears initializing even without deferred", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: false,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
startSeq: 125,
|
||||
endSeq: 125,
|
||||
entries: [makeTimelineEntry(125, "far ahead")],
|
||||
entries: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
||||
expect(result.sideEffects).toContainEqual({
|
||||
type: "catch_up",
|
||||
cursor: { endSeq: 120 },
|
||||
expect(result.clearInitializing).toBe(true);
|
||||
expect(result.initResolution).toBe(null);
|
||||
});
|
||||
|
||||
it("always includes flush_pending_updates side effect on success", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
entries: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true);
|
||||
});
|
||||
|
||||
it("initializes cursor when no existing cursor on first entries", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor: undefined,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-1",
|
||||
entries: [makeTimelineEntry(1, "first"), makeTimelineEntry(2, "second")],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// processAgentStreamEvent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("processAgentStreamEvent", () => {
|
||||
it("treats seq-less timeline events as provisional head updates", () => {
|
||||
it("passes through non-timeline events without cursor changes", () => {
|
||||
const turnEvent: AgentStreamEventPayload = {
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("partial"),
|
||||
event: turnEvent,
|
||||
seq: undefined,
|
||||
epoch: undefined,
|
||||
});
|
||||
|
||||
expect(result.changedHead).toBe(true);
|
||||
expect(result.changedTail).toBe(false);
|
||||
expect(result.head).toHaveLength(1);
|
||||
expect(result.head[0]).toMatchObject({
|
||||
kind: "assistant_message",
|
||||
text: "partial",
|
||||
});
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.cursor).toBe(null);
|
||||
expect(result.sideEffects).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps tool call rows anchored in tail across live and committed updates", () => {
|
||||
const running = processAgentStreamEvent({
|
||||
it("accepts timeline event with cursor advance", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 4,
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeToolCallEvent("running"),
|
||||
seq: undefined,
|
||||
});
|
||||
expect(running.head).toEqual([]);
|
||||
expect(running.tail).toHaveLength(1);
|
||||
expect(running.tail[0]).toMatchObject({
|
||||
kind: "tool_call",
|
||||
payload: {
|
||||
source: "agent",
|
||||
data: {
|
||||
callId: "call-1",
|
||||
status: "running",
|
||||
},
|
||||
},
|
||||
event: makeTimelineEvent("new chunk"),
|
||||
seq: 5,
|
||||
epoch: "epoch-1",
|
||||
currentCursor: existingCursor,
|
||||
});
|
||||
|
||||
const completedLive = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeToolCallEvent("completed"),
|
||||
seq: undefined,
|
||||
currentHead: running.head,
|
||||
currentTail: running.tail,
|
||||
currentCursor: { startSeq: 1, endSeq: 7 },
|
||||
});
|
||||
expect(completedLive.head).toEqual([]);
|
||||
expect(completedLive.tail).toHaveLength(1);
|
||||
expect(completedLive.tail[0]).toMatchObject({
|
||||
kind: "tool_call",
|
||||
payload: {
|
||||
source: "agent",
|
||||
data: {
|
||||
callId: "call-1",
|
||||
status: "completed",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const committed = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeToolCallEvent("completed"),
|
||||
seq: 8,
|
||||
currentHead: completedLive.head,
|
||||
currentTail: completedLive.tail,
|
||||
currentCursor: { startSeq: 1, endSeq: 7 },
|
||||
});
|
||||
|
||||
expect(committed.head).toEqual([]);
|
||||
expect(committed.tail).toHaveLength(1);
|
||||
expect(committed.tail[0]).toMatchObject({
|
||||
kind: "tool_call",
|
||||
payload: {
|
||||
source: "agent",
|
||||
data: {
|
||||
callId: "call-1",
|
||||
status: "completed",
|
||||
},
|
||||
},
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
});
|
||||
expect(result.sideEffects).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves assistant/tool interleaving while a turn is streaming", () => {
|
||||
const assistantBeforeTool = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("before"),
|
||||
seq: undefined,
|
||||
});
|
||||
it("detects gap and emits catch-up side effect", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 4,
|
||||
};
|
||||
|
||||
const runningTool = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeToolCallEvent("running"),
|
||||
seq: undefined,
|
||||
currentHead: assistantBeforeTool.head,
|
||||
currentTail: assistantBeforeTool.tail,
|
||||
});
|
||||
|
||||
const assistantAfterTool = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("after"),
|
||||
seq: undefined,
|
||||
currentHead: runningTool.head,
|
||||
currentTail: runningTool.tail,
|
||||
});
|
||||
|
||||
const completedTool = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeToolCallEvent("completed"),
|
||||
seq: undefined,
|
||||
currentHead: assistantAfterTool.head,
|
||||
currentTail: assistantAfterTool.tail,
|
||||
});
|
||||
|
||||
expect(completedTool.head).toEqual([]);
|
||||
expect(completedTool.tail.map((item) => item.kind)).toEqual([
|
||||
"assistant_message",
|
||||
"tool_call",
|
||||
"assistant_message",
|
||||
]);
|
||||
expect(
|
||||
completedTool.tail[0]?.kind === "assistant_message" ? completedTool.tail[0].text : null,
|
||||
).toBe("before");
|
||||
expect(
|
||||
completedTool.tail[2]?.kind === "assistant_message" ? completedTool.tail[2].text : null,
|
||||
).toBe("after");
|
||||
});
|
||||
|
||||
it("requests catch-up when a committed live row skips ahead", () => {
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("far ahead"),
|
||||
seq: 125,
|
||||
currentCursor: { startSeq: 1, endSeq: 120 },
|
||||
seq: 10,
|
||||
epoch: "epoch-1",
|
||||
currentCursor: existingCursor,
|
||||
});
|
||||
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.changedTail).toBe(false);
|
||||
expect(result.changedHead).toBe(false);
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.sideEffects).toContainEqual({
|
||||
type: "catch_up",
|
||||
cursor: { endSeq: 120 },
|
||||
|
||||
const catchUp = result.sideEffects.find((e) => e.type === "catch_up");
|
||||
expect(catchUp).toBeDefined();
|
||||
expect(catchUp!.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
endSeq: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("flushes provisional head into tail on terminal turn events", () => {
|
||||
const withHead = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("streaming"),
|
||||
seq: undefined,
|
||||
});
|
||||
it("drops stale timeline event", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 8,
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: {
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
},
|
||||
currentHead: withHead.head,
|
||||
currentTail: withHead.tail,
|
||||
event: makeTimelineEvent("old"),
|
||||
seq: 5,
|
||||
epoch: "epoch-1",
|
||||
currentCursor: existingCursor,
|
||||
});
|
||||
|
||||
expect(result.changedHead).toBe(true);
|
||||
expect(result.changedTail).toBe(true);
|
||||
expect(result.head).toEqual([]);
|
||||
expect(result.tail).toHaveLength(1);
|
||||
expect(result.tail[0]).toMatchObject({
|
||||
kind: "assistant_message",
|
||||
text: "streaming",
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.changedTail).toBe(false);
|
||||
expect(result.changedHead).toBe(false);
|
||||
expect(result.sideEffects).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops timeline event with epoch mismatch", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("wrong epoch"),
|
||||
seq: 6,
|
||||
epoch: "epoch-2",
|
||||
currentCursor: existingCursor,
|
||||
});
|
||||
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.changedTail).toBe(false);
|
||||
expect(result.changedHead).toBe(false);
|
||||
expect(result.sideEffects).toEqual([]);
|
||||
});
|
||||
|
||||
it("initializes cursor when none exists", () => {
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("first"),
|
||||
seq: 1,
|
||||
epoch: "epoch-1",
|
||||
currentCursor: undefined,
|
||||
});
|
||||
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("derives optimistic idle status on turn_completed for running agent", () => {
|
||||
const turnCompletedEvent: AgentStreamEventPayload = {
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: turnCompletedEvent,
|
||||
currentAgent: {
|
||||
status: "running",
|
||||
updatedAt: new Date(1000),
|
||||
lastActivityAt: new Date(1000),
|
||||
},
|
||||
timestamp: new Date(2000),
|
||||
});
|
||||
|
||||
expect(result.agentChanged).toBe(true);
|
||||
expect(result.agent).not.toBe(null);
|
||||
expect(result.agent!.status).toBe("idle");
|
||||
expect(result.agent!.updatedAt.getTime()).toBe(2000);
|
||||
expect(result.agent!.lastActivityAt.getTime()).toBe(2000);
|
||||
});
|
||||
|
||||
it("derives optimistic error status on turn_failed for running agent", () => {
|
||||
const turnFailedEvent: AgentStreamEventPayload = {
|
||||
type: "turn_failed",
|
||||
provider: "claude",
|
||||
error: "something broke",
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: turnFailedEvent,
|
||||
currentAgent: {
|
||||
status: "running",
|
||||
updatedAt: new Date(1000),
|
||||
lastActivityAt: new Date(1000),
|
||||
},
|
||||
timestamp: new Date(2000),
|
||||
});
|
||||
|
||||
expect(result.agentChanged).toBe(true);
|
||||
expect(result.agent!.status).toBe("error");
|
||||
});
|
||||
|
||||
it("does not change agent when status is not running", () => {
|
||||
const turnCompletedEvent: AgentStreamEventPayload = {
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: turnCompletedEvent,
|
||||
currentAgent: {
|
||||
status: "idle",
|
||||
updatedAt: new Date(1000),
|
||||
lastActivityAt: new Date(1000),
|
||||
},
|
||||
timestamp: new Date(2000),
|
||||
});
|
||||
|
||||
expect(result.agentChanged).toBe(false);
|
||||
expect(result.agent).toBe(null);
|
||||
});
|
||||
|
||||
it("does not change agent when no agent is provided", () => {
|
||||
const turnCompletedEvent: AgentStreamEventPayload = {
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: turnCompletedEvent,
|
||||
currentAgent: null,
|
||||
timestamp: new Date(2000),
|
||||
});
|
||||
|
||||
expect(result.agentChanged).toBe(false);
|
||||
expect(result.agent).toBe(null);
|
||||
});
|
||||
|
||||
it("preserves updatedAt when agent timestamp is newer than event", () => {
|
||||
const turnCompletedEvent: AgentStreamEventPayload = {
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: turnCompletedEvent,
|
||||
currentAgent: {
|
||||
status: "running",
|
||||
updatedAt: new Date(5000),
|
||||
lastActivityAt: new Date(5000),
|
||||
},
|
||||
timestamp: new Date(2000),
|
||||
});
|
||||
|
||||
expect(result.agentChanged).toBe(true);
|
||||
expect(result.agent!.updatedAt.getTime()).toBe(5000);
|
||||
expect(result.agent!.lastActivityAt.getTime()).toBe(5000);
|
||||
});
|
||||
|
||||
it("does not produce agent patch for non-terminal events", () => {
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("just text"),
|
||||
currentAgent: {
|
||||
status: "running",
|
||||
updatedAt: new Date(1000),
|
||||
lastActivityAt: new Date(1000),
|
||||
},
|
||||
seq: 1,
|
||||
epoch: "epoch-1",
|
||||
timestamp: new Date(2000),
|
||||
});
|
||||
|
||||
expect(result.agentChanged).toBe(false);
|
||||
expect(result.agent).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
||||
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { applyStreamEvent, hydrateStreamState, reduceStreamUpdate } from "@/types/stream";
|
||||
import {
|
||||
applyStreamEvent,
|
||||
flushHeadToTail,
|
||||
hydrateStreamState,
|
||||
reduceStreamUpdate,
|
||||
} from "@/types/stream";
|
||||
import {
|
||||
classifySessionTimelineSeq,
|
||||
type SessionTimelineSeqDecision,
|
||||
@@ -12,25 +17,38 @@ import {
|
||||
} from "@/contexts/session-timeline-bootstrap-policy";
|
||||
import { deriveOptimisticLifecycleStatus } from "@/contexts/session-stream-lifecycle";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared cursor type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TimelineCursor = {
|
||||
epoch: string;
|
||||
startSeq: number;
|
||||
endSeq: number;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Side-effect discriminated unions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TimelineReducerSideEffect =
|
||||
| { type: "catch_up"; cursor: { endSeq: number } }
|
||||
| { type: "catch_up"; cursor: { epoch: string; endSeq: number } }
|
||||
| { type: "flush_pending_updates" };
|
||||
|
||||
export type AgentStreamReducerSideEffect = {
|
||||
type: "catch_up";
|
||||
cursor: { endSeq: number };
|
||||
cursor: { epoch: string; endSeq: number };
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// processTimelineResponse
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TimelineDirection = "tail" | "before" | "after";
|
||||
type InitRequestDirection = "tail" | "after";
|
||||
|
||||
type TimelineResponseEntry = {
|
||||
seq: number;
|
||||
seqStart: number;
|
||||
provider: string;
|
||||
item: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
@@ -40,8 +58,10 @@ export interface ProcessTimelineResponseInput {
|
||||
payload: {
|
||||
agentId: string;
|
||||
direction: TimelineDirection;
|
||||
startSeq: number | null;
|
||||
endSeq: number | null;
|
||||
reset: boolean;
|
||||
epoch: string;
|
||||
startCursor: { seq: number } | null;
|
||||
endCursor: { seq: number } | null;
|
||||
entries: TimelineResponseEntry[];
|
||||
error: string | null;
|
||||
};
|
||||
@@ -64,9 +84,217 @@ export interface ProcessTimelineResponseOutput {
|
||||
sideEffects: TimelineReducerSideEffect[];
|
||||
}
|
||||
|
||||
export function processTimelineResponse(
|
||||
input: ProcessTimelineResponseInput,
|
||||
): ProcessTimelineResponseOutput {
|
||||
const {
|
||||
payload,
|
||||
currentTail,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
isInitializing,
|
||||
hasActiveInitDeferred,
|
||||
initRequestDirection,
|
||||
} = input;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Error path: reject init and leave stream state unchanged
|
||||
// ------------------------------------------------------------------
|
||||
if (payload.error) {
|
||||
return {
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
cursor: currentCursor,
|
||||
cursorChanged: false,
|
||||
initResolution: hasActiveInitDeferred ? "reject" : null,
|
||||
clearInitializing: isInitializing,
|
||||
error: payload.error,
|
||||
sideEffects: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Convert entries to timeline units
|
||||
// ------------------------------------------------------------------
|
||||
const timelineUnits = payload.entries.map((entry) => ({
|
||||
seq: entry.seqStart,
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: entry.provider,
|
||||
item: entry.item,
|
||||
} as AgentStreamEventPayload,
|
||||
timestamp: new Date(entry.timestamp),
|
||||
}));
|
||||
|
||||
const toHydratedEvents = (
|
||||
units: typeof timelineUnits,
|
||||
): Array<{ event: AgentStreamEventPayload; timestamp: Date }> =>
|
||||
units.map(({ event, timestamp }) => ({ event, timestamp }));
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Derive bootstrap policy (replace vs incremental)
|
||||
// ------------------------------------------------------------------
|
||||
const bootstrapPolicy = deriveBootstrapTailTimelinePolicy({
|
||||
direction: payload.direction,
|
||||
reset: payload.reset,
|
||||
epoch: payload.epoch,
|
||||
endCursor: payload.endCursor,
|
||||
isInitializing,
|
||||
hasActiveInitDeferred,
|
||||
});
|
||||
const replace = bootstrapPolicy.replace;
|
||||
|
||||
let nextTail = currentTail;
|
||||
let nextHead = currentHead;
|
||||
let nextCursor: TimelineCursor | null | undefined = currentCursor;
|
||||
let cursorChanged = false;
|
||||
const sideEffects: TimelineReducerSideEffect[] = [];
|
||||
|
||||
if (replace) {
|
||||
// ----------------------------------------------------------------
|
||||
// Replace path: full hydration from scratch
|
||||
// ----------------------------------------------------------------
|
||||
nextTail = hydrateStreamState(toHydratedEvents(timelineUnits), {
|
||||
source: "canonical",
|
||||
});
|
||||
nextHead = [];
|
||||
|
||||
if (payload.startCursor && payload.endCursor) {
|
||||
nextCursor = {
|
||||
epoch: payload.epoch,
|
||||
startSeq: payload.startCursor.seq,
|
||||
endSeq: payload.endCursor.seq,
|
||||
};
|
||||
cursorChanged = true;
|
||||
} else {
|
||||
nextCursor = null;
|
||||
cursorChanged = true;
|
||||
}
|
||||
|
||||
if (bootstrapPolicy.catchUpCursor) {
|
||||
sideEffects.push({
|
||||
type: "catch_up",
|
||||
cursor: bootstrapPolicy.catchUpCursor,
|
||||
});
|
||||
}
|
||||
} else if (timelineUnits.length > 0) {
|
||||
// ----------------------------------------------------------------
|
||||
// Incremental append path
|
||||
// ----------------------------------------------------------------
|
||||
const acceptedUnits: typeof timelineUnits = [];
|
||||
let cursor = currentCursor;
|
||||
let gapCursor: { epoch: string; endSeq: number } | null = null;
|
||||
|
||||
for (const unit of timelineUnits) {
|
||||
const decision: SessionTimelineSeqDecision = classifySessionTimelineSeq({
|
||||
cursor: cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null,
|
||||
epoch: payload.epoch,
|
||||
seq: unit.seq,
|
||||
});
|
||||
|
||||
if (decision === "gap") {
|
||||
gapCursor = cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null;
|
||||
break;
|
||||
}
|
||||
if (decision === "drop_stale" || decision === "drop_epoch") {
|
||||
continue;
|
||||
}
|
||||
|
||||
acceptedUnits.push(unit);
|
||||
if (decision === "init") {
|
||||
cursor = {
|
||||
epoch: payload.epoch,
|
||||
startSeq: unit.seq,
|
||||
endSeq: unit.seq,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (!cursor) {
|
||||
continue;
|
||||
}
|
||||
cursor = {
|
||||
...cursor,
|
||||
endSeq: unit.seq,
|
||||
};
|
||||
}
|
||||
|
||||
if (acceptedUnits.length > 0) {
|
||||
// Flush head to tail before appending canonical entries so that
|
||||
// chronological ordering is preserved. This matters on mobile where
|
||||
// the server drops live events for backgrounded/unfocused agents:
|
||||
// when the client catches up, the head may still hold stale live
|
||||
// items from before the gap that must be committed ahead of the
|
||||
// canonical gap-fill entries.
|
||||
const baseTail =
|
||||
currentHead.length > 0 ? flushHeadToTail(currentTail, currentHead) : currentTail;
|
||||
if (currentHead.length > 0) {
|
||||
nextHead = [];
|
||||
}
|
||||
|
||||
nextTail = acceptedUnits.reduce<StreamItem[]>(
|
||||
(state, { event, timestamp }) =>
|
||||
reduceStreamUpdate(state, event, timestamp, {
|
||||
source: "canonical",
|
||||
}),
|
||||
baseTail,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
cursor &&
|
||||
(!currentCursor ||
|
||||
currentCursor.epoch !== cursor.epoch ||
|
||||
currentCursor.startSeq !== cursor.startSeq ||
|
||||
currentCursor.endSeq !== cursor.endSeq)
|
||||
) {
|
||||
nextCursor = cursor;
|
||||
cursorChanged = true;
|
||||
}
|
||||
|
||||
if (gapCursor) {
|
||||
sideEffects.push({ type: "catch_up", cursor: gapCursor });
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Flush pending agent updates side effect
|
||||
// ------------------------------------------------------------------
|
||||
sideEffects.push({ type: "flush_pending_updates" });
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Init resolution
|
||||
// ------------------------------------------------------------------
|
||||
const shouldResolveDeferredInit = shouldResolveTimelineInit({
|
||||
hasActiveInitDeferred,
|
||||
isInitializing,
|
||||
initRequestDirection,
|
||||
responseDirection: payload.direction,
|
||||
reset: payload.reset,
|
||||
});
|
||||
const clearInitializing = shouldResolveDeferredInit || (isInitializing && !hasActiveInitDeferred);
|
||||
|
||||
const initResolution: "resolve" | "reject" | null = shouldResolveDeferredInit ? "resolve" : null;
|
||||
|
||||
return {
|
||||
tail: nextTail,
|
||||
head: nextHead,
|
||||
cursor: nextCursor,
|
||||
cursorChanged,
|
||||
initResolution,
|
||||
clearInitializing,
|
||||
error: null,
|
||||
sideEffects,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// processAgentStreamEvent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ProcessAgentStreamEventInput {
|
||||
event: AgentStreamEventPayload;
|
||||
seq: number | undefined;
|
||||
epoch: string | undefined;
|
||||
currentTail: StreamItem[];
|
||||
currentHead: StreamItem[];
|
||||
currentCursor: TimelineCursor | undefined;
|
||||
@@ -96,229 +324,57 @@ export interface ProcessAgentStreamEventOutput {
|
||||
sideEffects: AgentStreamReducerSideEffect[];
|
||||
}
|
||||
|
||||
function cursorsEqual(
|
||||
left: TimelineCursor | null | undefined,
|
||||
right: TimelineCursor | null | undefined,
|
||||
): boolean {
|
||||
if (!left || !right) {
|
||||
return left === right;
|
||||
}
|
||||
return left.startSeq === right.startSeq && left.endSeq === right.endSeq;
|
||||
}
|
||||
|
||||
function removeSupersededProvisionalItems(
|
||||
head: StreamItem[],
|
||||
event: AgentStreamEventPayload,
|
||||
): StreamItem[] {
|
||||
if (head.length === 0 || event.type !== "timeline") {
|
||||
return head;
|
||||
}
|
||||
|
||||
let nextHead = head;
|
||||
if (event.item.type === "assistant_message") {
|
||||
nextHead = head.filter((item) => item.kind !== "assistant_message");
|
||||
} else if (event.item.type === "tool_call") {
|
||||
const committedToolCall = event.item;
|
||||
nextHead = head.filter(
|
||||
(item) =>
|
||||
item.kind !== "tool_call" ||
|
||||
item.payload.source !== "agent" ||
|
||||
item.payload.data.callId !== committedToolCall.callId,
|
||||
);
|
||||
}
|
||||
|
||||
return nextHead.length === head.length ? head : nextHead;
|
||||
}
|
||||
|
||||
export function processTimelineResponse(
|
||||
input: ProcessTimelineResponseInput,
|
||||
): ProcessTimelineResponseOutput {
|
||||
const {
|
||||
payload,
|
||||
currentTail,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
isInitializing,
|
||||
hasActiveInitDeferred,
|
||||
initRequestDirection,
|
||||
} = input;
|
||||
|
||||
if (payload.error) {
|
||||
return {
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
cursor: currentCursor,
|
||||
cursorChanged: false,
|
||||
initResolution: hasActiveInitDeferred ? "reject" : null,
|
||||
clearInitializing: isInitializing,
|
||||
error: payload.error,
|
||||
sideEffects: [],
|
||||
};
|
||||
}
|
||||
|
||||
const timelineUnits = payload.entries.map((entry) => ({
|
||||
seq: entry.seq,
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: entry.provider,
|
||||
item: entry.item,
|
||||
} as AgentStreamEventPayload,
|
||||
timestamp: new Date(entry.timestamp),
|
||||
}));
|
||||
|
||||
const bootstrapPolicy = deriveBootstrapTailTimelinePolicy({
|
||||
direction: payload.direction,
|
||||
endSeq: payload.endSeq,
|
||||
isInitializing,
|
||||
hasActiveInitDeferred,
|
||||
});
|
||||
|
||||
let nextTail = currentTail;
|
||||
let nextHead = currentHead;
|
||||
let nextCursor: TimelineCursor | null | undefined = currentCursor;
|
||||
let cursorChanged = false;
|
||||
const sideEffects: TimelineReducerSideEffect[] = [];
|
||||
|
||||
if (bootstrapPolicy.replace) {
|
||||
nextTail = hydrateStreamState(
|
||||
timelineUnits.map(({ event, timestamp }) => ({ event, timestamp })),
|
||||
{ source: "canonical" },
|
||||
);
|
||||
nextHead = [];
|
||||
nextCursor =
|
||||
typeof payload.startSeq === "number" && typeof payload.endSeq === "number"
|
||||
? {
|
||||
startSeq: payload.startSeq,
|
||||
endSeq: payload.endSeq,
|
||||
}
|
||||
: null;
|
||||
cursorChanged = !cursorsEqual(currentCursor, nextCursor);
|
||||
|
||||
if (bootstrapPolicy.catchUpCursor) {
|
||||
sideEffects.push({
|
||||
type: "catch_up",
|
||||
cursor: bootstrapPolicy.catchUpCursor,
|
||||
});
|
||||
}
|
||||
} else if (payload.direction === "before") {
|
||||
const prepended = hydrateStreamState(
|
||||
timelineUnits.map(({ event, timestamp }) => ({ event, timestamp })),
|
||||
{ source: "canonical" },
|
||||
);
|
||||
nextTail = prepended.length > 0 ? [...prepended, ...currentTail] : currentTail;
|
||||
const derivedCursor =
|
||||
typeof payload.startSeq === "number"
|
||||
? {
|
||||
startSeq: payload.startSeq,
|
||||
endSeq: currentCursor?.endSeq ?? payload.endSeq ?? payload.startSeq,
|
||||
}
|
||||
: currentCursor;
|
||||
nextCursor = derivedCursor;
|
||||
cursorChanged = !cursorsEqual(currentCursor, derivedCursor);
|
||||
} else if (timelineUnits.length > 0) {
|
||||
const acceptedUnits: typeof timelineUnits = [];
|
||||
let cursor = currentCursor;
|
||||
let gapCursor: { endSeq: number } | null = null;
|
||||
|
||||
for (const unit of timelineUnits) {
|
||||
const decision: SessionTimelineSeqDecision = classifySessionTimelineSeq({
|
||||
cursor: cursor ? { endSeq: cursor.endSeq } : null,
|
||||
seq: unit.seq,
|
||||
});
|
||||
|
||||
if (decision === "gap") {
|
||||
gapCursor = cursor ? { endSeq: cursor.endSeq } : null;
|
||||
break;
|
||||
}
|
||||
if (decision === "drop_stale") {
|
||||
continue;
|
||||
}
|
||||
|
||||
acceptedUnits.push(unit);
|
||||
cursor =
|
||||
decision === "init"
|
||||
? { startSeq: unit.seq, endSeq: unit.seq }
|
||||
: { ...(cursor ?? { startSeq: unit.seq, endSeq: unit.seq }), endSeq: unit.seq };
|
||||
nextHead = removeSupersededProvisionalItems(nextHead, unit.event);
|
||||
}
|
||||
|
||||
if (acceptedUnits.length > 0) {
|
||||
nextTail = acceptedUnits.reduce<StreamItem[]>(
|
||||
(state, { event, timestamp }) =>
|
||||
reduceStreamUpdate(state, event, timestamp, {
|
||||
source: "canonical",
|
||||
}),
|
||||
currentTail,
|
||||
);
|
||||
}
|
||||
|
||||
if (cursor && !cursorsEqual(currentCursor, cursor)) {
|
||||
nextCursor = cursor;
|
||||
cursorChanged = true;
|
||||
}
|
||||
|
||||
if (gapCursor) {
|
||||
sideEffects.push({ type: "catch_up", cursor: gapCursor });
|
||||
}
|
||||
}
|
||||
|
||||
sideEffects.push({ type: "flush_pending_updates" });
|
||||
|
||||
const shouldResolveDeferredInit = shouldResolveTimelineInit({
|
||||
hasActiveInitDeferred,
|
||||
isInitializing,
|
||||
initRequestDirection,
|
||||
responseDirection: payload.direction,
|
||||
});
|
||||
const clearInitializing = shouldResolveDeferredInit || (isInitializing && !hasActiveInitDeferred);
|
||||
|
||||
return {
|
||||
tail: nextTail,
|
||||
head: nextHead,
|
||||
cursor: nextCursor,
|
||||
cursorChanged,
|
||||
initResolution: shouldResolveDeferredInit ? "resolve" : null,
|
||||
clearInitializing,
|
||||
error: null,
|
||||
sideEffects,
|
||||
};
|
||||
}
|
||||
|
||||
export function processAgentStreamEvent(
|
||||
input: ProcessAgentStreamEventInput,
|
||||
): ProcessAgentStreamEventOutput {
|
||||
const { event, seq, currentTail, currentHead, currentCursor, currentAgent, timestamp } = input;
|
||||
const { event, seq, epoch, currentTail, currentHead, currentCursor, currentAgent, timestamp } =
|
||||
input;
|
||||
|
||||
let shouldApplyStreamEvent = true;
|
||||
let nextTimelineCursor: TimelineCursor | null = null;
|
||||
let cursorChanged = false;
|
||||
const sideEffects: AgentStreamReducerSideEffect[] = [];
|
||||
|
||||
if (event.type === "timeline" && typeof seq === "number") {
|
||||
// ------------------------------------------------------------------
|
||||
// Timeline sequencing gate
|
||||
// ------------------------------------------------------------------
|
||||
if (event.type === "timeline" && typeof seq === "number" && typeof epoch === "string") {
|
||||
const decision = classifySessionTimelineSeq({
|
||||
cursor: currentCursor ? { endSeq: currentCursor.endSeq } : null,
|
||||
cursor: currentCursor ? { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq } : null,
|
||||
epoch,
|
||||
seq,
|
||||
});
|
||||
|
||||
if (decision === "gap") {
|
||||
if (decision === "init") {
|
||||
nextTimelineCursor = { epoch, startSeq: seq, endSeq: seq };
|
||||
cursorChanged = true;
|
||||
} else if (decision === "accept") {
|
||||
nextTimelineCursor = {
|
||||
...(currentCursor ?? { epoch, startSeq: seq, endSeq: seq }),
|
||||
epoch,
|
||||
endSeq: seq,
|
||||
};
|
||||
cursorChanged = true;
|
||||
} else if (decision === "gap") {
|
||||
shouldApplyStreamEvent = false;
|
||||
if (currentCursor) {
|
||||
sideEffects.push({
|
||||
type: "catch_up",
|
||||
cursor: { endSeq: currentCursor.endSeq },
|
||||
cursor: {
|
||||
epoch: currentCursor.epoch,
|
||||
endSeq: currentCursor.endSeq,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (decision === "drop_stale") {
|
||||
shouldApplyStreamEvent = false;
|
||||
} else {
|
||||
nextTimelineCursor =
|
||||
decision === "init"
|
||||
? { startSeq: seq, endSeq: seq }
|
||||
: { ...(currentCursor ?? { startSeq: seq, endSeq: seq }), endSeq: seq };
|
||||
cursorChanged = !cursorsEqual(currentCursor, nextTimelineCursor);
|
||||
// drop_stale or drop_epoch
|
||||
shouldApplyStreamEvent = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Apply stream event to tail/head
|
||||
// ------------------------------------------------------------------
|
||||
const { tail, head, changedTail, changedHead } = shouldApplyStreamEvent
|
||||
? applyStreamEvent({
|
||||
tail: currentTail,
|
||||
@@ -334,6 +390,9 @@ export function processAgentStreamEvent(
|
||||
changedHead: false,
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Optimistic lifecycle status
|
||||
// ------------------------------------------------------------------
|
||||
let agentPatch: AgentPatch | null = null;
|
||||
let agentChanged = false;
|
||||
|
||||
|
||||
@@ -2,42 +2,26 @@ import { describe, expect, it } from "vitest";
|
||||
import { classifySessionTimelineSeq } from "./session-timeline-seq-gate";
|
||||
import {
|
||||
deriveBootstrapTailTimelinePolicy,
|
||||
deriveInitialTimelineRequest,
|
||||
shouldResolveTimelineInit,
|
||||
} from "./session-timeline-bootstrap-policy";
|
||||
|
||||
describe("deriveInitialTimelineRequest", () => {
|
||||
it("uses tail bootstrap when history has not synced yet", () => {
|
||||
expect(
|
||||
deriveInitialTimelineRequest({
|
||||
cursor: { seq: 42 },
|
||||
hasAuthoritativeHistory: false,
|
||||
initialTimelineLimit: 200,
|
||||
}),
|
||||
).toEqual({
|
||||
direction: "tail",
|
||||
limit: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses catch-up after the committed cursor once history is synced", () => {
|
||||
expect(
|
||||
deriveInitialTimelineRequest({
|
||||
cursor: { seq: 42 },
|
||||
hasAuthoritativeHistory: true,
|
||||
initialTimelineLimit: 200,
|
||||
}),
|
||||
).toEqual({
|
||||
direction: "after",
|
||||
cursor: { seq: 42 },
|
||||
limit: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveBootstrapTailTimelinePolicy", () => {
|
||||
it("always replaces on explicit reset without catch-up cursor", () => {
|
||||
const policy = deriveBootstrapTailTimelinePolicy({
|
||||
direction: "after",
|
||||
reset: true,
|
||||
epoch: "epoch-1",
|
||||
endCursor: { seq: 200 },
|
||||
isInitializing: false,
|
||||
hasActiveInitDeferred: false,
|
||||
});
|
||||
|
||||
expect(policy.replace).toBe(true);
|
||||
expect(policy.catchUpCursor).toBeNull();
|
||||
});
|
||||
|
||||
it("forces baseline replace and canonical catch-up for init tail race", () => {
|
||||
const advancedCursor = { endSeq: 205 };
|
||||
const advancedCursor = { epoch: "epoch-1", endSeq: 205 };
|
||||
const tailSeqStart = 101;
|
||||
const tailSeqEnd = 200;
|
||||
|
||||
@@ -45,6 +29,7 @@ describe("deriveBootstrapTailTimelinePolicy", () => {
|
||||
for (let seq = tailSeqStart; seq <= tailSeqEnd; seq += 1) {
|
||||
const decision = classifySessionTimelineSeq({
|
||||
cursor: advancedCursor,
|
||||
epoch: "epoch-1",
|
||||
seq,
|
||||
});
|
||||
if (decision === "accept" || decision === "init") {
|
||||
@@ -55,13 +40,16 @@ describe("deriveBootstrapTailTimelinePolicy", () => {
|
||||
|
||||
const policy = deriveBootstrapTailTimelinePolicy({
|
||||
direction: "tail",
|
||||
endSeq: 200,
|
||||
reset: false,
|
||||
epoch: "epoch-1",
|
||||
endCursor: { seq: 200 },
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: true,
|
||||
});
|
||||
|
||||
expect(policy.replace).toBe(true);
|
||||
expect(policy.catchUpCursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
endSeq: 200,
|
||||
});
|
||||
});
|
||||
@@ -69,7 +57,9 @@ describe("deriveBootstrapTailTimelinePolicy", () => {
|
||||
it("does not replace non-bootstrap, non-reset responses", () => {
|
||||
const policy = deriveBootstrapTailTimelinePolicy({
|
||||
direction: "tail",
|
||||
endSeq: 200,
|
||||
reset: false,
|
||||
epoch: "epoch-1",
|
||||
endCursor: { seq: 200 },
|
||||
isInitializing: false,
|
||||
hasActiveInitDeferred: false,
|
||||
});
|
||||
@@ -87,6 +77,7 @@ describe("shouldResolveTimelineInit", () => {
|
||||
isInitializing: true,
|
||||
initRequestDirection: "tail",
|
||||
responseDirection: "tail",
|
||||
reset: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -98,6 +89,7 @@ describe("shouldResolveTimelineInit", () => {
|
||||
isInitializing: true,
|
||||
initRequestDirection: "tail",
|
||||
responseDirection: "after",
|
||||
reset: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -109,6 +101,7 @@ describe("shouldResolveTimelineInit", () => {
|
||||
isInitializing: true,
|
||||
initRequestDirection: "after",
|
||||
responseDirection: "after",
|
||||
reset: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ type BootstrapTailCursor = {
|
||||
} | null;
|
||||
|
||||
type InitialTimelineCursor = {
|
||||
epoch: string;
|
||||
seq: number;
|
||||
} | null;
|
||||
|
||||
@@ -19,37 +20,48 @@ export function deriveInitialTimelineRequest({
|
||||
initialTimelineLimit: number;
|
||||
}): {
|
||||
direction: "tail" | "after";
|
||||
cursor?: { seq: number };
|
||||
cursor?: { epoch: string; seq: number };
|
||||
limit: number;
|
||||
projection: "canonical";
|
||||
} {
|
||||
if (!hasAuthoritativeHistory || !cursor) {
|
||||
return {
|
||||
direction: "tail",
|
||||
limit: initialTimelineLimit,
|
||||
projection: "canonical",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
direction: "after",
|
||||
cursor: { seq: cursor.seq },
|
||||
cursor: { epoch: cursor.epoch, seq: cursor.seq },
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveBootstrapTailTimelinePolicy({
|
||||
direction,
|
||||
endSeq,
|
||||
reset,
|
||||
epoch,
|
||||
endCursor,
|
||||
isInitializing,
|
||||
hasActiveInitDeferred,
|
||||
}: {
|
||||
direction: TimelineDirection;
|
||||
endSeq: number | null;
|
||||
reset: boolean;
|
||||
epoch: string;
|
||||
endCursor: BootstrapTailCursor;
|
||||
isInitializing: boolean;
|
||||
hasActiveInitDeferred: boolean;
|
||||
}): {
|
||||
replace: boolean;
|
||||
catchUpCursor: { endSeq: number } | null;
|
||||
catchUpCursor: { epoch: string; endSeq: number } | null;
|
||||
} {
|
||||
if (reset) {
|
||||
return { replace: true, catchUpCursor: null };
|
||||
}
|
||||
|
||||
const isBootstrapTailInit = direction === "tail" && isInitializing && hasActiveInitDeferred;
|
||||
if (!isBootstrapTailInit) {
|
||||
return { replace: false, catchUpCursor: null };
|
||||
@@ -57,7 +69,7 @@ export function deriveBootstrapTailTimelinePolicy({
|
||||
|
||||
return {
|
||||
replace: true,
|
||||
catchUpCursor: typeof endSeq === "number" ? { endSeq } : null,
|
||||
catchUpCursor: endCursor ? { epoch, endSeq: endCursor.seq } : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,14 +78,19 @@ export function shouldResolveTimelineInit({
|
||||
isInitializing,
|
||||
initRequestDirection,
|
||||
responseDirection,
|
||||
reset,
|
||||
}: {
|
||||
hasActiveInitDeferred: boolean;
|
||||
isInitializing: boolean;
|
||||
initRequestDirection: InitRequestDirection;
|
||||
responseDirection: TimelineDirection;
|
||||
reset: boolean;
|
||||
}): boolean {
|
||||
if (!hasActiveInitDeferred || !isInitializing) {
|
||||
return false;
|
||||
}
|
||||
if (reset) {
|
||||
return true;
|
||||
}
|
||||
return responseDirection === initRequestDirection;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ describe("classifySessionTimelineSeq", () => {
|
||||
it("accepts contiguous forward seq", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: { endSeq: 4 },
|
||||
cursor: { epoch: "epoch-1", endSeq: 4 },
|
||||
epoch: "epoch-1",
|
||||
seq: 5,
|
||||
}),
|
||||
).toBe("accept");
|
||||
@@ -14,7 +15,8 @@ describe("classifySessionTimelineSeq", () => {
|
||||
it("drops stale seq older than the current end", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: { endSeq: 8 },
|
||||
cursor: { epoch: "epoch-1", endSeq: 8 },
|
||||
epoch: "epoch-1",
|
||||
seq: 7,
|
||||
}),
|
||||
).toBe("drop_stale");
|
||||
@@ -23,16 +25,28 @@ describe("classifySessionTimelineSeq", () => {
|
||||
it("drops duplicate replay seq equal to the current end", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: { endSeq: 8 },
|
||||
cursor: { epoch: "epoch-1", endSeq: 8 },
|
||||
epoch: "epoch-1",
|
||||
seq: 8,
|
||||
}),
|
||||
).toBe("drop_stale");
|
||||
});
|
||||
|
||||
it("drops epoch mismatch", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: { epoch: "epoch-1", endSeq: 4 },
|
||||
epoch: "epoch-2",
|
||||
seq: 5,
|
||||
}),
|
||||
).toBe("drop_epoch");
|
||||
});
|
||||
|
||||
it("initializes when cursor is null", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: null,
|
||||
epoch: "epoch-1",
|
||||
seq: 1,
|
||||
}),
|
||||
).toBe("init");
|
||||
@@ -41,7 +55,8 @@ describe("classifySessionTimelineSeq", () => {
|
||||
it("classifies forward gaps", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: { endSeq: 4 },
|
||||
cursor: { epoch: "epoch-1", endSeq: 4 },
|
||||
epoch: "epoch-1",
|
||||
seq: 9,
|
||||
}),
|
||||
).toBe("gap");
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
export type SessionTimelineSeqCursor =
|
||||
| {
|
||||
epoch: string;
|
||||
endSeq: number;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
export type SessionTimelineSeqDecision = "accept" | "drop_stale" | "gap" | "init";
|
||||
export type SessionTimelineSeqDecision = "accept" | "drop_stale" | "drop_epoch" | "gap" | "init";
|
||||
|
||||
export function classifySessionTimelineSeq({
|
||||
cursor,
|
||||
epoch,
|
||||
seq,
|
||||
}: {
|
||||
cursor: SessionTimelineSeqCursor;
|
||||
epoch: string;
|
||||
seq: number;
|
||||
}): SessionTimelineSeqDecision {
|
||||
if (!cursor) {
|
||||
return "init";
|
||||
}
|
||||
if (cursor.epoch !== epoch) {
|
||||
return "drop_epoch";
|
||||
}
|
||||
if (seq <= cursor.endSeq) {
|
||||
return "drop_stale";
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ import { describe, expect, it } from "vitest";
|
||||
import { __private__ } from "./use-agent-initialization";
|
||||
|
||||
describe("useAgentInitialization timeline request policy", () => {
|
||||
it("uses committed tail bootstrap when history has not synced yet", () => {
|
||||
it("uses canonical tail bootstrap when history has not synced yet", () => {
|
||||
expect(
|
||||
__private__.deriveInitialTimelineRequest({
|
||||
cursor: {
|
||||
epoch: "epoch-1",
|
||||
seq: 42,
|
||||
},
|
||||
hasAuthoritativeHistory: false,
|
||||
@@ -14,10 +15,11 @@ describe("useAgentInitialization timeline request policy", () => {
|
||||
).toEqual({
|
||||
direction: "tail",
|
||||
limit: 200,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses committed tail bootstrap when cursor is missing", () => {
|
||||
it("uses canonical tail bootstrap when cursor is missing", () => {
|
||||
expect(
|
||||
__private__.deriveInitialTimelineRequest({
|
||||
cursor: null,
|
||||
@@ -27,13 +29,15 @@ describe("useAgentInitialization timeline request policy", () => {
|
||||
).toEqual({
|
||||
direction: "tail",
|
||||
limit: 200,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses committed catch-up after the current cursor once history is synced", () => {
|
||||
it("uses canonical catch-up after the current cursor once history is synced", () => {
|
||||
expect(
|
||||
__private__.deriveInitialTimelineRequest({
|
||||
cursor: {
|
||||
epoch: "epoch-1",
|
||||
seq: 42,
|
||||
},
|
||||
hasAuthoritativeHistory: true,
|
||||
@@ -41,8 +45,9 @@ describe("useAgentInitialization timeline request policy", () => {
|
||||
}),
|
||||
).toEqual({
|
||||
direction: "after",
|
||||
cursor: { seq: 42 },
|
||||
cursor: { epoch: "epoch-1", seq: 42 },
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,6 +61,7 @@ describe("useAgentInitialization timeline request policy", () => {
|
||||
).toEqual({
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export function useAgentInitialization({
|
||||
const hasAuthoritativeHistory =
|
||||
session?.agentAuthoritativeHistoryApplied.get(agentId) === true;
|
||||
const timelineRequest = deriveInitialTimelineRequest({
|
||||
cursor: cursor ? { seq: cursor.endSeq } : null,
|
||||
cursor: cursor ? { epoch: cursor.epoch, seq: cursor.endSeq } : null,
|
||||
hasAuthoritativeHistory,
|
||||
initialTimelineLimit,
|
||||
});
|
||||
@@ -107,6 +107,7 @@ export function useAgentInitialization({
|
||||
await client.fetchAgentTimeline(agentId, {
|
||||
direction: "tail",
|
||||
limit: initialTimelineLimit,
|
||||
projection: "canonical",
|
||||
});
|
||||
} catch (error) {
|
||||
setAgentInitializing(agentId, false);
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => {
|
||||
const storage = new Map<string, string>();
|
||||
return {
|
||||
default: {
|
||||
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
|
||||
setItem: vi.fn(async (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
}),
|
||||
removeItem: vi.fn(async (key: string) => {
|
||||
storage.delete(key);
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
||||
import {
|
||||
__providerRecencyStoreTestUtils,
|
||||
sortProvidersByRecency,
|
||||
useProviderRecencyStore,
|
||||
} from "./provider-recency-store";
|
||||
|
||||
describe("provider-recency-store", () => {
|
||||
beforeEach(() => {
|
||||
useProviderRecencyStore.setState({
|
||||
recentProviderIds: [],
|
||||
recordUsage: useProviderRecencyStore.getState().recordUsage,
|
||||
});
|
||||
});
|
||||
|
||||
it("sorts used providers first and keeps unused providers in default order", () => {
|
||||
const sorted = sortProvidersByRecency(AGENT_PROVIDER_DEFINITIONS, ["codex"]);
|
||||
|
||||
expect(sorted.map((provider) => provider.id)).toEqual([
|
||||
"codex",
|
||||
...AGENT_PROVIDER_DEFINITIONS.filter((provider) => provider.id !== "codex").map(
|
||||
(provider) => provider.id,
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
it("moves the latest provider to the front without duplicating prior entries", () => {
|
||||
useProviderRecencyStore.getState().recordUsage("codex");
|
||||
useProviderRecencyStore.getState().recordUsage("opencode");
|
||||
useProviderRecencyStore.getState().recordUsage("codex");
|
||||
|
||||
expect(useProviderRecencyStore.getState().recentProviderIds).toEqual(["codex", "opencode"]);
|
||||
});
|
||||
|
||||
it("filters invalid and duplicate providers during migration", () => {
|
||||
expect(
|
||||
__providerRecencyStoreTestUtils.migratePersistedState({
|
||||
recentProviderIds: ["codex", "invalid", "codex", "claude"],
|
||||
}),
|
||||
).toEqual({
|
||||
recentProviderIds: ["codex", "claude"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,131 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
isValidAgentProvider,
|
||||
type AgentProviderDefinition,
|
||||
} from "@server/server/agent/provider-manifest";
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
|
||||
const PROVIDER_RECENCY_STORE_VERSION = 1;
|
||||
|
||||
interface ProviderRecencyStoreState {
|
||||
recentProviderIds: AgentProvider[];
|
||||
recordUsage: (providerId: AgentProvider) => void;
|
||||
}
|
||||
|
||||
function sanitizeRecentProviderIds(providerIds: readonly string[] | undefined): AgentProvider[] {
|
||||
if (!providerIds || providerIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set<AgentProvider>();
|
||||
const sanitized: AgentProvider[] = [];
|
||||
for (const providerId of providerIds) {
|
||||
if (!isValidAgentProvider(providerId)) {
|
||||
continue;
|
||||
}
|
||||
if (seen.has(providerId)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(providerId);
|
||||
sanitized.push(providerId);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function sortProvidersByRecency<T extends { id: string }>(
|
||||
providers: readonly T[],
|
||||
recentProviderIds: readonly string[],
|
||||
): T[] {
|
||||
if (providers.length <= 1) {
|
||||
return [...providers];
|
||||
}
|
||||
|
||||
const recentRank = new Map<string, number>();
|
||||
for (const providerId of recentProviderIds) {
|
||||
if (recentRank.has(providerId)) {
|
||||
continue;
|
||||
}
|
||||
recentRank.set(providerId, recentRank.size);
|
||||
}
|
||||
|
||||
return providers
|
||||
.map((provider, defaultIndex) => ({
|
||||
provider,
|
||||
defaultIndex,
|
||||
recentIndex: recentRank.get(provider.id) ?? Number.POSITIVE_INFINITY,
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
if (left.recentIndex !== right.recentIndex) {
|
||||
return left.recentIndex - right.recentIndex;
|
||||
}
|
||||
return left.defaultIndex - right.defaultIndex;
|
||||
})
|
||||
.map((entry) => entry.provider);
|
||||
}
|
||||
|
||||
function migratePersistedState(
|
||||
state: unknown,
|
||||
): Pick<ProviderRecencyStoreState, "recentProviderIds"> {
|
||||
const record = state as { recentProviderIds?: string[] } | null | undefined;
|
||||
return {
|
||||
recentProviderIds: sanitizeRecentProviderIds(record?.recentProviderIds),
|
||||
};
|
||||
}
|
||||
|
||||
export const useProviderRecencyStore = create<ProviderRecencyStoreState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
recentProviderIds: [],
|
||||
recordUsage: (providerId) => {
|
||||
if (!isValidAgentProvider(providerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
recentProviderIds: [
|
||||
providerId,
|
||||
...state.recentProviderIds.filter((id) => id !== providerId),
|
||||
],
|
||||
}));
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "agent-provider-recency",
|
||||
version: PROVIDER_RECENCY_STORE_VERSION,
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
partialize: (state) => ({
|
||||
recentProviderIds: state.recentProviderIds,
|
||||
}),
|
||||
migrate: (persistedState) => migratePersistedState(persistedState),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export function useProviderRecency(
|
||||
availableProviders: readonly AgentProviderDefinition[] = AGENT_PROVIDER_DEFINITIONS,
|
||||
): {
|
||||
providers: AgentProviderDefinition[];
|
||||
recordUsage: (providerId: AgentProvider) => void;
|
||||
} {
|
||||
const recentProviderIds = useProviderRecencyStore((state) => state.recentProviderIds);
|
||||
const recordUsage = useProviderRecencyStore((state) => state.recordUsage);
|
||||
|
||||
const providers = useMemo(
|
||||
() => sortProvidersByRecency(availableProviders, recentProviderIds),
|
||||
[availableProviders, recentProviderIds],
|
||||
);
|
||||
|
||||
return {
|
||||
providers,
|
||||
recordUsage,
|
||||
};
|
||||
}
|
||||
|
||||
export const __providerRecencyStoreTestUtils = {
|
||||
migratePersistedState,
|
||||
sanitizeRecentProviderIds,
|
||||
};
|
||||
@@ -212,6 +212,7 @@ export type DaemonServerInfo = {
|
||||
};
|
||||
|
||||
export interface AgentTimelineCursorState {
|
||||
epoch: string;
|
||||
startSeq: number;
|
||||
endSeq: number;
|
||||
}
|
||||
|
||||
@@ -729,7 +729,7 @@ function finalizeHeadItems(head: StreamItem[]): StreamItem[] {
|
||||
/**
|
||||
* Flush head items to tail, avoiding duplicates.
|
||||
*/
|
||||
function flushHeadToTail(tail: StreamItem[], head: StreamItem[]): StreamItem[] {
|
||||
export function flushHeadToTail(tail: StreamItem[], head: StreamItem[]): StreamItem[] {
|
||||
if (head.length === 0) {
|
||||
return tail;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export async function fetchProjectedTimelineItems(
|
||||
const timeline = await input.client.fetchAgentTimeline(input.agentId, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "projected",
|
||||
});
|
||||
return timeline.entries.map((entry) => entry.item);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user