diff --git a/README.md b/README.md index 4e566c532..d04bae51c 100644 --- a/README.md +++ b/README.md @@ -691,6 +691,8 @@ Streaming Markdown responses animate only newly arrived words; text that is alre In the Web UI, completed assistant turns can be branched into a new main conversation. The new thread starts from that turn's checkpoint. Because workspace files are not checkpointed, the branch only receives a best-effort copy of the current workspace when you branch from the latest turn; branching from an older turn keeps just the restored message history so the branch never inherits files that were created in a later part of the conversation. +The Web UI reports completed task time once per run. This is total wall-clock time—including model reasoning, tool calls, and waiting—not a per-step or model-only thinking duration. Reasoning content remains available through its own separate disclosure. + Web UI chat links percent-encode custom thread identifiers before placing them in route segments, so reserved URL characters such as `#` and `?` do not change which conversation is opened. ``` diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index dd960b549..0720af356 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -71,6 +71,8 @@ The frontend is a stateful chat application. Users create **threads** (conversat 5. TanStack Query manages server state; localStorage stores user settings 6. Components subscribe to thread state and render updates +Run duration is run-scoped UI metadata even though the compatibility field `additional_kwargs.turn_duration` is repeated on historical AI messages. `core/messages/run-duration.ts` folds those copies into one display anchored after the run's last visible message group. `MessageList` owns the temporary client-side duration for a just-completed live turn until authoritative history arrives. The duration is total run wall-clock time, not per-message reasoning time; reasoning disclosure and run activity/duration are rendered separately. + Composer drafts are tab-scoped browser state. `core/threads/composer-draft.ts` stores only text plus the selected slash-skill name in `sessionStorage`, keyed by user, agent, and logical conversation scope. New-chat pages pass the stable scope `"new"` because their runtime `threadId` is a fresh UUID on every reload; established conversations use their real thread ID. `InputBox` waits for enabled skills before restoring a skill chip, degrades a missing/disabled skill back to editable slash text, and clears the stored draft through `SendMessageOptions.onSent` only after the send passes the in-flight guard. Attachments, sidecar quotes, voice state, and polish undo state are not persisted. Auth UI note: the login page's "keep me signed in" option submits only `remember_me` to the Gateway and may persist only the email address through `core/auth/remember-login.ts`. Passwords and tokens must never be stored in frontend storage; the `HttpOnly access_token` and readable `csrf_token` cookies remain Gateway-owned. diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index 0b28bfc2a..0141fe111 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -10,7 +10,6 @@ import { useCallback, useMemo, useState, - useEffect, type ImgHTMLAttributes, } from "react"; @@ -24,6 +23,7 @@ import { Reasoning, ReasoningTrigger, } from "@/components/ai-elements/reasoning"; +import { Shimmer } from "@/components/ai-elements/shimmer"; import { Task, TaskTrigger } from "@/components/ai-elements/task"; import { Badge } from "@/components/ui/badge"; import { @@ -139,7 +139,6 @@ export function MessageListItem({ threadId, artifactPaths = [], showCopyButton = true, - turnStartTime, }: { className?: string; message: Message; @@ -149,7 +148,6 @@ export function MessageListItem({ feedback?: FeedbackData | null; runId?: string; showCopyButton?: boolean; - turnStartTime?: number | null; }) { const isHuman = message.type === "human"; return ( @@ -164,7 +162,6 @@ export function MessageListItem({ threadId={threadId} artifactPaths={artifactPaths} runId={runId} - turnStartTime={turnStartTime} /> {!isLoading && showCopyButton && ( (); - function HumanMessageText({ content }: { content: string }) { // `parseSlashSkillReference` is a pure regex gate (no data subscription), so // the overwhelmingly common plain-text human message never subscribes to the @@ -284,7 +279,6 @@ function MessageContent_({ threadId, artifactPaths, runId, - turnStartTime, }: { className?: string; message: Message; @@ -292,52 +286,18 @@ function MessageContent_({ threadId: string; artifactPaths: readonly string[]; runId?: string; - turnStartTime?: number | null; }) { + const { t } = useI18n(); const isHuman = message.type === "human"; - const rawTurnDuration = message.additional_kwargs?.turn_duration as - | number - | undefined; - - const [cachedDuration, setCachedDuration] = useState( - () => - message.id - ? clientTurnDurations.get(`${threadId}:${message.id}`) - : undefined, + const getReasoningMessage = useCallback( + (isStreaming: boolean) => + isStreaming ? ( + {t.runDuration.reasoning} + ) : ( + t.runDuration.reasoning + ), + [t.runDuration.reasoning], ); - const turnDuration = rawTurnDuration ?? cachedDuration; - - useEffect(() => { - if (rawTurnDuration !== undefined && message.id) { - clientTurnDurations.set(`${threadId}:${message.id}`, rawTurnDuration); - setCachedDuration(rawTurnDuration); - } - }, [rawTurnDuration, message.id, threadId]); - - const handleDurationChange = useCallback( - (d: number | undefined) => { - if (d !== undefined && message.id) { - clientTurnDurations.set(`${threadId}:${message.id}`, d); - setCachedDuration(d); - } - }, - [message.id, threadId], - ); - - useEffect(() => { - return () => { - for (const key of clientTurnDurations.keys()) { - if (key.startsWith(`${threadId}:`)) { - clientTurnDurations.delete(key); - } - } - }; - }, [threadId]); - - const [wasLoading, setWasLoading] = useState(isLoading); - useEffect(() => { - if (isLoading) setWasLoading(true); - }, [isLoading]); const components = useMemo( () => ({ img: (props: ImgHTMLAttributes) => ( @@ -414,13 +374,8 @@ function MessageContent_({ if (!isHuman && reasoningContent && !rawContent) { return ( - - + + {reasoningContent} @@ -459,20 +414,12 @@ function MessageContent_({ return ( {filesList} - {!isHuman && - (!!reasoningContent || wasLoading || turnDuration !== undefined) && ( - - - {reasoningContent && ( - {reasoningContent} - )} - - )} + {reasoningContent && ( + + + {reasoningContent} + + )} next.messages[index] !== undefined && - sameMessageIdentity(message, next.messages[index]), + sameMessageIdentity(message, next.messages[index]) && + sameRunDurationMetadata(message, next.messages[index]), ); } @@ -360,19 +373,50 @@ export function MessageList({ const sidecar = useMaybeSidecar(); const [selectionToolbar, setSelectionToolbar] = useState(null); - const [turnStartTime, setTurnStartTime] = useState(null); + const messages = thread.messages; + const groupedMessages = useStableMessageGroups(messages, thread.isLoading); + const browserView = useMaybeBrowserView(); + const pushBrowserFrame = browserView?.pushFrame; + const messageCount = messages.length; + // The backend exposes no live start timestamp, so a mid-run mount measures + // from mount until authoritative persisted turn_duration replaces it. + const [turnStartTime, setTurnStartTime] = useState(() => + thread.isLoading ? Date.now() : null, + ); + const turnStartTimeRef = useRef(turnStartTime); + const [clientDurationsByGroupId, setClientDurationsByGroupId] = useState< + ReadonlyMap + >(() => new Map()); const prevIsLoading = useRef(thread.isLoading); useEffect(() => { if (thread.isLoading && !prevIsLoading.current) { - setTurnStartTime(Date.now()); + const now = Date.now(); + turnStartTimeRef.current = now; + setTurnStartTime(now); + } else if (!thread.isLoading && prevIsLoading.current) { + const startTime = turnStartTimeRef.current; + const lastAssistantGroup = [...groupedMessages] + .reverse() + .find((group) => group.type !== "human" && group.id); + if (startTime !== null && lastAssistantGroup?.id && !thread.error) { + const duration = Math.max( + 0, + Math.floor((Date.now() - startTime) / 1000), + ); + const key = `${threadId}:${lastAssistantGroup.id}`; + setClientDurationsByGroupId((current) => { + const next = new Map(current); + next.set(key, duration); + return next; + }); + } + turnStartTimeRef.current = null; + setTurnStartTime(null); } prevIsLoading.current = thread.isLoading; - }, [thread.isLoading]); - const messages = thread.messages; - const browserView = useMaybeBrowserView(); - const pushBrowserFrame = browserView?.pushFrame; - const messageCount = messages.length; + }, [groupedMessages, thread.error, thread.isLoading, threadId]); + useEffect(() => { // Only the primary chat surface drives the shared browser panel. The // sidecar renders a different thread's messages against the same @@ -410,7 +454,6 @@ export function MessageList({ // repeatedly scan long history looking for the last browser frame. // eslint-disable-next-line react-hooks/exhaustive-deps }, [messageCount, pushBrowserFrame, sidecarSurface]); - const groupedMessages = useStableMessageGroups(messages, thread.isLoading); const [regeneratingMessageId, setRegeneratingMessageId] = useState< string | null >(null); @@ -437,6 +480,28 @@ export function MessageList({ const lastGroupIndex = groupedMessages.length - 1; const turnUsageMessagesByGroupIndex = getAssistantTurnUsageMessages(groupedMessages); + const runDurationDisplaysByGroupIndex = useMemo( + () => getRunDurationDisplaysByGroupIndex(groupedMessages), + [groupedMessages], + ); + useEffect(() => { + setClientDurationsByGroupId((current) => { + let next: Map | undefined; + runDurationDisplaysByGroupIndex.forEach((displays, groupIndex) => { + const groupId = groupedMessages[groupIndex]?.id; + if (displays.length === 0 || !groupId) { + return; + } + const key = `${threadId}:${groupId}`; + if (!current.has(key)) { + return; + } + next ??= new Map(current); + next.delete(key); + }); + return next ?? current; + }); + }, [groupedMessages, runDurationDisplaysByGroupIndex, threadId]); const tokenDebugSteps = useMemo( () => tokenUsageInlineMode === "step_debug" @@ -886,6 +951,47 @@ export function MessageList({ return ; } + const withRunDuration = ( + group: (typeof groupedMessages)[number], + groupIndex: number, + content: ReactNode, + ) => { + const persistedDisplays = runDurationDisplaysByGroupIndex[groupIndex] ?? []; + const clientDuration = + !thread.error && group.id + ? clientDurationsByGroupId.get(`${threadId}:${group.id}`) + : undefined; + const displays = + persistedDisplays.length > 0 + ? persistedDisplays + : clientDuration !== undefined + ? [ + { + runId: `client:${group.id}`, + durationSeconds: clientDuration, + }, + ] + : []; + + if (!content && displays.length === 0) { + return null; + } + + return ( +
+ {content} + {displays.map((display) => ( + + ))} +
+ ); + }; return ( <> ); @@ -979,7 +1081,7 @@ export function MessageList({ branchableAssistantGroupIds.has(group.id), group.id === latestAssistantGroupId, )} - + , ); } else if (group.type === "assistant:clarification") { const message = group.messages[0]; @@ -996,8 +1098,10 @@ export function MessageList({ const pending = pendingHumanInputRequestIds.has( humanInputRequest.request_id, ); - return ( -
+ return withRunDuration( + group, + groupIndex, +
+
, ); } if (hasContent(message)) { - return ( -
+ return withRunDuration( + group, + groupIndex, +
+
, ); } - return null; + return withRunDuration(group, groupIndex, null); } else if (group.type === "assistant:present-files") { const files: string[] = []; for (const message of group.messages) { @@ -1051,8 +1157,10 @@ export function MessageList({ files.push(...presentFiles); } } - return ( -
+ return withRunDuration( + group, + groupIndex, +
{group.messages[0] && hasContent(group.messages[0]) && ( +
, ); } else if (group.type === "assistant:subagent") { const tasks = new Set(); @@ -1152,22 +1260,23 @@ export function MessageList({ ); } } - return ( -
+ return withRunDuration( + group, + groupIndex, +
{results} {renderTokenUsage({ messages: group.messages, turnUsageMessages, debugMessageIds: subagentDebugMessageIds, })} -
+
, ); } - return ( -
+ return withRunDuration( + group, + groupIndex, +
+
, ); })} {thread.isLoading && !hasActiveAssistantText && (
- - - +
)}
diff --git a/frontend/src/components/workspace/messages/run-duration.tsx b/frontend/src/components/workspace/messages/run-duration.tsx new file mode 100644 index 000000000..25dfb1e2e --- /dev/null +++ b/frontend/src/components/workspace/messages/run-duration.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { Clock3Icon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { Shimmer } from "@/components/ai-elements/shimmer"; +import { useI18n } from "@/core/i18n/hooks"; +import { formatRunDuration } from "@/core/messages/run-duration"; + +export function RunActivity({ startTime }: { startTime: number | null }) { + const { t } = useI18n(); + const [elapsed, setElapsed] = useState(0); + + useEffect(() => { + if (startTime === null) { + setElapsed(0); + return; + } + + const updateElapsed = () => { + setElapsed(Math.max(0, Math.floor((Date.now() - startTime) / 1000))); + }; + updateElapsed(); + const interval = setInterval(updateElapsed, 1000); + return () => clearInterval(interval); + }, [startTime]); + + const formatted = formatRunDuration(elapsed, t.runDuration); + + return ( +
+ + {t.runDuration.working} + {formatted && } +
+ ); +} + +export function RunDuration({ durationSeconds }: { durationSeconds: number }) { + const { t } = useI18n(); + const formatted = formatRunDuration(durationSeconds, t.runDuration); + if (!formatted) { + return null; + } + + return ( +
+ + {t.runDuration.completedIn(formatted)} +
+ ); +} diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index c9fa25538..e35269af4 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -57,6 +57,19 @@ export const enUS: Translations = { showBrowser: "Open browser panel", }, + runDuration: { + reasoning: "Reasoning", + working: "Working…", + completedIn: (duration) => `Completed in ${duration}`, + description: + "Total task time, including model reasoning, tool calls, and waiting.", + lessThanSecond: "<1s", + hours: (value) => `${value}h`, + minutes: (value) => `${value}m`, + seconds: (value) => `${value}s`, + separator: " ", + }, + // Home home: { docs: "Docs", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 0931cd54e..529b34a48 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -46,6 +46,18 @@ export interface Translations { showBrowser: string; }; + runDuration: { + reasoning: string; + working: string; + completedIn: (duration: string) => string; + description: string; + lessThanSecond: string; + hours: (value: number) => string; + minutes: (value: number) => string; + seconds: (value: number) => string; + separator: string; + }; + home: { docs: string; blog: string; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 16f653aef..87723536a 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -57,6 +57,18 @@ export const zhCN: Translations = { showBrowser: "打开浏览器面板", }, + runDuration: { + reasoning: "思考过程", + working: "执行中…", + completedIn: (duration) => `本次任务耗时 ${duration}`, + description: "任务总耗时,包括模型推理、工具调用和等待时间。", + lessThanSecond: "不足 1 秒", + hours: (value) => `${value} 小时`, + minutes: (value) => `${value} 分`, + seconds: (value) => `${value} 秒`, + separator: " ", + }, + // Home home: { docs: "文档", diff --git a/frontend/src/core/messages/run-duration.ts b/frontend/src/core/messages/run-duration.ts new file mode 100644 index 000000000..b62ac4e72 --- /dev/null +++ b/frontend/src/core/messages/run-duration.ts @@ -0,0 +1,104 @@ +import type { Message } from "@langchain/langgraph-sdk"; + +import type { MessageGroup } from "./utils"; + +export interface RunDurationDisplay { + runId: string; + durationSeconds: number; +} + +export interface RunDurationFormatter { + lessThanSecond: string; + hours: (value: number) => string; + minutes: (value: number) => string; + seconds: (value: number) => string; + separator: string; +} + +type MessageWithRunId = Message & { run_id?: unknown }; + +export function getMessageRunId(message: Message): string | undefined { + const runId = (message as MessageWithRunId).run_id; + return typeof runId === "string" && runId.length > 0 ? runId : undefined; +} + +function normalizeDuration(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return undefined; + } + return Math.floor(value); +} + +/** + * Locate the single UI position that owns each completed run's wall-clock + * duration. The backend keeps the value on every AI message for compatibility, + * but the UI treats it as run-scoped metadata and renders it after the last + * visible group belonging to that run. + */ +export function getRunDurationDisplaysByGroupIndex( + groups: MessageGroup[], +): RunDurationDisplay[][] { + const displays = groups.map(() => [] as RunDurationDisplay[]); + const durationByRunId = new Map(); + const lastGroupIndexByRunId = new Map(); + + groups.forEach((group, groupIndex) => { + for (const message of group.messages) { + const runId = getMessageRunId(message); + if (!runId) { + continue; + } + + lastGroupIndexByRunId.set(runId, groupIndex); + if (message.type !== "ai") { + continue; + } + + const duration = normalizeDuration( + message.additional_kwargs?.turn_duration, + ); + if (duration !== undefined) { + durationByRunId.set(runId, duration); + } + } + }); + + for (const [runId, durationSeconds] of durationByRunId) { + const groupIndex = lastGroupIndexByRunId.get(runId); + if (groupIndex !== undefined) { + displays[groupIndex]?.push({ runId, durationSeconds }); + } + } + + return displays; +} + +export function formatRunDuration( + value: number, + formatter: RunDurationFormatter, +): string | null { + const duration = normalizeDuration(value); + if (duration === undefined) { + return null; + } + if (duration === 0) { + return formatter.lessThanSecond; + } + + const hours = Math.floor(duration / 3600); + const minutes = Math.floor((duration % 3600) / 60); + const seconds = duration % 60; + const parts: string[] = []; + + if (hours > 0) { + parts.push(formatter.hours(hours)); + } + if (minutes > 0) { + parts.push(formatter.minutes(minutes)); + } + if (seconds > 0) { + parts.push(formatter.seconds(seconds)); + } + + return parts.join(formatter.separator); +} diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index a0f96c0fd..023a66809 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -18,6 +18,7 @@ import { getAPIClient } from "../api"; import { fetch } from "../api/fetcher"; import { getBackendBaseURL } from "../config"; import { useI18n } from "../i18n/hooks"; +import { getMessageRunId } from "../messages/run-duration"; import { isHiddenFromUIMessage } from "../messages/utils"; import type { FileInMessage } from "../messages/utils"; import type { LocalSettings } from "../settings"; @@ -316,8 +317,13 @@ export function mergeMessages( optimisticMessages: Message[], ): Message[] { const savedTurnDurations = new Map(); + const savedRunIds = new Map(); for (const msg of historyMessages) { const identity = messageIdentity(msg); + const runId = getMessageRunId(msg); + if (identity && runId) { + savedRunIds.set(identity, runId); + } if (identity && msg.additional_kwargs?.turn_duration !== undefined) { savedTurnDurations.set( identity, @@ -412,17 +418,26 @@ export function mergeMessages( return merged.map((message) => { const identity = messageIdentity(message); - if ( - identity && + if (!identity) { + return message; + } + const shouldRestoreRunId = + savedRunIds.has(identity) && !getMessageRunId(message); + const shouldRestoreTurnDuration = savedTurnDurations.has(identity) && - message.additional_kwargs?.turn_duration === undefined - ) { + message.additional_kwargs?.turn_duration === undefined; + if (shouldRestoreRunId || shouldRestoreTurnDuration) { return { ...message, - additional_kwargs: { - ...message.additional_kwargs, - turn_duration: savedTurnDurations.get(identity), - }, + ...(shouldRestoreRunId ? { run_id: savedRunIds.get(identity) } : {}), + ...(shouldRestoreTurnDuration + ? { + additional_kwargs: { + ...message.additional_kwargs, + turn_duration: savedTurnDurations.get(identity), + }, + } + : {}), } as Message; } return message; diff --git a/frontend/tests/e2e/thread-history.spec.ts b/frontend/tests/e2e/thread-history.spec.ts index 9e55367f5..3ed9b397d 100644 --- a/frontend/tests/e2e/thread-history.spec.ts +++ b/frontend/tests/e2e/thread-history.spec.ts @@ -94,6 +94,54 @@ test.describe("Thread history", () => { ).toBeVisible({ timeout: 15_000 }); }); + test("shows a completed run duration once after multi-step history", async ({ + page, + }) => { + mockLangGraphAPI(page, { + threads: [ + { + thread_id: MOCK_THREAD_ID, + title: "Multi-step duration", + updated_at: "2025-06-03T12:00:00Z", + messages: [ + { + type: "human", + id: "msg-human-duration", + content: [{ type: "text", text: "Complete several steps" }], + }, + { + type: "ai", + id: "msg-ai-duration-1", + content: "Intermediate result", + additional_kwargs: { turn_duration: 114 }, + }, + { + type: "ai", + id: "msg-ai-duration-2", + content: "Final result", + additional_kwargs: { + turn_duration: 114, + reasoning_content: "Final synthesis reasoning", + }, + }, + ], + }, + ], + }); + + await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`); + await expect(page.getByText("Final result")).toBeVisible({ + timeout: 15_000, + }); + + await expect(page.getByTestId("run-duration")).toHaveCount(1); + await expect(page.getByText("Completed in 1m 54s")).toBeVisible(); + await expect( + page.getByRole("button", { name: "Reasoning", exact: true }), + ).toBeVisible(); + await expect(page.getByText("Thought for 114 seconds")).toHaveCount(0); + }); + test("input box recalls previous prompts with arrow keys", async ({ page, }) => { diff --git a/frontend/tests/unit/core/messages/run-duration.test.ts b/frontend/tests/unit/core/messages/run-duration.test.ts new file mode 100644 index 000000000..cd339d7d6 --- /dev/null +++ b/frontend/tests/unit/core/messages/run-duration.test.ts @@ -0,0 +1,138 @@ +import type { Message } from "@langchain/langgraph-sdk"; +import { describe, expect, test } from "@rstest/core"; + +import { enUS } from "@/core/i18n/locales/en-US"; +import { zhCN } from "@/core/i18n/locales/zh-CN"; +import { + formatRunDuration, + getRunDurationDisplaysByGroupIndex, +} from "@/core/messages/run-duration"; +import { getMessageGroups } from "@/core/messages/utils"; + +function message( + id: string, + type: Message["type"], + content: string, + runId?: string, + duration?: unknown, +): Message { + return { + id, + type, + content, + ...(runId ? { run_id: runId } : {}), + ...(type === "ai" && duration !== undefined + ? { additional_kwargs: { turn_duration: duration } } + : {}), + } as Message; +} + +describe("run duration display placement", () => { + test("shows one duration after the final visible group for a multi-step run", () => { + const groups = getMessageGroups([ + message("human-1", "human", "Research this"), + { + ...message("ai-tool-1", "ai", "", "run-1", 114), + tool_calls: [{ id: "call-1", name: "read_file", args: {} }], + } as Message, + { + ...message("tool-1", "tool", "file contents", "run-1"), + tool_call_id: "call-1", + } as Message, + message("ai-middle", "ai", "Intermediate summary", "run-1", 114), + { + ...message("ai-tool-2", "ai", "", "run-1", 114), + tool_calls: [{ id: "call-2", name: "write_todos", args: {} }], + } as Message, + { + ...message("tool-2", "tool", "todos updated", "run-1"), + tool_call_id: "call-2", + } as Message, + message("ai-final", "ai", "Final answer", "run-1", 114), + ]); + + expect( + getRunDurationDisplaysByGroupIndex(groups).map((displays) => + displays.map(({ runId, durationSeconds }) => ({ + runId, + durationSeconds, + })), + ), + ).toEqual([[], [], [], [], [{ runId: "run-1", durationSeconds: 114 }]]); + }); + + test("keeps equal durations independent across runs", () => { + const groups = getMessageGroups([ + message("human-1", "human", "First"), + message("ai-1", "ai", "First answer", "run-1", 114), + message("human-2", "human", "Second"), + message("ai-2", "ai", "Second answer", "run-2", 114), + ]); + + expect(getRunDurationDisplaysByGroupIndex(groups)).toEqual([ + [], + [{ runId: "run-1", durationSeconds: 114 }], + [], + [{ runId: "run-2", durationSeconds: 114 }], + ]); + }); + + test("carries an earlier AI duration to the run's final tool-bearing group", () => { + const groups = getMessageGroups([ + message("human-1", "human", "Do it"), + message("ai-answer", "ai", "Initial answer", "run-1", 9), + { + ...message("ai-tool", "ai", "", "run-1"), + tool_calls: [{ id: "call-1", name: "write_todos", args: {} }], + } as Message, + { + ...message("tool-1", "tool", "done", "run-1"), + tool_call_id: "call-1", + } as Message, + ]); + + expect(getRunDurationDisplaysByGroupIndex(groups)).toEqual([ + [], + [], + [{ runId: "run-1", durationSeconds: 9 }], + ]); + }); + + test("keeps zero but ignores missing, negative, and non-finite durations", () => { + const groups = getMessageGroups([ + message("ai-zero", "ai", "Zero", "run-zero", 0), + message("ai-missing", "ai", "Missing", "run-missing"), + message("ai-negative", "ai", "Negative", "run-negative", -1), + message("ai-infinite", "ai", "Infinite", "run-infinite", Infinity), + ]); + + expect(getRunDurationDisplaysByGroupIndex(groups)).toEqual([ + [{ runId: "run-zero", durationSeconds: 0 }], + [], + [], + [], + ]); + }); +}); + +describe("run duration formatting", () => { + test("formats sub-second, second, minute, and hour durations in English", () => { + expect(formatRunDuration(0, enUS.runDuration)).toBe("<1s"); + expect(formatRunDuration(59, enUS.runDuration)).toBe("59s"); + expect(formatRunDuration(114, enUS.runDuration)).toBe("1m 54s"); + expect(formatRunDuration(3723, enUS.runDuration)).toBe("1h 2m 3s"); + }); + + test("formats durations in Chinese", () => { + expect(formatRunDuration(0, zhCN.runDuration)).toBe("不足 1 秒"); + expect(formatRunDuration(114, zhCN.runDuration)).toBe("1 分 54 秒"); + expect(formatRunDuration(3723, zhCN.runDuration)).toBe("1 小时 2 分 3 秒"); + }); + + test("rejects invalid durations and floors fractional seconds", () => { + expect(formatRunDuration(-1, enUS.runDuration)).toBeNull(); + expect(formatRunDuration(Infinity, enUS.runDuration)).toBeNull(); + expect(formatRunDuration(Number.NaN, enUS.runDuration)).toBeNull(); + expect(formatRunDuration(61.9, enUS.runDuration)).toBe("1m 1s"); + }); +}); diff --git a/frontend/tests/unit/core/threads/message-merge.test.ts b/frontend/tests/unit/core/threads/message-merge.test.ts index 470aa2c42..3162ccef7 100644 --- a/frontend/tests/unit/core/threads/message-merge.test.ts +++ b/frontend/tests/unit/core/threads/message-merge.test.ts @@ -96,6 +96,39 @@ test("mergeMessages lets live thread messages replace overlapping history", () = ]); }); +test("mergeMessages preserves historical run metadata on a live checkpoint replacement", () => { + const persistedAi = { + id: "ai-1", + type: "ai", + content: "persisted", + additional_kwargs: { turn_duration: 114 }, + } as Message; + const history = buildVisibleHistoryMessages( + [ + { + run_id: "run-1", + content: persistedAi, + metadata: { caller: "lead_agent" }, + created_at: "2026-07-21T00:00:00Z", + }, + ], + new Set(), + ); + const checkpointAi = { + id: "ai-1", + type: "ai", + content: "live checkpoint", + } as Message; + + expect(mergeMessages(history, [checkpointAi], [])).toEqual([ + { + ...checkpointAi, + run_id: "run-1", + additional_kwargs: { turn_duration: 114 }, + }, + ]); +}); + test("mergeMessages keeps a protected pre-compression input at its canonical position", () => { const canonicalInput = { id: "input-1",