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