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, expectTurnCopyButton,
expectScrollFollowsNewContent, expectScrollFollowsNewContent,
} from "./helpers/agent-stream"; } from "./helpers/agent-stream";
import { clickNewChat } from "./helpers/launcher";
import { startRunningMockAgent } from "./helpers/composer"; import { startRunningMockAgent } from "./helpers/composer";
test.describe("Agent stream UI", () => { test.describe("Agent stream UI", () => {
@@ -42,4 +43,23 @@ test.describe("Agent stream UI", () => {
await repo.cleanup(); 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 head = [assistantMessage("live-a", 121)];
const model = buildAgentStreamRenderModel({ const model = buildAgentStreamRenderModel({
agentStatus: "running",
tail, tail,
head, head,
platform: "web", platform: "web",
@@ -52,6 +53,7 @@ describe("buildAgentStreamRenderModel", () => {
const head = [assistantMessage("live-a", 3)]; const head = [assistantMessage("live-a", 3)];
const model = buildAgentStreamRenderModel({ const model = buildAgentStreamRenderModel({
agentStatus: "running",
tail, tail,
head, head,
platform: "web", platform: "web",
@@ -69,12 +71,14 @@ describe("buildAgentStreamRenderModel", () => {
const secondHead = [assistantMessage("live-b", 4)]; const secondHead = [assistantMessage("live-b", 4)];
const first = buildAgentStreamRenderModel({ const first = buildAgentStreamRenderModel({
agentStatus: "running",
tail, tail,
head: firstHead, head: firstHead,
platform: "native", platform: "native",
isMobileBreakpoint: false, isMobileBreakpoint: false,
}); });
const second = buildAgentStreamRenderModel({ const second = buildAgentStreamRenderModel({
agentStatus: "running",
tail, tail,
head: secondHead, head: secondHead,
platform: "native", platform: "native",
@@ -85,4 +89,73 @@ describe("buildAgentStreamRenderModel", () => {
expect(first.segments.historyMounted).toBe(second.segments.historyMounted); expect(first.segments.historyMounted).toBe(second.segments.historyMounted);
expect(second.segments.liveHead.map((item) => item.id)).toEqual(["live-b"]); 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 type { ReactNode } from "react";
import { deriveStreamTurnTiming, type StreamTurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
import { import {
findMountedWindowStart, findMountedWindowStart,
@@ -32,11 +33,13 @@ export interface StreamRenderAuxiliary {
export interface AgentStreamRenderModel { export interface AgentStreamRenderModel {
history: StreamItem[]; history: StreamItem[];
segments: StreamRenderSegments; segments: StreamRenderSegments;
turnTiming: StreamTurnTiming;
boundary: StreamHistoryBoundary; boundary: StreamHistoryBoundary;
auxiliary: StreamRenderAuxiliary; auxiliary: StreamRenderAuxiliary;
} }
export interface BuildAgentStreamRenderModelInput { export interface BuildAgentStreamRenderModelInput {
agentStatus: string;
tail: StreamItem[]; tail: StreamItem[];
head: StreamItem[]; head: StreamItem[];
platform: "web" | "native"; platform: "web" | "native";
@@ -55,6 +58,10 @@ const splitHistoryCache = new WeakMap<
StreamItem[], StreamItem[],
Map<string, Pick<AgentStreamRenderModel, "history" | "segments">> Map<string, Pick<AgentStreamRenderModel, "history" | "segments">>
>(); >();
const turnTimingCache = new WeakMap<
StreamItem[],
WeakMap<StreamItem[], Map<string, StreamTurnTiming>>
>();
function getOrderedItems(params: { function getOrderedItems(params: {
cache: WeakMap<StreamItem[], Map<string, StreamItem[]>>; cache: WeakMap<StreamItem[], Map<string, StreamItem[]>>;
@@ -127,6 +134,30 @@ function splitOrderedTail(params: {
return split; 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( export function buildAgentStreamRenderModel(
input: BuildAgentStreamRenderModelInput, input: BuildAgentStreamRenderModelInput,
): AgentStreamRenderModel { ): AgentStreamRenderModel {
@@ -160,6 +191,11 @@ export function buildAgentStreamRenderModel(
platform: input.platform, platform: input.platform,
isMobileBreakpoint: input.isMobileBreakpoint, isMobileBreakpoint: input.isMobileBreakpoint,
}); });
const turnTiming = getTurnTiming({
agentStatus: input.agentStatus,
tail: input.tail,
head: input.head,
});
return { return {
history: splitHistory.history, history: splitHistory.history,
@@ -167,6 +203,7 @@ export function buildAgentStreamRenderModel(
...splitHistory.segments, ...splitHistory.segments,
liveHead: orderedHead, liveHead: orderedHead,
}, },
turnTiming,
boundary: { boundary: {
hasVirtualizedHistory: splitHistory.segments.historyVirtualized.length > 0, hasVirtualizedHistory: splitHistory.segments.historyVirtualized.length > 0,
hasMountedHistory: splitHistory.segments.historyMounted.length > 0, hasMountedHistory: splitHistory.segments.historyMounted.length > 0,
@@ -10,7 +10,7 @@ import Animated, {
} from "react-native-reanimated"; } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout"; 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 type { StreamItem } from "@/types/stream";
import { import {
getWorkingIndicatorDotStrength, getWorkingIndicatorDotStrength,
@@ -28,6 +28,7 @@ export type TurnContentStrategy = StreamStrategy;
export interface TurnFooterHost { export interface TurnFooterHost {
itemId: string; itemId: string;
items: StreamItem[]; items: StreamItem[];
timing?: TurnTiming;
startIndex: number; startIndex: number;
} }
@@ -36,11 +37,13 @@ export function resolveBottomTurnFooterHost(input: {
history: StreamItem[]; history: StreamItem[];
liveHead: StreamItem[]; liveHead: StreamItem[];
isInverted: boolean; isInverted: boolean;
timingByAssistantId: Map<string, TurnTiming>;
}): TurnFooterHost | null { }): TurnFooterHost | null {
if (input.agentStatus === "running") { if (input.agentStatus === "running") {
return null; 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 startIndex = input.isInverted ? 0 : footerItems.length - 1;
const item = footerItems[startIndex]; const item = footerItems[startIndex];
if (!item || item.kind !== "assistant_message") { if (!item || item.kind !== "assistant_message") {
@@ -49,6 +52,7 @@ export function resolveBottomTurnFooterHost(input: {
return { return {
itemId: item.id, itemId: item.id,
items: footerItems, items: footerItems,
timing: input.timingByAssistantId.get(item.id),
startIndex, startIndex,
}; };
} }
@@ -89,22 +93,34 @@ export const TurnFooter = memo(function TurnFooter({
return null; return null;
} }
return ( 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({ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
strategy, strategy,
items, items,
timing,
startIndex, startIndex,
}: { }: {
strategy: TurnContentStrategy; strategy: TurnContentStrategy;
items: StreamItem[]; items: StreamItem[];
timing?: TurnTiming;
startIndex: number; startIndex: number;
}) { }) {
return ( return (
<TurnFooterRow> <TurnFooterRow>
<CompletedTurnFooter strategy={strategy} items={items} startIndex={startIndex} /> <CompletedTurnFooter
strategy={strategy}
items={items}
timing={timing}
startIndex={startIndex}
/>
</TurnFooterRow> </TurnFooterRow>
); );
}); });
@@ -194,10 +210,12 @@ function RunningTurnFooter({ inFlightTurnStartedAt }: { inFlightTurnStartedAt: D
function CompletedTurnFooter({ function CompletedTurnFooter({
strategy, strategy,
items, items,
timing,
startIndex, startIndex,
}: { }: {
strategy: TurnContentStrategy; strategy: TurnContentStrategy;
items: StreamItem[]; items: StreamItem[];
timing?: TurnTiming;
startIndex: number; startIndex: number;
}) { }) {
const getContent = useCallback( const getContent = useCallback(
@@ -209,16 +227,12 @@ function CompletedTurnFooter({
}), }),
[strategy, items, startIndex], [strategy, items, startIndex],
); );
const header = useMemo(
() => findTurnHeaderForAssistantTurn({ strategy, items, startIndex }),
[strategy, items, startIndex],
);
return ( return (
<View style={stylesheet.turnFooterSlot}> <View style={stylesheet.turnFooterSlot}>
<AssistantTurnFooter <AssistantTurnFooter
getContent={getContent} getContent={getContent}
startedAt={header?.startedAt} completedAt={timing?.completedAt}
durationMs={header?.durationMs} durationMs={timing?.durationMs}
/> />
</View> </View>
); );
@@ -21,7 +21,7 @@ import {
type ViewStyle, type ViewStyle,
} from "react-native"; } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; 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 { useMutation } from "@tanstack/react-query";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { Check, ChevronDown, X } from "lucide-react-native"; import { Check, ChevronDown, X } from "lucide-react-native";
@@ -74,12 +74,10 @@ import {
type BottomAnchorLocalRequest, type BottomAnchorLocalRequest,
type BottomAnchorRouteRequest, type BottomAnchorRouteRequest,
} from "./use-bottom-anchor-controller"; } from "./use-bottom-anchor-controller";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { normalizeInlinePathTarget } from "@/utils/inline-path"; import { normalizeInlinePathTarget } from "@/utils/inline-path";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event"; import { useStableEvent } from "@/hooks/use-stable-event";
import { findInFlightTurnStartedAt } from "@/timeline/turn-time";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
@@ -129,6 +127,7 @@ function renderStreamItemWithTurnFooter(input: {
item: StreamItem; item: StreamItem;
nextItem: StreamItem | undefined; nextItem: StreamItem | undefined;
items: StreamItem[]; items: StreamItem[];
timing: AgentStreamRenderModel["turnTiming"]["byAssistantId"];
index: number; index: number;
agentStatus: string; agentStatus: string;
suppressTurnFooter: boolean | undefined; suppressTurnFooter: boolean | undefined;
@@ -155,6 +154,7 @@ function renderStreamItemWithTurnFooter(input: {
<CompletedTurnFooterRow <CompletedTurnFooterRow
strategy={input.strategy} strategy={input.strategy}
items={input.items} items={input.items}
timing={input.timing.get(input.item.id)}
startIndex={input.index} startIndex={input.index}
/> />
) : null} ) : null}
@@ -245,6 +245,8 @@ export interface AgentStreamViewProps {
onOpenWorkspaceFile?: (input: { filePath: string }) => void; onOpenWorkspaceFile?: (input: { filePath: string }) => void;
} }
const EMPTY_STREAM_HEAD: StreamItem[] = [];
const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>( const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
function AgentStreamView( function AgentStreamView(
{ {
@@ -386,21 +388,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const baseRenderModel = useMemo(() => { const baseRenderModel = useMemo(() => {
return buildAgentStreamRenderModel({ return buildAgentStreamRenderModel({
agentStatus: agent.status,
tail: streamItems, tail: streamItems,
head: streamHead ?? [], head: streamHead ?? EMPTY_STREAM_HEAD,
platform: isWeb ? "web" : "native", platform: isWeb ? "web" : "native",
isMobileBreakpoint: isMobile, isMobileBreakpoint: isMobile,
}); });
}, [isMobile, streamHead, streamItems]); }, [agent.status, isMobile, streamHead, streamItems]);
const inFlightTurnStartedAt = useMemo(
() =>
findInFlightTurnStartedAt({
agentStatus: agent.status,
liveHead: baseRenderModel.segments.liveHead,
tail: streamItems,
}),
[agent.status, baseRenderModel.segments.liveHead, streamItems],
);
useImperativeHandle( useImperativeHandle(
ref, 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: default:
return null; return null;
} }
@@ -662,11 +650,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
history: baseRenderModel.history, history: baseRenderModel.history,
liveHead: baseRenderModel.segments.liveHead, liveHead: baseRenderModel.segments.liveHead,
isInverted: streamRenderStrategy.getFlatListInverted(), isInverted: streamRenderStrategy.getFlatListInverted(),
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
}); });
}, [ }, [
agent.status, agent.status,
baseRenderModel.history, baseRenderModel.history,
baseRenderModel.segments.liveHead, baseRenderModel.segments.liveHead,
baseRenderModel.turnTiming.byAssistantId,
streamRenderStrategy, streamRenderStrategy,
]); ]);
@@ -689,13 +679,19 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
item, item,
nextItem, nextItem,
items, items,
timing: baseRenderModel.turnTiming.byAssistantId,
index, index,
agentStatus: agent.status, agentStatus: agent.status,
suppressTurnFooter: seams.suppressTurnFooter, suppressTurnFooter: seams.suppressTurnFooter,
strategy: streamRenderStrategy, strategy: streamRenderStrategy,
}); });
}, },
[renderStreamItemContent, agent.status, streamRenderStrategy], [
agent.status,
baseRenderModel.turnTiming.byAssistantId,
renderStreamItemContent,
streamRenderStrategy,
],
); );
const pendingPermissionItems = useMemo( const pendingPermissionItems = useMemo(
@@ -717,12 +713,17 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
showRunningTurnFooter || bottomTurnFooterHost ? ( showRunningTurnFooter || bottomTurnFooterHost ? (
<TurnFooter <TurnFooter
isRunning={showRunningTurnFooter} isRunning={showRunningTurnFooter}
inFlightTurnStartedAt={inFlightTurnStartedAt} inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt}
host={bottomTurnFooterHost} host={bottomTurnFooterHost}
strategy={streamRenderStrategy} strategy={streamRenderStrategy}
/> />
) : null, ) : null,
[showRunningTurnFooter, inFlightTurnStartedAt, bottomTurnFooterHost, streamRenderStrategy], [
showRunningTurnFooter,
baseRenderModel.turnTiming.runningStartedAt,
bottomTurnFooterHost,
streamRenderStrategy,
],
); );
const renderModel = useMemo<AgentStreamRenderModel>(() => { const renderModel = useMemo<AgentStreamRenderModel>(() => {
return { return {
@@ -748,7 +749,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
); );
const historyItems = renderModel.history; const historyItems = renderModel.history;
const _liveHeadItems = renderModel.segments.liveHead;
const { boundary, auxiliary } = renderModel; const { boundary, auxiliary } = renderModel;
const lastHistoryItem = historyItems.at(-1) ?? null; const lastHistoryItem = historyItems.at(-1) ?? null;
const firstLiveHeadItem = renderModel.segments.liveHead[0] ?? null; const firstLiveHeadItem = renderModel.segments.liveHead[0] ?? null;
+6 -8
View File
@@ -557,7 +557,7 @@ export const UserMessage = memo(function UserMessage({
interface AssistantTurnFooterProps { interface AssistantTurnFooterProps {
getContent: () => string; getContent: () => string;
startedAt?: Date; completedAt?: Date;
durationMs?: number; 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. * 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 * or tap (native). The hidden sizer keeps the label width stable while the
* visible text swaps. * visible text swaps.
*/ */
export const AssistantTurnFooter = memo(function AssistantTurnFooter({ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
getContent, getContent,
startedAt, completedAt,
durationMs, durationMs,
}: AssistantTurnFooterProps) { }: AssistantTurnFooterProps) {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
@@ -622,8 +622,8 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
[durationMs], [durationMs],
); );
const timestampLabel = useMemo( const timestampLabel = useMemo(
() => (startedAt ? formatMessageTimestamp(startedAt) : ""), () => (completedAt ? formatMessageTimestamp(completedAt) : ""),
[startedAt], [completedAt],
); );
const canSwap = Boolean(timestampLabel); const canSwap = Boolean(timestampLabel);
@@ -655,9 +655,7 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
onHoverIn={handleHoverIn} onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut} onHoverOut={handleHoverOut}
accessibilityRole={canSwap ? "button" : undefined} accessibilityRole={canSwap ? "button" : undefined}
accessibilityLabel={ accessibilityLabel={canSwap ? `${durationLabel}, ended ${timestampLabel}` : durationLabel}
canSwap ? `${durationLabel}, started ${timestampLabel}` : durationLabel
}
> >
<View style={assistantTurnFooterStylesheet.labelWrapper}> <View style={assistantTurnFooterStylesheet.labelWrapper}>
{/* Sizer reserves space for whichever label is longer so the {/* 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 { describe, expect, it } from "vitest";
import type { AgentStreamEventPayload } from "@server/shared/messages"; import type { AgentStreamEventPayload } from "@server/shared/messages";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
@@ -241,12 +240,10 @@ describe("processTimelineResponse", () => {
}); });
const user = result.tail.find((item) => item.kind === "user_message"); 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"); expect(user?.timestamp.toISOString()).toBe("2025-01-01T12:00:03.000Z");
invariant(header?.kind === "turn_header"); expect(assistant?.timestamp.toISOString()).toBe("2025-01-01T12:00:04.000Z");
expect(header.startedAt.toISOString()).toBe("2025-01-01T12:00:04.000Z");
expect(header.completedAt?.toISOString()).toBe("2025-01-01T12:00:04.000Z");
}); });
it("sets cursor to null when reset=true but no cursors in payload", () => { 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[] { export interface TurnTiming {
const lastHeader = findLastTurnHeader(state); startedAt: Date;
if (lastHeader && lastHeader.completedAt === undefined) { completedAt: Date;
return state; durationMs: number;
}
const header: TurnHeaderItem = {
kind: "turn_header",
id: `turn_${startedAt.getTime()}_${state.length.toString(36)}`,
timestamp: startedAt,
startedAt,
source: "live",
};
return [...state, header];
} }
export function completeLastTurnHeader( export interface StreamTurnTiming {
state: StreamItem[], byAssistantId: Map<string, TurnTiming>;
completedAt: Date, runningStartedAt: Date | null;
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 function synthesizeMissingTurnHeaders(state: StreamItem[]): StreamItem[] { export function deriveStreamTurnTiming(params: {
const result: StreamItem[] = []; agentStatus: string;
let segment: StreamItem[] = []; tail: StreamItem[];
let needsHeader = true; head: StreamItem[];
}): StreamTurnTiming {
const byAssistantId = new Map<string, TurnTiming>();
let currentUserAt: Date | null = null;
let currentLastItemAt: Date | null = null;
let currentAssistantIds: string[] = [];
function flushSegment() { const flushCompletedTurn = () => {
if (segment.length === 0) { if (!currentUserAt || !currentLastItemAt || currentAssistantIds.length === 0) {
return; return;
} }
if (needsHeader) { const timing: TurnTiming = {
const first = segment[0]; startedAt: currentUserAt,
const last = segment[segment.length - 1]; completedAt: currentLastItemAt,
const startedAt = first.timestamp; durationMs: Math.max(0, currentLastItemAt.getTime() - currentUserAt.getTime()),
const completedAt = last.timestamp; };
const header: TurnHeaderItem = { for (const id of currentAssistantIds) {
kind: "turn_header", byAssistantId.set(id, timing);
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);
} }
result.push(...segment); };
segment = [];
}
for (const item of state) { const visitItem = (item: StreamItem) => {
if (item.kind === "user_message") { if (item.kind === "user_message") {
flushSegment(); flushCompletedTurn();
needsHeader = true; currentUserAt = item.timestamp;
result.push(item); currentLastItemAt = null;
continue; currentAssistantIds = [];
return;
} }
if (item.kind === "turn_header") { if (!currentUserAt) {
flushSegment(); return;
needsHeader = false;
result.push(item);
continue;
} }
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(); const runningStartedAt =
return result; params.agentStatus === "running"
} ? (findLastUserMessageTimestamp(params.head) ?? currentUserAt)
: null;
export function findInFlightTurnStartedAt(params: {
agentStatus: string;
liveHead: StreamItem[];
tail: StreamItem[];
}): Date | null {
if (params.agentStatus !== "running") { if (params.agentStatus !== "running") {
return null; flushCompletedTurn();
} }
const headStartedAt = findLastOpenTurnHeader(params.liveHead)?.startedAt;
if (headStartedAt) { return {
return headStartedAt; byAssistantId,
} runningStartedAt,
return findLastOpenTurnHeader(params.tail)?.startedAt ?? null; };
} }
export interface TurnHeaderNeighborResolver { function findLastUserMessageTimestamp(items: StreamItem[]): Date | null {
getNeighborIndex(index: number, relation: "above" | "below"): number; for (let i = items.length - 1; i >= 0; i -= 1) {
} const item = items[i];
if (item?.kind === "user_message") {
export function findTurnHeaderForAssistantTurn(params: { return item.timestamp;
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;
} }
} }
return null; return null;
+21 -35
View File
@@ -841,8 +841,8 @@ describe("stream reducer canonical tool calls", () => {
}); });
}); });
describe("turn headers", () => { describe("turn lifecycle events", () => {
it("inserts a live header on turn_started and closes it on turn_completed", () => { it("finalizes active stream items without adding timeline rows", () => {
const startedAt = new Date("2025-01-01T12:00:00Z"); const startedAt = new Date("2025-01-01T12:00:00Z");
const completedAt = new Date("2025-01-01T12:00:05Z"); 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); state = reduceStreamUpdate(state, { type: "turn_completed", provider: "claude" }, completedAt);
const header = state.find((item) => item.kind === "turn_header"); assert.deepStrictEqual(
invariant(header?.kind === "turn_header"); state.map((item) => item.kind),
assert.strictEqual(header.source, "live"); ["assistant_message"],
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());
}); });
it("synthesizes a derived header during hydration when turn events are missing", () => { it("hydrates canonical timeline rows without synthetic turn rows", () => {
const state = hydrateStreamState([ const state = hydrateStreamState([
{ {
event: { event: {
@@ -883,24 +880,13 @@ describe("turn headers", () => {
}, },
]); ]);
const headers = state.filter((item) => item.kind === "turn_header"); assert.deepStrictEqual(
assert.strictEqual(headers.length, 1); state.map((item) => item.kind),
const [header] = headers; ["user_message", "assistant_message", "assistant_message"],
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",
]);
}); });
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 startedAt = new Date("2025-01-01T14:00:00Z");
const state = hydrateStreamState([ const state = hydrateStreamState([
{ {
@@ -918,15 +904,13 @@ describe("turn headers", () => {
}, },
]); ]);
const headers = state.filter((item) => item.kind === "turn_header"); assert.deepStrictEqual(
assert.strictEqual(headers.length, 1); state.map((item) => item.kind),
const header = headers[0]; ["user_message", "assistant_message"],
invariant(header?.kind === "turn_header"); );
assert.strictEqual(header.source, "live");
assert.strictEqual(header.completedAt, undefined);
}); });
it("skips synthesis for turns with no assistant output", () => { it("keeps adjacent user messages as adjacent timeline rows", () => {
const state = hydrateStreamState([ const state = hydrateStreamState([
{ {
event: { event: {
@@ -946,7 +930,9 @@ describe("turn headers", () => {
}, },
]); ]);
const headers = state.filter((item) => item.kind === "turn_header"); assert.deepStrictEqual(
assert.strictEqual(headers.length, 0); 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 type { AttachmentMetadata } from "@/attachments/types";
import { extractTaskEntriesFromToolCall } from "../utils/tool-call-parsers"; import { extractTaskEntriesFromToolCall } from "../utils/tool-call-parsers";
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
import {
appendLiveTurnHeader,
completeLastTurnHeader,
synthesizeMissingTurnHeaders,
} from "@/timeline/turn-time";
/** /**
* Simple hash function for deterministic ID generation * Simple hash function for deterministic ID generation
@@ -55,21 +50,7 @@ export type StreamItem =
| ToolCallItem | ToolCallItem
| TodoListItem | TodoListItem
| ActivityLogItem | ActivityLogItem
| CompactionItem | 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";
}
export type UserMessageImageAttachment = AttachmentMetadata; export type UserMessageImageAttachment = AttachmentMetadata;
@@ -723,15 +704,10 @@ export function reduceStreamUpdate(
case "timeline": case "timeline":
return reduceTimelineEvent(state, event, timestamp, source); return reduceTimelineEvent(state, event, timestamp, source);
case "thread_started": case "thread_started":
return finalizeActiveThoughts(state);
case "turn_started": case "turn_started":
return appendLiveTurnHeader(finalizeActiveThoughts(state), timestamp);
case "turn_completed": case "turn_completed":
return completeLastTurnHeader(finalizeActiveThoughts(state), timestamp, "completed");
case "turn_failed": case "turn_failed":
return completeLastTurnHeader(finalizeActiveThoughts(state), timestamp, "failed");
case "turn_canceled": case "turn_canceled":
return completeLastTurnHeader(finalizeActiveThoughts(state), timestamp, "canceled");
case "permission_requested": case "permission_requested":
case "permission_resolved": case "permission_resolved":
case "attention_required": case "attention_required":
@@ -755,7 +731,7 @@ export function hydrateStreamState(
return reduceStreamUpdate(state, event, timestamp, options); 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 () => { test("preserves Codex app-server assistant item ids in persisted history", async () => {
const session = createSession(); const session = createSession();
session.client = { 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 { function codexImageOutputFromResult(result: unknown): ProviderImageOutput | null {
if (typeof result === "string") { if (typeof result === "string") {
const trimmed = result.trim(); const trimmed = result.trim();
@@ -1616,7 +1638,8 @@ async function loadCodexThreadHistoryTimeline(params: {
for (const item of turn.items) { for (const item of turn.items) {
const timelineItem = threadItemToTimeline(item, { cwd: params.cwd }); const timelineItem = threadItemToTimeline(item, { cwd: params.cwd });
if (timelineItem) { if (timelineItem) {
const timestamp = readCodexHistoryTimestamp(item); const timestamp =
readCodexHistoryTimestamp(item) ?? readCodexTurnHistoryTimestamp(turn, timelineItem);
timeline.push({ timeline.push({
item: timelineItem, item: timelineItem,
timestamp: timestamp ?? undefined, timestamp: timestamp ?? undefined,