Use daemon timestamps for turn timing (#1033)
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user