Fix agent turn timing persistence
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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"],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user