Fix agent turn timing persistence

This commit is contained in:
Mohamed Boudra
2026-05-15 17:38:28 +07:00
parent 6cbe4e6a14
commit 160d861534
13 changed files with 443 additions and 233 deletions
+20
View File
@@ -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 });
});
});
@@ -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);
});
});
@@ -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<string, Pick<AgentStreamRenderModel, "history" | "segments">>
>();
const turnTimingCache = new WeakMap<
StreamItem[],
WeakMap<StreamItem[], Map<string, StreamTurnTiming>>
>();
function getOrderedItems(params: {
cache: WeakMap<StreamItem[], Map<string, StreamItem[]>>;
@@ -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,
@@ -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<string, TurnTiming>;
}): 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 (
<CompletedTurnFooterRow strategy={strategy} items={host.items} startIndex={host.startIndex} />
<CompletedTurnFooterRow
strategy={strategy}
items={host.items}
timing={host.timing}
startIndex={host.startIndex}
/>
);
});
export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
strategy,
items,
timing,
startIndex,
}: {
strategy: TurnContentStrategy;
items: StreamItem[];
timing?: TurnTiming;
startIndex: number;
}) {
return (
<TurnFooterRow>
<CompletedTurnFooter strategy={strategy} items={items} startIndex={startIndex} />
<CompletedTurnFooter
strategy={strategy}
items={items}
timing={timing}
startIndex={startIndex}
/>
</TurnFooterRow>
);
});
@@ -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 (
<View style={stylesheet.turnFooterSlot}>
<AssistantTurnFooter
getContent={getContent}
startedAt={header?.startedAt}
durationMs={header?.durationMs}
completedAt={timing?.completedAt}
durationMs={timing?.durationMs}
/>
</View>
);
@@ -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: {
<CompletedTurnFooterRow
strategy={input.strategy}
items={input.items}
timing={input.timing.get(input.item.id)}
startIndex={input.index}
/>
) : null}
@@ -245,6 +245,8 @@ export interface AgentStreamViewProps {
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
}
const EMPTY_STREAM_HEAD: StreamItem[] = [];
const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
function AgentStreamView(
{
@@ -386,21 +388,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const baseRenderModel = useMemo(() => {
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<AgentStreamViewHandle, AgentStreamVi
/>
);
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<AgentStreamViewHandle, AgentStreamVi
history: baseRenderModel.history,
liveHead: baseRenderModel.segments.liveHead,
isInverted: streamRenderStrategy.getFlatListInverted(),
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
});
}, [
agent.status,
baseRenderModel.history,
baseRenderModel.segments.liveHead,
baseRenderModel.turnTiming.byAssistantId,
streamRenderStrategy,
]);
@@ -689,13 +679,19 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
item,
nextItem,
items,
timing: baseRenderModel.turnTiming.byAssistantId,
index,
agentStatus: agent.status,
suppressTurnFooter: seams.suppressTurnFooter,
strategy: streamRenderStrategy,
});
},
[renderStreamItemContent, agent.status, streamRenderStrategy],
[
agent.status,
baseRenderModel.turnTiming.byAssistantId,
renderStreamItemContent,
streamRenderStrategy,
],
);
const pendingPermissionItems = useMemo(
@@ -717,12 +713,17 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
showRunningTurnFooter || bottomTurnFooterHost ? (
<TurnFooter
isRunning={showRunningTurnFooter}
inFlightTurnStartedAt={inFlightTurnStartedAt}
inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt}
host={bottomTurnFooterHost}
strategy={streamRenderStrategy}
/>
) : null,
[showRunningTurnFooter, inFlightTurnStartedAt, bottomTurnFooterHost, streamRenderStrategy],
[
showRunningTurnFooter,
baseRenderModel.turnTiming.runningStartedAt,
bottomTurnFooterHost,
streamRenderStrategy,
],
);
const renderModel = useMemo<AgentStreamRenderModel>(() => {
return {
@@ -748,7 +749,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
const historyItems = renderModel.history;
const _liveHeadItems = renderModel.segments.liveHead;
const { boundary, auxiliary } = renderModel;
const lastHistoryItem = historyItems.at(-1) ?? null;
const firstLiveHeadItem = renderModel.segments.liveHead[0] ?? null;
+6 -8
View File
@@ -557,7 +557,7 @@ export const UserMessage = memo(function UserMessage({
interface AssistantTurnFooterProps {
getContent: () => 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}
>
<View style={assistantTurnFooterStylesheet.labelWrapper}>
{/* Sizer reserves space for whichever label is longer so the
@@ -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", () => {
@@ -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);
});
});
+61 -124
View File
@@ -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<string, TurnTiming>;
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<string, TurnTiming>();
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;
+21 -35
View File
@@ -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"],
);
});
});
+2 -26
View File
@@ -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);
}
/**
@@ -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 = {
@@ -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,