From 160d861534edb9b5c52a2ff683a66f6e01818f75 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 15 May 2026 17:38:28 +0700 Subject: [PATCH] Fix agent turn timing persistence --- packages/app/e2e/agent-stream-ui.spec.ts | 20 ++ .../agent-stream-render-model.test.ts | 73 +++++++ .../components/agent-stream-render-model.ts | 37 ++++ .../components/agent-stream-turn-footer.tsx | 34 +++- .../app/src/components/agent-stream-view.tsx | 48 ++--- packages/app/src/components/message.tsx | 14 +- .../timeline/session-stream-reducers.test.ts | 7 +- packages/app/src/timeline/turn-time.test.ts | 87 ++++++++ packages/app/src/timeline/turn-time.ts | 185 ++++++------------ packages/app/src/types/stream.test.ts | 56 ++---- packages/app/src/types/stream.ts | 28 +-- .../providers/codex-app-server-agent.test.ts | 62 ++++++ .../agent/providers/codex-app-server-agent.ts | 25 ++- 13 files changed, 443 insertions(+), 233 deletions(-) create mode 100644 packages/app/src/timeline/turn-time.test.ts diff --git a/packages/app/e2e/agent-stream-ui.spec.ts b/packages/app/e2e/agent-stream-ui.spec.ts index 8fea036fd..8c51323cb 100644 --- a/packages/app/e2e/agent-stream-ui.spec.ts +++ b/packages/app/e2e/agent-stream-ui.spec.ts @@ -6,6 +6,7 @@ import { expectTurnCopyButton, expectScrollFollowsNewContent, } from "./helpers/agent-stream"; +import { clickNewChat } from "./helpers/launcher"; import { startRunningMockAgent } from "./helpers/composer"; test.describe("Agent stream UI", () => { @@ -42,4 +43,23 @@ test.describe("Agent stream UI", () => { await repo.cleanup(); } }); + + test("shows elapsed timer on first app-created running turn", async ({ page, withWorkspace }) => { + test.setTimeout(90_000); + const workspace = await withWorkspace({ prefix: "stream-first-app-turn-timer-" }); + await workspace.navigateTo(); + await clickNewChat(page); + await page.getByText("Model defaults are still loading").waitFor({ + state: "hidden", + timeout: 30_000, + }); + const prompt = "Stream briefly for first app-created turn timer test."; + const composer = page.getByRole("textbox", { name: "Message agent..." }).first(); + await composer.fill(prompt); + await page.getByRole("button", { name: "Send message" }).click(); + await page.getByText(prompt, { exact: true }).first().waitFor({ state: "visible" }); + await awaitAssistantMessage(page); + await expectInlineWorkingIndicator(page); + await page.getByTestId("turn-working-elapsed").waitFor({ state: "visible", timeout: 5_000 }); + }); }); diff --git a/packages/app/src/components/agent-stream-render-model.test.ts b/packages/app/src/components/agent-stream-render-model.test.ts index ca20bdd33..344e279be 100644 --- a/packages/app/src/components/agent-stream-render-model.test.ts +++ b/packages/app/src/components/agent-stream-render-model.test.ts @@ -35,6 +35,7 @@ describe("buildAgentStreamRenderModel", () => { const head = [assistantMessage("live-a", 121)]; const model = buildAgentStreamRenderModel({ + agentStatus: "running", tail, head, platform: "web", @@ -52,6 +53,7 @@ describe("buildAgentStreamRenderModel", () => { const head = [assistantMessage("live-a", 3)]; const model = buildAgentStreamRenderModel({ + agentStatus: "running", tail, head, platform: "web", @@ -69,12 +71,14 @@ describe("buildAgentStreamRenderModel", () => { const secondHead = [assistantMessage("live-b", 4)]; const first = buildAgentStreamRenderModel({ + agentStatus: "running", tail, head: firstHead, platform: "native", isMobileBreakpoint: false, }); const second = buildAgentStreamRenderModel({ + agentStatus: "running", tail, head: secondHead, platform: "native", @@ -85,4 +89,73 @@ describe("buildAgentStreamRenderModel", () => { expect(first.segments.historyMounted).toBe(second.segments.historyMounted); expect(second.segments.liveHead.map((item) => item.id)).toEqual(["live-b"]); }); + + it("derives running turn timing across committed history and live head", () => { + const tail = [userMessage("u1", 1)]; + const head = [assistantMessage("live-a", 4)]; + + const model = buildAgentStreamRenderModel({ + agentStatus: "running", + tail, + head, + platform: "web", + isMobileBreakpoint: false, + }); + + expect(model.turnTiming.runningStartedAt).toBe(tail[0]?.timestamp); + expect(model.turnTiming.byAssistantId.has("live-a")).toBe(false); + }); + + it("maps completed turn timing to assistant ids across committed history and live head", () => { + const tail = [userMessage("u1", 1)]; + const head = [assistantMessage("live-a", 4)]; + + const model = buildAgentStreamRenderModel({ + agentStatus: "idle", + tail, + head, + platform: "web", + isMobileBreakpoint: false, + }); + + expect(model.turnTiming.runningStartedAt).toBe(null); + expect(model.turnTiming.byAssistantId.get("live-a")).toEqual({ + startedAt: tail[0]?.timestamp, + completedAt: head[0]?.timestamp, + durationMs: 3000, + }); + }); + + it("derives the same timing for native inverted rendering", () => { + const tail = [userMessage("u1", 1), assistantMessage("a1", 4)]; + + const model = buildAgentStreamRenderModel({ + agentStatus: "idle", + tail, + head: [], + platform: "native", + isMobileBreakpoint: false, + }); + + expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]); + expect(model.turnTiming.byAssistantId.get("a1")).toEqual({ + startedAt: tail[0]?.timestamp, + completedAt: tail[1]?.timestamp, + durationMs: 3000, + }); + }); + + it("does not create completed timing for adjacent user messages", () => { + const tail = [userMessage("u1", 1), userMessage("u2", 4)]; + + const model = buildAgentStreamRenderModel({ + agentStatus: "idle", + tail, + head: [], + platform: "web", + isMobileBreakpoint: false, + }); + + expect(model.turnTiming.byAssistantId.size).toBe(0); + }); }); diff --git a/packages/app/src/components/agent-stream-render-model.ts b/packages/app/src/components/agent-stream-render-model.ts index 4ff510909..b5e836a67 100644 --- a/packages/app/src/components/agent-stream-render-model.ts +++ b/packages/app/src/components/agent-stream-render-model.ts @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { deriveStreamTurnTiming, type StreamTurnTiming } from "@/timeline/turn-time"; import type { StreamItem } from "@/types/stream"; import { findMountedWindowStart, @@ -32,11 +33,13 @@ export interface StreamRenderAuxiliary { export interface AgentStreamRenderModel { history: StreamItem[]; segments: StreamRenderSegments; + turnTiming: StreamTurnTiming; boundary: StreamHistoryBoundary; auxiliary: StreamRenderAuxiliary; } export interface BuildAgentStreamRenderModelInput { + agentStatus: string; tail: StreamItem[]; head: StreamItem[]; platform: "web" | "native"; @@ -55,6 +58,10 @@ const splitHistoryCache = new WeakMap< StreamItem[], Map> >(); +const turnTimingCache = new WeakMap< + StreamItem[], + WeakMap> +>(); function getOrderedItems(params: { cache: WeakMap>; @@ -127,6 +134,30 @@ function splitOrderedTail(params: { return split; } +function getTurnTiming(params: { + agentStatus: string; + tail: StreamItem[]; + head: StreamItem[]; +}): StreamTurnTiming { + let cachedByHead = turnTimingCache.get(params.tail); + if (!cachedByHead) { + cachedByHead = new WeakMap(); + turnTimingCache.set(params.tail, cachedByHead); + } + let cachedByStatus = cachedByHead.get(params.head); + if (!cachedByStatus) { + cachedByStatus = new Map(); + cachedByHead.set(params.head, cachedByStatus); + } + const cached = cachedByStatus.get(params.agentStatus); + if (cached) { + return cached; + } + const timing = deriveStreamTurnTiming(params); + cachedByStatus.set(params.agentStatus, timing); + return timing; +} + export function buildAgentStreamRenderModel( input: BuildAgentStreamRenderModelInput, ): AgentStreamRenderModel { @@ -160,6 +191,11 @@ export function buildAgentStreamRenderModel( platform: input.platform, isMobileBreakpoint: input.isMobileBreakpoint, }); + const turnTiming = getTurnTiming({ + agentStatus: input.agentStatus, + tail: input.tail, + head: input.head, + }); return { history: splitHistory.history, @@ -167,6 +203,7 @@ export function buildAgentStreamRenderModel( ...splitHistory.segments, liveHead: orderedHead, }, + turnTiming, boundary: { hasVirtualizedHistory: splitHistory.segments.historyVirtualized.length > 0, hasMountedHistory: splitHistory.segments.historyMounted.length > 0, diff --git a/packages/app/src/components/agent-stream-turn-footer.tsx b/packages/app/src/components/agent-stream-turn-footer.tsx index 0e30c7990..300eb0804 100644 --- a/packages/app/src/components/agent-stream-turn-footer.tsx +++ b/packages/app/src/components/agent-stream-turn-footer.tsx @@ -10,7 +10,7 @@ import Animated, { } from "react-native-reanimated"; import { StyleSheet } from "react-native-unistyles"; import { MAX_CONTENT_WIDTH } from "@/constants/layout"; -import { findTurnHeaderForAssistantTurn } from "@/timeline/turn-time"; +import type { TurnTiming } from "@/timeline/turn-time"; import type { StreamItem } from "@/types/stream"; import { getWorkingIndicatorDotStrength, @@ -28,6 +28,7 @@ export type TurnContentStrategy = StreamStrategy; export interface TurnFooterHost { itemId: string; items: StreamItem[]; + timing?: TurnTiming; startIndex: number; } @@ -36,11 +37,13 @@ export function resolveBottomTurnFooterHost(input: { history: StreamItem[]; liveHead: StreamItem[]; isInverted: boolean; + timingByAssistantId: Map; }): TurnFooterHost | null { if (input.agentStatus === "running") { return null; } - const footerItems = input.liveHead.length > 0 ? input.liveHead : input.history; + const usesLiveHead = input.liveHead.length > 0; + const footerItems = usesLiveHead ? input.liveHead : input.history; const startIndex = input.isInverted ? 0 : footerItems.length - 1; const item = footerItems[startIndex]; if (!item || item.kind !== "assistant_message") { @@ -49,6 +52,7 @@ export function resolveBottomTurnFooterHost(input: { return { itemId: item.id, items: footerItems, + timing: input.timingByAssistantId.get(item.id), startIndex, }; } @@ -89,22 +93,34 @@ export const TurnFooter = memo(function TurnFooter({ return null; } return ( - + ); }); export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({ strategy, items, + timing, startIndex, }: { strategy: TurnContentStrategy; items: StreamItem[]; + timing?: TurnTiming; startIndex: number; }) { return ( - + ); }); @@ -194,10 +210,12 @@ function RunningTurnFooter({ inFlightTurnStartedAt }: { inFlightTurnStartedAt: D function CompletedTurnFooter({ strategy, items, + timing, startIndex, }: { strategy: TurnContentStrategy; items: StreamItem[]; + timing?: TurnTiming; startIndex: number; }) { const getContent = useCallback( @@ -209,16 +227,12 @@ function CompletedTurnFooter({ }), [strategy, items, startIndex], ); - const header = useMemo( - () => findTurnHeaderForAssistantTurn({ strategy, items, startIndex }), - [strategy, items, startIndex], - ); return ( ); diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 3fe2fae61..102ef6d89 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -21,7 +21,7 @@ import { type ViewStyle, } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { useIsCompactFormFactor } from "@/constants/layout"; +import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; import { useMutation } from "@tanstack/react-query"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { Check, ChevronDown, X } from "lucide-react-native"; @@ -74,12 +74,10 @@ import { type BottomAnchorLocalRequest, type BottomAnchorRouteRequest, } from "./use-bottom-anchor-controller"; -import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { normalizeInlinePathTarget } from "@/utils/inline-path"; import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import { useStableEvent } from "@/hooks/use-stable-event"; -import { findInFlightTurnStartedAt } from "@/timeline/turn-time"; import { isWeb } from "@/constants/platform"; import type { Theme } from "@/styles/theme"; @@ -129,6 +127,7 @@ function renderStreamItemWithTurnFooter(input: { item: StreamItem; nextItem: StreamItem | undefined; items: StreamItem[]; + timing: AgentStreamRenderModel["turnTiming"]["byAssistantId"]; index: number; agentStatus: string; suppressTurnFooter: boolean | undefined; @@ -155,6 +154,7 @@ function renderStreamItemWithTurnFooter(input: { ) : null} @@ -245,6 +245,8 @@ export interface AgentStreamViewProps { onOpenWorkspaceFile?: (input: { filePath: string }) => void; } +const EMPTY_STREAM_HEAD: StreamItem[] = []; + const AgentStreamViewComponent = forwardRef( function AgentStreamView( { @@ -386,21 +388,13 @@ const AgentStreamViewComponent = forwardRef { return buildAgentStreamRenderModel({ + agentStatus: agent.status, tail: streamItems, - head: streamHead ?? [], + head: streamHead ?? EMPTY_STREAM_HEAD, platform: isWeb ? "web" : "native", isMobileBreakpoint: isMobile, }); - }, [isMobile, streamHead, streamItems]); - const inFlightTurnStartedAt = useMemo( - () => - findInFlightTurnStartedAt({ - agentStatus: agent.status, - liveHead: baseRenderModel.segments.liveHead, - tail: streamItems, - }), - [agent.status, baseRenderModel.segments.liveHead, streamItems], - ); + }, [agent.status, isMobile, streamHead, streamItems]); useImperativeHandle( ref, () => ({ @@ -643,12 +637,6 @@ const AgentStreamViewComponent = forwardRef ); - case "turn_header": - // Purely a data carrier - duration renders next to the assistant - // turn's copy button (CompletedTurnFooter), and the in-flight - // ticker renders next to the WorkingIndicator dots. - return null; - default: return null; } @@ -662,11 +650,13 @@ const AgentStreamViewComponent = forwardRef ) : null, - [showRunningTurnFooter, inFlightTurnStartedAt, bottomTurnFooterHost, streamRenderStrategy], + [ + showRunningTurnFooter, + baseRenderModel.turnTiming.runningStartedAt, + bottomTurnFooterHost, + streamRenderStrategy, + ], ); const renderModel = useMemo(() => { return { @@ -748,7 +749,6 @@ const AgentStreamViewComponent = forwardRef string; - startedAt?: Date; + completedAt?: Date; durationMs?: number; } @@ -595,13 +595,13 @@ const TIMESTAMP_REVEAL_MS = 3000; /** * Footer rendered next to the copy button at the end of an assistant turn. - * Always shows the turn duration; swaps to the start timestamp on hover (web) + * Always shows the turn duration; swaps to the end timestamp on hover (web) * or tap (native). The hidden sizer keeps the label width stable while the * visible text swaps. */ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ getContent, - startedAt, + completedAt, durationMs, }: AssistantTurnFooterProps) { const [hovered, setHovered] = useState(false); @@ -622,8 +622,8 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ [durationMs], ); const timestampLabel = useMemo( - () => (startedAt ? formatMessageTimestamp(startedAt) : ""), - [startedAt], + () => (completedAt ? formatMessageTimestamp(completedAt) : ""), + [completedAt], ); const canSwap = Boolean(timestampLabel); @@ -655,9 +655,7 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ onHoverIn={handleHoverIn} onHoverOut={handleHoverOut} accessibilityRole={canSwap ? "button" : undefined} - accessibilityLabel={ - canSwap ? `${durationLabel}, started ${timestampLabel}` : durationLabel - } + accessibilityLabel={canSwap ? `${durationLabel}, ended ${timestampLabel}` : durationLabel} > {/* Sizer reserves space for whichever label is longer so the diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index f150b2a1b..2879ddadb 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -1,4 +1,3 @@ -import invariant from "tiny-invariant"; import { describe, expect, it } from "vitest"; import type { AgentStreamEventPayload } from "@server/shared/messages"; import type { StreamItem } from "@/types/stream"; @@ -241,12 +240,10 @@ describe("processTimelineResponse", () => { }); const user = result.tail.find((item) => item.kind === "user_message"); - const header = result.tail.find((item) => item.kind === "turn_header"); + const assistant = result.tail.find((item) => item.kind === "assistant_message"); expect(user?.timestamp.toISOString()).toBe("2025-01-01T12:00:03.000Z"); - invariant(header?.kind === "turn_header"); - expect(header.startedAt.toISOString()).toBe("2025-01-01T12:00:04.000Z"); - expect(header.completedAt?.toISOString()).toBe("2025-01-01T12:00:04.000Z"); + expect(assistant?.timestamp.toISOString()).toBe("2025-01-01T12:00:04.000Z"); }); it("sets cursor to null when reset=true but no cursors in payload", () => { diff --git a/packages/app/src/timeline/turn-time.test.ts b/packages/app/src/timeline/turn-time.test.ts new file mode 100644 index 000000000..575817b5e --- /dev/null +++ b/packages/app/src/timeline/turn-time.test.ts @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import { describe, it } from "vitest"; +import { deriveStreamTurnTiming } from "./turn-time"; +import type { StreamItem } from "@/types/stream"; + +function user(id: string, timestamp: Date): StreamItem { + return { + kind: "user_message", + id, + text: id, + timestamp, + }; +} + +function assistant(id: string, timestamp: Date): StreamItem { + return { + kind: "assistant_message", + id, + text: id, + timestamp, + }; +} + +describe("deriveStreamTurnTiming", () => { + it("uses the last user message as the running turn start", () => { + const firstUserAt = new Date("2026-05-15T00:00:00.000Z"); + const secondUserAt = new Date("2026-05-15T00:01:00.000Z"); + + const timing = deriveStreamTurnTiming({ + agentStatus: "running", + tail: [ + user("u1", firstUserAt), + assistant("a1", new Date("2026-05-15T00:00:05.000Z")), + user("u2", secondUserAt), + ], + head: [assistant("a2", new Date("2026-05-15T00:01:04.000Z"))], + }); + + assert.equal(timing.runningStartedAt, secondUserAt); + assert.equal(timing.byAssistantId.has("a2"), false); + }); + + it("derives completed turn timing from user and assistant item timestamps", () => { + const userAt = new Date("2026-05-15T00:00:00.000Z"); + const assistantAt = new Date("2026-05-15T00:00:07.000Z"); + + const timing = deriveStreamTurnTiming({ + agentStatus: "idle", + tail: [ + user("u1", userAt), + assistant("a1", assistantAt), + user("u2", new Date("2026-05-15T00:01:00.000Z")), + ], + head: [], + }); + + assert.deepEqual(timing.byAssistantId.get("a1"), { + startedAt: userAt, + completedAt: assistantAt, + durationMs: 7000, + }); + }); + + it("maps multiple assistant chunks in one turn to the same timing", () => { + const userAt = new Date("2026-05-15T00:00:00.000Z"); + const firstAssistantAt = new Date("2026-05-15T00:00:03.000Z"); + const lastAssistantAt = new Date("2026-05-15T00:00:07.000Z"); + + const timing = deriveStreamTurnTiming({ + agentStatus: "idle", + tail: [ + user("u1", userAt), + assistant("a1", firstAssistantAt), + assistant("a2", lastAssistantAt), + ], + head: [], + }); + + const expected = { + startedAt: userAt, + completedAt: lastAssistantAt, + durationMs: 7000, + }; + assert.deepEqual(timing.byAssistantId.get("a1"), expected); + assert.deepEqual(timing.byAssistantId.get("a2"), expected); + }); +}); diff --git a/packages/app/src/timeline/turn-time.ts b/packages/app/src/timeline/turn-time.ts index f27bcd00b..575f52ef6 100644 --- a/packages/app/src/timeline/turn-time.ts +++ b/packages/app/src/timeline/turn-time.ts @@ -1,146 +1,83 @@ -import type { StreamItem, TurnHeaderItem, TurnHeaderOutcome } from "@/types/stream"; +import type { StreamItem } from "@/types/stream"; -export function appendLiveTurnHeader(state: StreamItem[], startedAt: Date): StreamItem[] { - const lastHeader = findLastTurnHeader(state); - if (lastHeader && lastHeader.completedAt === undefined) { - return state; - } - const header: TurnHeaderItem = { - kind: "turn_header", - id: `turn_${startedAt.getTime()}_${state.length.toString(36)}`, - timestamp: startedAt, - startedAt, - source: "live", - }; - return [...state, header]; +export interface TurnTiming { + startedAt: Date; + completedAt: Date; + durationMs: number; } -export function completeLastTurnHeader( - state: StreamItem[], - completedAt: Date, - outcome: TurnHeaderOutcome, -): StreamItem[] { - for (let i = state.length - 1; i >= 0; i -= 1) { - const item = state[i]; - if (item?.kind !== "turn_header") { - continue; - } - if (item.completedAt !== undefined) { - return state; - } - const updated: TurnHeaderItem = { - ...item, - completedAt, - durationMs: Math.max(0, completedAt.getTime() - item.startedAt.getTime()), - outcome, - }; - const next = [...state]; - next[i] = updated; - return next; - } - return state; +export interface StreamTurnTiming { + byAssistantId: Map; + runningStartedAt: Date | null; } -export function synthesizeMissingTurnHeaders(state: StreamItem[]): StreamItem[] { - const result: StreamItem[] = []; - let segment: StreamItem[] = []; - let needsHeader = true; +export function deriveStreamTurnTiming(params: { + agentStatus: string; + tail: StreamItem[]; + head: StreamItem[]; +}): StreamTurnTiming { + const byAssistantId = new Map(); + let currentUserAt: Date | null = null; + let currentLastItemAt: Date | null = null; + let currentAssistantIds: string[] = []; - function flushSegment() { - if (segment.length === 0) { + const flushCompletedTurn = () => { + if (!currentUserAt || !currentLastItemAt || currentAssistantIds.length === 0) { return; } - if (needsHeader) { - const first = segment[0]; - const last = segment[segment.length - 1]; - const startedAt = first.timestamp; - const completedAt = last.timestamp; - const header: TurnHeaderItem = { - kind: "turn_header", - id: `turn_derived_${startedAt.getTime()}_${result.length.toString(36)}`, - timestamp: startedAt, - startedAt, - completedAt, - durationMs: Math.max(0, completedAt.getTime() - startedAt.getTime()), - outcome: "completed", - source: "derived", - }; - result.push(header); + const timing: TurnTiming = { + startedAt: currentUserAt, + completedAt: currentLastItemAt, + durationMs: Math.max(0, currentLastItemAt.getTime() - currentUserAt.getTime()), + }; + for (const id of currentAssistantIds) { + byAssistantId.set(id, timing); } - result.push(...segment); - segment = []; - } + }; - for (const item of state) { + const visitItem = (item: StreamItem) => { if (item.kind === "user_message") { - flushSegment(); - needsHeader = true; - result.push(item); - continue; + flushCompletedTurn(); + currentUserAt = item.timestamp; + currentLastItemAt = null; + currentAssistantIds = []; + return; } - if (item.kind === "turn_header") { - flushSegment(); - needsHeader = false; - result.push(item); - continue; + if (!currentUserAt) { + return; } - segment.push(item); + currentLastItemAt = item.timestamp; + if (item.kind === "assistant_message") { + currentAssistantIds.push(item.id); + } + }; + + for (const item of params.tail) { + visitItem(item); + } + for (const item of params.head) { + visitItem(item); } - flushSegment(); - return result; -} - -export function findInFlightTurnStartedAt(params: { - agentStatus: string; - liveHead: StreamItem[]; - tail: StreamItem[]; -}): Date | null { + const runningStartedAt = + params.agentStatus === "running" + ? (findLastUserMessageTimestamp(params.head) ?? currentUserAt) + : null; if (params.agentStatus !== "running") { - return null; + flushCompletedTurn(); } - const headStartedAt = findLastOpenTurnHeader(params.liveHead)?.startedAt; - if (headStartedAt) { - return headStartedAt; - } - return findLastOpenTurnHeader(params.tail)?.startedAt ?? null; + + return { + byAssistantId, + runningStartedAt, + }; } -export interface TurnHeaderNeighborResolver { - getNeighborIndex(index: number, relation: "above" | "below"): number; -} - -export function findTurnHeaderForAssistantTurn(params: { - strategy: TurnHeaderNeighborResolver; - items: StreamItem[]; - startIndex: number; -}): TurnHeaderItem | null { - let index = params.startIndex; - while (index >= 0 && index < params.items.length) { - const item = params.items[index]; - if (!item) return null; - if (item.kind === "turn_header") return item; - if (item.kind === "user_message") return null; - index = params.strategy.getNeighborIndex(index, "above"); - } - return null; -} - -function findLastTurnHeader(state: StreamItem[]): TurnHeaderItem | null { - for (let i = state.length - 1; i >= 0; i -= 1) { - const item = state[i]; - if (item?.kind === "turn_header") { - return item; - } - } - return null; -} - -function findLastOpenTurnHeader(state: StreamItem[]): TurnHeaderItem | null { - for (let i = state.length - 1; i >= 0; i -= 1) { - const item = state[i]; - if (item?.kind === "turn_header" && item.completedAt === undefined) { - return item; +function findLastUserMessageTimestamp(items: StreamItem[]): Date | null { + for (let i = items.length - 1; i >= 0; i -= 1) { + const item = items[i]; + if (item?.kind === "user_message") { + return item.timestamp; } } return null; diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index 42b5973f9..8882b8209 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -841,8 +841,8 @@ describe("stream reducer canonical tool calls", () => { }); }); -describe("turn headers", () => { - it("inserts a live header on turn_started and closes it on turn_completed", () => { +describe("turn lifecycle events", () => { + it("finalizes active stream items without adding timeline rows", () => { const startedAt = new Date("2025-01-01T12:00:00Z"); const completedAt = new Date("2025-01-01T12:00:05Z"); @@ -854,16 +854,13 @@ describe("turn headers", () => { ); state = reduceStreamUpdate(state, { type: "turn_completed", provider: "claude" }, completedAt); - const header = state.find((item) => item.kind === "turn_header"); - invariant(header?.kind === "turn_header"); - assert.strictEqual(header.source, "live"); - assert.strictEqual(header.outcome, "completed"); - assert.strictEqual(header.durationMs, 5_000); - assert.strictEqual(header.startedAt.getTime(), startedAt.getTime()); - assert.strictEqual(header.completedAt?.getTime(), completedAt.getTime()); + assert.deepStrictEqual( + state.map((item) => item.kind), + ["assistant_message"], + ); }); - it("synthesizes a derived header during hydration when turn events are missing", () => { + it("hydrates canonical timeline rows without synthetic turn rows", () => { const state = hydrateStreamState([ { event: { @@ -883,24 +880,13 @@ describe("turn headers", () => { }, ]); - const headers = state.filter((item) => item.kind === "turn_header"); - assert.strictEqual(headers.length, 1); - const [header] = headers; - invariant(header?.kind === "turn_header"); - assert.strictEqual(header.source, "derived"); - assert.strictEqual(header.outcome, "completed"); - assert.strictEqual(header.durationMs, 3_000); - // Header should sit between the user message and the first assistant item. - const kinds = state.map((item) => item.kind); - assert.deepStrictEqual(kinds, [ - "user_message", - "turn_header", - "assistant_message", - "assistant_message", - ]); + assert.deepStrictEqual( + state.map((item) => item.kind), + ["user_message", "assistant_message", "assistant_message"], + ); }); - it("does not synthesize a derived header when a live header is already present", () => { + it("does not materialize turn_started events during hydration", () => { const startedAt = new Date("2025-01-01T14:00:00Z"); const state = hydrateStreamState([ { @@ -918,15 +904,13 @@ describe("turn headers", () => { }, ]); - const headers = state.filter((item) => item.kind === "turn_header"); - assert.strictEqual(headers.length, 1); - const header = headers[0]; - invariant(header?.kind === "turn_header"); - assert.strictEqual(header.source, "live"); - assert.strictEqual(header.completedAt, undefined); + assert.deepStrictEqual( + state.map((item) => item.kind), + ["user_message", "assistant_message"], + ); }); - it("skips synthesis for turns with no assistant output", () => { + it("keeps adjacent user messages as adjacent timeline rows", () => { const state = hydrateStreamState([ { event: { @@ -946,7 +930,9 @@ describe("turn headers", () => { }, ]); - const headers = state.filter((item) => item.kind === "turn_header"); - assert.strictEqual(headers.length, 0); + assert.deepStrictEqual( + state.map((item) => item.kind), + ["user_message", "user_message"], + ); }); }); diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 5c98db57e..e0197e64e 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -3,11 +3,6 @@ import type { AgentAttachment, AgentStreamEventPayload } from "@server/shared/me import type { AttachmentMetadata } from "@/attachments/types"; import { extractTaskEntriesFromToolCall } from "../utils/tool-call-parsers"; import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; -import { - appendLiveTurnHeader, - completeLastTurnHeader, - synthesizeMissingTurnHeaders, -} from "@/timeline/turn-time"; /** * Simple hash function for deterministic ID generation @@ -55,21 +50,7 @@ export type StreamItem = | ToolCallItem | TodoListItem | ActivityLogItem - | CompactionItem - | TurnHeaderItem; - -export type TurnHeaderOutcome = "completed" | "failed" | "canceled"; - -export interface TurnHeaderItem { - kind: "turn_header"; - id: string; - timestamp: Date; - startedAt: Date; - completedAt?: Date; - durationMs?: number; - outcome?: TurnHeaderOutcome; - source: "live" | "derived"; -} + | CompactionItem; export type UserMessageImageAttachment = AttachmentMetadata; @@ -723,15 +704,10 @@ export function reduceStreamUpdate( case "timeline": return reduceTimelineEvent(state, event, timestamp, source); case "thread_started": - return finalizeActiveThoughts(state); case "turn_started": - return appendLiveTurnHeader(finalizeActiveThoughts(state), timestamp); case "turn_completed": - return completeLastTurnHeader(finalizeActiveThoughts(state), timestamp, "completed"); case "turn_failed": - return completeLastTurnHeader(finalizeActiveThoughts(state), timestamp, "failed"); case "turn_canceled": - return completeLastTurnHeader(finalizeActiveThoughts(state), timestamp, "canceled"); case "permission_requested": case "permission_resolved": case "attention_required": @@ -755,7 +731,7 @@ export function hydrateStreamState( return reduceStreamUpdate(state, event, timestamp, options); }, []); - return synthesizeMissingTurnHeaders(finalizeActiveThoughts(hydrated)); + return finalizeActiveThoughts(hydrated); } /** diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 3841c8f3a..89ec1ccbe 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -1418,6 +1418,68 @@ describe("Codex app-server provider", () => { ]); }); + test("uses Codex turn timestamps for timestamp-less persisted history items", async () => { + const session = createSession(); + session.client = { + request: vi.fn(async (method: string) => { + if (method !== "thread/read") { + return {}; + } + return { + thread: { + turns: [ + { + startedAt: 1_778_832_941, + completedAt: 1_778_833_094, + items: [ + { + type: "userMessage", + id: "user-history", + content: [{ type: "text", text: "Check OpenCode timestamps." }], + }, + { + type: "agentMessage", + id: "message-history", + text: "History loaded.", + }, + ], + }, + ], + }, + }; + }), + }; + + await asInternals(session).loadPersistedHistory(); + + const history: AgentStreamEvent[] = []; + for await (const event of session.streamHistory()) { + history.push(event); + } + + expect(history).toEqual([ + { + type: "timeline", + provider: "codex", + timestamp: "2026-05-15T08:15:41.000Z", + item: { + type: "user_message", + text: "Check OpenCode timestamps.", + }, + }, + { + type: "timeline", + provider: "codex", + timestamp: "2026-05-15T08:18:14.000Z", + item: { + type: "assistant_message", + text: "History loaded.", + messageId: "message-history", + }, + }, + ]); + }); + test("preserves Codex app-server assistant item ids in persisted history", async () => { const session = createSession(); session.client = { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index c043fef78..392defffd 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -1462,6 +1462,28 @@ function readCodexHistoryTimestamp(item: unknown): string | null { ); } +function readCodexTurnHistoryTimestamp( + turn: unknown, + timelineItem: AgentTimelineItem, +): string | null { + const record = toObjectRecord(turn); + if (!record) { + return null; + } + + const startedAt = + normalizeProviderReplayTimestamp(record.startedAt) ?? + normalizeProviderReplayTimestamp(record.started_at); + const completedAt = + normalizeProviderReplayTimestamp(record.completedAt) ?? + normalizeProviderReplayTimestamp(record.completed_at); + + if (timelineItem.type === "user_message") { + return startedAt ?? completedAt; + } + return completedAt ?? startedAt; +} + function codexImageOutputFromResult(result: unknown): ProviderImageOutput | null { if (typeof result === "string") { const trimmed = result.trim(); @@ -1616,7 +1638,8 @@ async function loadCodexThreadHistoryTimeline(params: { for (const item of turn.items) { const timelineItem = threadItemToTimeline(item, { cwd: params.cwd }); if (timelineItem) { - const timestamp = readCodexHistoryTimestamp(item); + const timestamp = + readCodexHistoryTimestamp(item) ?? readCodexTurnHistoryTimestamp(turn, timelineItem); timeline.push({ item: timelineItem, timestamp: timestamp ?? undefined,