Use daemon timestamps for turn timing (#1033)

This commit is contained in:
Mohamed Boudra
2026-05-15 11:09:13 +08:00
committed by GitHub
parent 1962225647
commit a72c7f6c4b
22 changed files with 1284 additions and 193 deletions
@@ -5004,6 +5004,69 @@ test("hydrateTimeline keeps provider user_message items when no canonical user h
expect(assistantMessages).toHaveLength(2);
});
test("hydrateTimeline preserves provider replay timestamps and marks missing ones untrusted", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-timestamps-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
class TimestampedHistorySession extends TestAgentSession {
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
yield {
type: "timeline",
provider: this.provider,
timestamp: "2026-05-01T10:00:00.000Z",
item: { type: "user_message", text: "hello", messageId: "msg_history_1" },
};
yield {
type: "timeline",
provider: this.provider,
item: { type: "assistant_message", text: "no original timestamp" },
};
}
}
class TimestampedHistoryClient implements AgentClient {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
async isAvailable(): Promise<boolean> {
return true;
}
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new TimestampedHistorySession(config);
}
async resumeSession(): Promise<AgentSession> {
throw new Error("Not used in this test");
}
}
const manager = new AgentManager({
clients: {
codex: new TimestampedHistoryClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000204",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
await manager.hydrateTimelineFromProvider(snapshot.id);
const timeline = manager.fetchTimeline(snapshot.id, { direction: "tail", limit: 0 }).rows;
expect(timeline).toHaveLength(2);
expect(timeline[0]).toMatchObject({
timestamp: "2026-05-01T10:00:00.000Z",
item: { type: "user_message", text: "hello", messageId: "msg_history_1" },
});
expect(timeline[1]?.timestamp).toEqual(expect.any(String));
});
test("hydrateTimeline suppresses only matching canonical user_message messageId", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-partial-dedup-"));
const storagePath = join(workdir, "agents");
@@ -122,6 +122,7 @@ export type AgentManagerEvent =
event: AgentStreamEvent;
seq?: number;
epoch?: string;
timestamp?: string;
};
export type AgentSubscriber = (event: AgentManagerEvent) => void;
@@ -1391,10 +1392,11 @@ export class AgentManager {
this.dispatchStream(agent.id, event, {
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
});
return;
}
this.dispatchStream(agent.id, event);
this.dispatchStream(agent.id, event, { timestamp: new Date().toISOString() });
};
void (async () => {
try {
@@ -1436,6 +1438,7 @@ export class AgentManager {
{
seq: row.seq,
epoch: this.timelineStore.getEpoch(agentId),
timestamp: row.timestamp,
},
);
if (options?.emitState !== false) {
@@ -1457,6 +1460,7 @@ export class AgentManager {
{
seq: row.seq,
epoch: this.timelineStore.getEpoch(agentId),
timestamp: row.timestamp,
},
);
await this.persistSnapshot(agent);
@@ -1786,7 +1790,7 @@ export class AgentManager {
const bufferedResolution = agent.bufferedPermissionResolutions.get(requestId);
if (bufferedResolution) {
agent.bufferedPermissionResolutions.delete(requestId);
this.dispatchStream(agent.id, bufferedResolution);
this.dispatchStream(agent.id, bufferedResolution, { timestamp: new Date().toISOString() });
}
return result;
@@ -1876,12 +1880,16 @@ export class AgentManager {
// Clear any pending permissions that weren't cleaned up by handleStreamEvent.
if (agent.pendingPermissions.size > 0) {
for (const [requestId] of agent.pendingPermissions) {
this.dispatchStream(agent.id, {
type: "permission_resolved",
provider: agent.provider,
requestId,
resolution: { behavior: "deny", message: "Interrupted" },
});
this.dispatchStream(
agent.id,
{
type: "permission_resolved",
provider: agent.provider,
requestId,
resolution: { behavior: "deny", message: "Interrupted" },
},
{ timestamp: new Date().toISOString() },
);
}
agent.pendingPermissions.clear();
this.touchUpdatedAt(agent);
@@ -2587,7 +2595,11 @@ export class AgentManager {
if (isDuplicateLegacyUserMessage(event.item, canonicalUserMessagesById)) {
continue;
}
this.recordTimeline(agent.id, event.item);
this.recordTimeline(
agent.id,
event.item,
event.timestamp ? { timestamp: event.timestamp } : undefined,
);
}
} catch {
// ignore history failures
@@ -2663,7 +2675,7 @@ export class AgentManager {
}
if (!options?.fromHistory && flags.shouldDispatchEvent) {
this.dispatchStream(agent.id, event);
this.dispatchStream(agent.id, event, { timestamp: new Date().toISOString() });
}
this.traceHandleStreamEventEnd(agent, event, eventTurnId, flags);
@@ -2862,7 +2874,11 @@ export class AgentManager {
}
if (options?.fromHistory) {
this.recordTimeline(agent.id, event.item);
this.recordTimeline(
agent.id,
event.item,
event.timestamp ? { timestamp: event.timestamp } : undefined,
);
flags.shouldDispatchEvent = false;
flags.shouldNotifyWaiters = false;
return;
@@ -3068,6 +3084,7 @@ export class AgentManager {
this.dispatchStream(agentId, event, {
seq: row.seq,
epoch: this.timelineStore.getEpoch(agentId),
timestamp: row.timestamp,
});
return event;
}
@@ -3108,6 +3125,7 @@ export class AgentManager {
{
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
},
);
}
@@ -3128,8 +3146,12 @@ export class AgentManager {
return parts.join("\n\n");
}
private recordTimeline(agentId: string, item: AgentTimelineItem): AgentTimelineRow {
const row = this.timelineStore.append(agentId, item);
private recordTimeline(
agentId: string,
item: AgentTimelineItem,
options?: { timestamp?: string },
): AgentTimelineRow {
const row = this.timelineStore.append(agentId, item, options);
this.enqueueDurableTimelineAppend(agentId, row);
return row;
}
@@ -3282,7 +3304,7 @@ export class AgentManager {
private dispatchStream(
agentId: string,
event: AgentStreamEvent,
metadata?: { seq?: number; epoch?: string },
metadata?: { seq?: number; epoch?: string; timestamp?: string },
): void {
const agent = this.agents.get(agentId);
this.logger.trace(
@@ -346,7 +346,13 @@ export type AgentStreamEvent =
turnId?: string;
}
| { type: "turn_canceled"; provider: AgentProvider; reason: string; turnId?: string }
| { type: "timeline"; item: AgentTimelineItem; provider: AgentProvider; turnId?: string }
| {
type: "timeline";
item: AgentTimelineItem;
provider: AgentProvider;
turnId?: string;
timestamp?: string;
}
| {
type: "permission_requested";
provider: AgentProvider;
@@ -0,0 +1,23 @@
import { describe, expect, test } from "vitest";
import { normalizeProviderReplayTimestamp } from "./provider-history-timestamps.js";
describe("normalizeProviderReplayTimestamp", () => {
test("preserves valid string timestamps after trimming", () => {
expect(normalizeProviderReplayTimestamp(" 2026-05-01T10:00:00.000Z ")).toBe(
"2026-05-01T10:00:00.000Z",
);
});
test("converts numeric second and millisecond timestamps to ISO", () => {
expect(normalizeProviderReplayTimestamp(1_778_762_475)).toBe("2026-05-14T12:41:15.000Z");
expect(normalizeProviderReplayTimestamp(1_778_762_475_873)).toBe("2026-05-14T12:41:15.873Z");
});
test("returns null for missing or invalid timestamps", () => {
expect(normalizeProviderReplayTimestamp(undefined)).toBeNull();
expect(normalizeProviderReplayTimestamp("not a timestamp")).toBeNull();
expect(normalizeProviderReplayTimestamp(Number.NaN)).toBeNull();
expect(normalizeProviderReplayTimestamp(Number.POSITIVE_INFINITY)).toBeNull();
});
});
@@ -0,0 +1,17 @@
export function normalizeProviderReplayTimestamp(value: unknown): string | null {
if (typeof value === "string") {
const timestamp = value.trim();
if (!timestamp || Number.isNaN(Date.parse(timestamp))) {
return null;
}
return timestamp;
}
if (typeof value !== "number" || !Number.isFinite(value)) {
return null;
}
const timestampMs = value > 1_000_000_000_000 ? value : value * 1000;
const date = new Date(timestampMs);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
@@ -41,6 +41,7 @@ import {
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "../provider-runner.js";
import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js";
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
import {
getAgentStreamEventTurnId,
@@ -185,6 +186,11 @@ interface AsyncMessageInput<T> {
iterable: AsyncIterable<T>;
}
interface PersistedTimelineEntry {
item: AgentTimelineItem;
timestamp?: string;
}
const CLAUDE_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
@@ -1548,7 +1554,7 @@ class ClaudeAgentSession implements AgentSession {
private readonly sidechainTracker = new ClaudeSidechainTracker({
getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null,
});
private persistedHistory: AgentTimelineItem[] = [];
private persistedHistory: PersistedTimelineEntry[] = [];
private historyPending = false;
private turnState: TurnState = "idle";
private nextTurnOrdinal = 1;
@@ -1756,8 +1762,13 @@ class ClaudeAgentSession implements AgentSession {
const history = this.persistedHistory;
this.persistedHistory = [];
this.historyPending = false;
for (const item of history) {
yield { type: "timeline", item, provider: "claude" };
for (const entry of history) {
yield {
type: "timeline",
item: entry.item,
provider: "claude",
timestamp: entry.timestamp,
};
}
}
@@ -2142,9 +2153,9 @@ class ClaudeAgentSession implements AgentSession {
pushUnique(historyIds[idx]);
}
for (let idx = this.persistedHistory.length - 1; idx >= 0; idx -= 1) {
const item = this.persistedHistory[idx];
if (item?.type === "user_message") {
pushUnique(item.messageId);
const entry = this.persistedHistory[idx];
if (entry?.item.type === "user_message") {
pushUnique(entry.item.messageId);
}
}
for (let idx = this.userMessageIds.length - 1; idx >= 0; idx -= 1) {
@@ -3624,7 +3635,7 @@ class ClaudeAgentSession implements AgentSession {
return;
}
const timeline: AgentTimelineItem[] = [];
const timeline: PersistedTimelineEntry[] = [];
for (const line of content.split(/\r?\n/)) {
this.ingestPersistedHistoryLine(line, timeline);
}
@@ -3635,7 +3646,7 @@ class ClaudeAgentSession implements AgentSession {
}
}
private ingestPersistedHistoryLine(line: string, timeline: AgentTimelineItem[]): void {
private ingestPersistedHistoryLine(line: string, timeline: PersistedTimelineEntry[]): void {
const trimmed = line.trim();
if (!trimmed) {
return;
@@ -3660,9 +3671,15 @@ class ClaudeAgentSession implements AgentSession {
this.rememberUserMessageId(entry.uuid);
}
const historyTimestamp = normalizeProviderReplayTimestamp(entry.timestamp);
const items = this.convertHistoryEntry(entry);
if (items.length > 0) {
timeline.push(...items);
timeline.push(
...items.map((item) => ({
item,
timestamp: historyTimestamp ?? undefined,
})),
);
}
}
@@ -1370,10 +1370,12 @@ describe("Codex app-server provider", () => {
type: "agentMessage",
id: "message-history",
text: "History loaded.",
timestamp: "2026-05-01T10:00:00.000Z",
},
{
type: "contextCompaction",
id: "compact-history",
createdAt: "2026-05-01T10:00:01.000Z",
},
],
},
@@ -1397,6 +1399,7 @@ describe("Codex app-server provider", () => {
{
type: "timeline",
provider: "codex",
timestamp: "2026-05-01T10:00:00.000Z",
item: {
type: "assistant_message",
text: "History loaded.",
@@ -1406,6 +1409,7 @@ describe("Codex app-server provider", () => {
{
type: "timeline",
provider: "codex",
timestamp: "2026-05-01T10:00:01.000Z",
item: {
type: "compaction",
status: "completed",
@@ -64,6 +64,7 @@ import {
renderProviderImageOutputAsAssistantMarkdown,
type ProviderImageOutput,
} from "./provider-image-output.js";
import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js";
import {
formatDiagnosticStatus,
formatProviderDiagnostic,
@@ -358,6 +359,11 @@ interface CodexConfiguredDefaults {
thinkingOptionId?: string;
}
interface PersistedTimelineEntry {
item: AgentTimelineItem;
timestamp?: string;
}
function mergeCodexConfiguredDefaults(
primary: CodexConfiguredDefaults,
fallback: CodexConfiguredDefaults,
@@ -1444,6 +1450,18 @@ function firstStringField(
return null;
}
function readCodexHistoryTimestamp(item: unknown): string | null {
const record = toObjectRecord(item);
if (!record) {
return null;
}
return (
normalizeProviderReplayTimestamp(record.timestamp) ??
normalizeProviderReplayTimestamp(record.createdAt) ??
normalizeProviderReplayTimestamp(record.created_at)
);
}
function codexImageOutputFromResult(result: unknown): ProviderImageOutput | null {
if (typeof result === "string") {
const trimmed = result.trim();
@@ -1591,14 +1609,18 @@ async function loadCodexThreadHistoryTimeline(params: {
threadId: string;
cwd: string | null;
requestThread: CodexThreadReadRequest;
}): Promise<AgentTimelineItem[]> {
}): Promise<PersistedTimelineEntry[]> {
const response = await requestCodexThreadHistory(params.requestThread, params.threadId);
const timeline: AgentTimelineItem[] = [];
const timeline: PersistedTimelineEntry[] = [];
for (const turn of response.thread.turns) {
for (const item of turn.items) {
const timelineItem = threadItemToTimeline(item, { cwd: params.cwd });
if (timelineItem) {
timeline.push(timelineItem);
const timestamp = readCodexHistoryTimestamp(item);
timeline.push({
item: timelineItem,
timestamp: timestamp ?? undefined,
});
}
}
}
@@ -2678,7 +2700,7 @@ class CodexAppServerAgentSession implements AgentSession {
private serviceTier: "fast" | null = null;
private planModeEnabled = false;
private historyPending = false;
private persistedHistory: AgentTimelineItem[] = [];
private persistedHistory: PersistedTimelineEntry[] = [];
private pendingPermissions = new Map<string, AgentPermissionRequest>();
private pendingPermissionHandlers = new Map<
string,
@@ -3334,8 +3356,13 @@ class CodexAppServerAgentSession implements AgentSession {
const history = this.persistedHistory;
this.persistedHistory = [];
this.historyPending = false;
for (const item of history) {
yield { type: "timeline", provider: CODEX_PROVIDER, item };
for (const entry of history) {
yield {
type: "timeline",
provider: CODEX_PROVIDER,
item: entry.item,
timestamp: entry.timestamp,
};
}
}
@@ -5151,7 +5178,7 @@ export class CodexAppServerAgentClient implements AgentClient {
const threadId = typeof thread.id === "string" ? thread.id : "";
const cwd = typeof thread.cwd === "string" ? thread.cwd : process.cwd();
const title = typeof thread.preview === "string" ? thread.preview : null;
let timeline: AgentTimelineItem[] = [];
let timeline: PersistedTimelineEntry[] = [];
try {
timeline = await loadCodexThreadHistoryTimeline({
@@ -5186,7 +5213,7 @@ export class CodexAppServerAgentClient implements AgentClient {
threadId,
},
},
timeline,
timeline: timeline.map((entry) => entry.item),
};
}),
);
@@ -833,6 +833,144 @@ describe("OpenCode adapter startTurn error handling", () => {
expect(fakeClient.session.delete).not.toHaveBeenCalled();
});
test("streamHistory preserves OpenCode replay timestamps from message and part times", async () => {
const fakeClient = {
session: {
messages: vi.fn().mockResolvedValue({
data: [
{
info: {
id: "msg_user",
sessionID: "ses_unit_test",
role: "user",
time: { created: 1778762475873 },
},
parts: [
{
id: "prt_user",
sessionID: "ses_unit_test",
messageID: "msg_user",
type: "text",
text: "Reply with exactly: probe ok",
},
],
},
{
info: {
id: "msg_assistant",
sessionID: "ses_unit_test",
role: "assistant",
time: { created: 1778762475884, completed: 1778762489358 },
},
parts: [
{
id: "prt_reasoning",
sessionID: "ses_unit_test",
messageID: "msg_assistant",
type: "reasoning",
text: "thinking",
time: { start: 1778762482953, end: 1778762483610 },
},
{
id: "prt_text",
sessionID: "ses_unit_test",
messageID: "msg_assistant",
type: "text",
text: "probe ok",
time: { start: 1778762483612, end: 1778762489351 },
},
],
},
],
error: undefined,
}),
},
} as never;
const session = new __openCodeInternals.OpenCodeAgentSession(
{ provider: "opencode", cwd: "/tmp/test" },
fakeClient,
"ses_unit_test",
createTestLogger(),
"/tmp/opencode-storage",
);
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
history.push(event);
}
expect(history).toEqual([
{
type: "timeline",
provider: "opencode",
timestamp: "2026-05-14T12:41:15.873Z",
item: { type: "user_message", text: "Reply with exactly: probe ok" },
},
{
type: "timeline",
provider: "opencode",
timestamp: "2026-05-14T12:41:22.953Z",
item: { type: "reasoning", text: "thinking" },
},
{
type: "timeline",
provider: "opencode",
timestamp: "2026-05-14T12:41:23.612Z",
item: { type: "assistant_message", text: "probe ok" },
},
]);
});
test("streamHistory omits replay timestamps when OpenCode omits times", async () => {
const fakeClient = {
session: {
messages: vi.fn().mockResolvedValue({
data: [
{
info: {
id: "msg_assistant",
sessionID: "ses_unit_test",
role: "assistant",
},
parts: [
{
id: "prt_text",
sessionID: "ses_unit_test",
messageID: "msg_assistant",
type: "text",
text: "no clocks here",
},
],
},
],
error: undefined,
}),
},
} as never;
const session = new __openCodeInternals.OpenCodeAgentSession(
{ provider: "opencode", cwd: "/tmp/test" },
fakeClient,
"ses_unit_test",
createTestLogger(),
"/tmp/opencode-storage",
);
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
history.push(event);
}
expect(history).toEqual([
{
type: "timeline",
provider: "opencode",
item: { type: "assistant_message", text: "no clocks here" },
},
]);
});
test("emits turn_failed when client.session.promptAsync throws synchronously", async () => {
// Yield the server-connected event, then park forever. The adapter waits
// for that first event before sending the prompt.
@@ -62,6 +62,7 @@ import {
type OpenCodeRuntime,
type OpenCodeServerAcquisition,
} from "./opencode/runtime.js";
import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js";
const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
@@ -899,6 +900,71 @@ function getOpenCodePartTimestamp(part: OpenCodeStoredPart): number {
return part.time?.start ?? part.time?.end ?? 0;
}
function resolveOpenCodeReplayTimestamp(params: {
message: { time?: { created?: number; completed?: number } | undefined };
part?: unknown;
}): string | null {
const timedPart = params.part as
| { time?: { start?: number; end?: number } | undefined }
| undefined;
const partTimestamp =
timedPart?.time?.start ??
timedPart?.time?.end ??
params.message.time?.created ??
params.message.time?.completed;
return normalizeProviderReplayTimestamp(partTimestamp);
}
function buildOpenCodeReplayTimelineEvent(params: {
item: AgentTimelineItem;
message: { time?: { created?: number; completed?: number } | undefined };
part?: unknown;
}): Extract<AgentStreamEvent, { type: "timeline" }> {
const timestamp = resolveOpenCodeReplayTimestamp({
message: params.message,
part: params.part,
});
return {
type: "timeline",
provider: "opencode",
item: params.item,
...(timestamp ? { timestamp } : {}),
};
}
function buildOpenCodeReplayPartTimelineEvent(params: {
part: OpenCodePart;
message: { structured?: unknown; time?: { created?: number; completed?: number } | undefined };
}): Extract<AgentStreamEvent, { type: "timeline" }> | null {
const { part, message } = params;
if (part.type === "text" && part.text) {
return buildOpenCodeReplayTimelineEvent({
item: { type: "assistant_message", text: part.text },
message,
part,
});
}
if (part.type === "reasoning" && part.text) {
return buildOpenCodeReplayTimelineEvent({
item: { type: "reasoning", text: part.text },
message,
part,
});
}
if (part.type !== "tool") {
return null;
}
const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part);
if (!parsedToolPart.success || !parsedToolPart.data) {
return null;
}
return buildOpenCodeReplayTimelineEvent({
item: parsedToolPart.data,
message,
part,
});
}
export const __openCodeInternals = {
buildOpenCodePromptParts,
buildOpenCodeModelContextWindowLookup,
@@ -2842,53 +2908,30 @@ class OpenCodeAgentSession implements AgentSession {
.join("");
if (text) {
yield {
type: "timeline",
provider: "opencode",
yield buildOpenCodeReplayTimelineEvent({
item: { type: "user_message", text },
};
message: info,
});
}
} else {
let emittedAssistantText = false;
for (const part of parts) {
if (part.type === "text" && part.text) {
emittedAssistantText = true;
yield {
type: "timeline",
provider: "opencode",
item: { type: "assistant_message", text: part.text },
};
continue;
}
if (part.type === "reasoning" && part.text) {
yield {
type: "timeline",
provider: "opencode",
item: { type: "reasoning", text: part.text },
};
continue;
}
if (part.type !== "tool") {
continue;
}
const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part);
if (parsedToolPart.success && parsedToolPart.data) {
yield {
type: "timeline",
provider: "opencode",
item: parsedToolPart.data,
};
const event = buildOpenCodeReplayPartTimelineEvent({ part, message: info });
if (event) {
yield event;
}
}
if (!emittedAssistantText) {
const text = stringifyStructuredAssistantMessage(info.structured);
if (text) {
yield {
type: "timeline",
provider: "opencode",
yield buildOpenCodeReplayTimelineEvent({
item: { type: "assistant_message", text },
};
message: info,
});
}
}
}
@@ -142,6 +142,7 @@ function collapseToolLifecycle(entries: readonly WorkingEntry[]): WorkingEntry[]
output[existingIndex] = {
...existing,
item: mergedItem,
timestamp: entry.timestamp,
seqEnd: Math.max(existing.seqEnd, entry.seqEnd),
sourceSeqRanges: mergedRanges,
collapsed,
@@ -181,6 +182,7 @@ function mergeReasoningChunks(entries: readonly WorkingEntry[]): WorkingEntry[]
type: "reasoning",
text: `${previousReasoning.text}${entryReasoning.text}`,
},
timestamp: entry.timestamp,
seqEnd: entry.seqEnd,
sourceSeqRanges: mergeSeqRanges(previous.sourceSeqRanges, entry.sourceSeqRanges),
collapsed: Array.from(collapsedKinds),
@@ -231,6 +233,7 @@ function mergeAssistantChunks(entries: readonly WorkingEntry[]): WorkingEntry[]
text: `${previousAssistant.text}${entryAssistant.text}`,
...(previousAssistant.messageId ? { messageId: previousAssistant.messageId } : {}),
},
timestamp: entry.timestamp,
seqEnd: entry.seqEnd,
sourceSeqRanges: mergeSeqRanges(previous.sourceSeqRanges, entry.sourceSeqRanges),
collapsed: Array.from(collapsedKinds),
+32 -23
View File
@@ -96,6 +96,7 @@ import type {
import { AgentManager } from "./agent/agent-manager.js";
import { ProviderSnapshotManager, resolveSnapshotCwd } from "./agent/provider-snapshot-manager.js";
import type {
AgentManagerEvent,
AgentTimelineCursor,
AgentTimelineFetchDirection,
ManagedAgent,
@@ -1277,20 +1278,8 @@ export class Session {
// Reduce bandwidth/CPU on mobile: only forward high-frequency agent stream events
// for the focused agent, with a short grace window while backgrounded.
// History catch-up is handled via pull-based `fetch_agent_timeline_request`.
const activity = this.clientActivity;
if (activity?.deviceType === "mobile") {
if (!activity.focusedAgentId) {
return;
}
if (activity.focusedAgentId !== event.agentId) {
return;
}
if (!activity.appVisible) {
const hiddenForMs = Date.now() - activity.appVisibilityChangedAt.getTime();
if (hiddenForMs >= this.MOBILE_BACKGROUND_STREAM_GRACE_MS) {
return;
}
}
if (this.shouldSkipAgentStreamForward(event.agentId)) {
return;
}
const serializedEvent = serializeAgentStreamEvent(event.event);
@@ -1309,17 +1298,9 @@ export class Session {
"agent.session.forward_stream",
);
const payload = {
agentId: event.agentId,
event: serializedEvent,
timestamp: new Date().toISOString(),
...(typeof event.seq === "number" ? { seq: event.seq } : {}),
...(typeof event.epoch === "string" ? { epoch: event.epoch } : {}),
} as const;
this.emit({
type: "agent_stream",
payload,
payload: this.buildAgentStreamPayload(event, serializedEvent),
});
if (event.event.type === "permission_requested") {
@@ -1347,6 +1328,34 @@ export class Session {
);
}
private shouldSkipAgentStreamForward(agentId: string): boolean {
const activity = this.clientActivity;
if (activity?.deviceType !== "mobile") {
return false;
}
if (!activity.focusedAgentId || activity.focusedAgentId !== agentId) {
return true;
}
if (activity.appVisible) {
return false;
}
const hiddenForMs = Date.now() - activity.appVisibilityChangedAt.getTime();
return hiddenForMs >= this.MOBILE_BACKGROUND_STREAM_GRACE_MS;
}
private buildAgentStreamPayload(
event: Extract<AgentManagerEvent, { type: "agent_stream" }>,
serializedEvent: Extract<SessionOutboundMessage, { type: "agent_stream" }>["payload"]["event"],
): Extract<SessionOutboundMessage, { type: "agent_stream" }>["payload"] {
return {
agentId: event.agentId,
event: serializedEvent,
timestamp: event.timestamp ?? new Date().toISOString(),
...(typeof event.seq === "number" ? { seq: event.seq } : {}),
...(typeof event.epoch === "string" ? { epoch: event.epoch } : {}),
};
}
private async buildAgentPayload(agent: ManagedAgent): Promise<AgentSnapshotPayload> {
const storedRecord = await this.agentStorage.get(agent.id);
const title = storedRecord?.title ?? storedRecord?.config?.title ?? null;