From b97ba8e2adfb4ceff3a1501e44108f8be7034fdc Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 4 Sep 2026 16:00:54 +0800 Subject: [PATCH 1/4] fix(vis): align with current agent-core-v2 --- apps/vis/server/src/lib/agent-record-types.ts | 28 +- apps/vis/server/src/lib/context-projector.ts | 465 ++++++++---------- apps/vis/server/src/lib/import-store.ts | 23 +- apps/vis/server/src/lib/session-store.ts | 52 +- apps/vis/server/src/lib/task-store.ts | 105 +++- apps/vis/server/src/lib/wire-reader.ts | 92 +++- apps/vis/server/src/routes/tasks.ts | 43 +- apps/vis/server/test/lib/agent-tree.test.ts | 3 +- .../server/test/lib/context-projector.test.ts | 190 ++++++- apps/vis/server/test/lib/import-store.test.ts | 15 +- .../vis/server/test/lib/session-store.test.ts | 45 +- apps/vis/server/test/lib/task-store.test.ts | 61 +++ apps/vis/server/test/lib/wire-reader.test.ts | 61 +++ apps/vis/server/test/routes/tasks.test.ts | 44 ++ .../src/components/analysis/TimelineTab.tsx | 28 +- .../web/src/components/context/ContextTab.tsx | 16 +- .../vis/web/src/components/state/StateTab.tsx | 18 +- .../src/components/subagents/SubagentNode.tsx | 5 + .../vis/web/src/components/tasks/TasksTab.tsx | 3 + apps/vis/web/src/components/wire/WireTab.tsx | 1 + .../vis/web/src/components/wire/renderers.tsx | 149 +++++- apps/vis/web/src/lib/analysis.ts | 70 ++- apps/vis/web/src/pages/SessionDetailPage.tsx | 10 +- apps/vis/web/src/pages/SubagentDetailPage.tsx | 5 + apps/vis/web/src/util/time.ts | 7 + apps/vis/web/test/analysis.test.ts | 40 ++ apps/vis/web/test/renderers.test.ts | 35 ++ apps/vis/web/test/time.test.ts | 11 + vitest.config.ts | 15 +- 29 files changed, 1224 insertions(+), 416 deletions(-) create mode 100644 apps/vis/web/test/renderers.test.ts create mode 100644 apps/vis/web/test/time.test.ts diff --git a/apps/vis/server/src/lib/agent-record-types.ts b/apps/vis/server/src/lib/agent-record-types.ts index f9082ba6c85..0cce49b3933 100644 --- a/apps/vis/server/src/lib/agent-record-types.ts +++ b/apps/vis/server/src/lib/agent-record-types.ts @@ -30,6 +30,9 @@ import type { CronCursorPayload, CronDeletePayload, CronTask, + ExportSessionManifest, + FileHistoryCheckpointed, + FileHistoryTracked, FullCompactionBegin, FullCompactionCancel, FullCompactionComplete, @@ -155,6 +158,8 @@ export type AgentRecord = | WireRecordOf<'cron.add', CronAddPayload> | WireRecordOf<'cron.cursor', CronCursorPayload> | WireRecordOf<'cron.delete', CronDeletePayload> + | WireRecordOf<'file_history.checkpoint', FileHistoryCheckpointed> + | WireRecordOf<'file_history.tracked', FileHistoryTracked> | WireRecordOf<'forked', GoalForked> | WireRecordOf<'full_compaction.begin', FullCompactionBegin> | WireRecordOf<'full_compaction.cancel', FullCompactionCancel> @@ -217,26 +222,10 @@ export type AgentRecordOf = Extract< /** * `manifest.json` shape inside a `/export-debug-zip` bundle. Structural - * mirror of the engine's `ExportSessionManifest`, which is not re-exported - * from the package entry. All fields optional-tolerant because the manifest - * comes from another machine / kimi-code version. + * current engine manifest with every field optional because the bundle may + * come from another machine or an older kimi-code version. */ -export interface ImportManifest { - sessionId?: string; - exportedAt?: string; - kimiCodeVersion?: string; - wireProtocolVersion?: string; - os?: string; - nodejsVersion?: string; - sessionFirstActivity?: string; - sessionLastActivity?: string; - title?: string; - workspaceDir?: string; - sessionLogPath?: string; - globalLogPath?: string; - installSource?: string; - shellEnv?: unknown; -} +export type ImportManifest = Partial; /** vis-side bookkeeping for one imported bundle, written to * `imported//import-meta.json`. */ @@ -294,6 +283,7 @@ export interface AgentInfo { agentId: string; type: 'main' | 'sub' | 'independent'; parentAgentId: string | null; + profileName: string | null; homedir: string; wireExists: boolean; wireRecordCount: number; diff --git a/apps/vis/server/src/lib/context-projector.ts b/apps/vis/server/src/lib/context-projector.ts index 292c1618348..8ba926295d8 100644 --- a/apps/vis/server/src/lib/context-projector.ts +++ b/apps/vis/server/src/lib/context-projector.ts @@ -1,20 +1,18 @@ import { - COMPACT_USER_MESSAGE_MAX_TOKENS, - COMPACTION_ELISION_VARIANT, - buildCompactionElisionText, - collectCompactableUserMessages, - isRealUserInput, - selectCompactionUserMessages, - selectRecentUserMessages, + buildContextCompactionShape, } from '@moonshot-ai/agent-core-v2/agent/contextMemory/compactionHandoff'; -import { estimateTokensForMessages } from '@moonshot-ai/agent-core-v2/kosong/contract/tokens'; +import { + computeUndoCut, + isFullyUndoable, + readContextCompactionShapeInput, +} from '@moonshot-ai/agent-core-v2/agent/contextMemory/contextOps'; +import { createLoopEventFold } from '@moonshot-ai/agent-core-v2/agent/contextMemory/loopEventFold'; import { renderToolResultForModel } from '@moonshot-ai/agent-core-v2/agent/contextMemory/toolResultRender'; import type { ContentPart, ContextMessage, PermissionMode, TokenUsage, - ToolCall, WireEntry, } from './agent-record-types'; @@ -80,12 +78,13 @@ const ZERO: TokenUsage = { inputOther: 0, output: 0, inputCacheRead: 0, inputCac * * - `context.append_message` records become messages as-is (the * user / tool messages and any explicit assistant injections). - * - `step.begin` pushes a fresh assistant message; later - * `content.part` and `tool.call` events on the same step **mutate - * that same message** to grow its content / toolCalls. `step.end` - * just closes the step. - * - `tool.result` events emit an independent `role: 'tool'` message, - * matching how the engine surfaces tool exchanges to the model. + * - `step.begin` settles a preceding attempt and opens a fresh assistant; + * later `content.part` and `tool.call` events on the same step grow that + * message. A normal `step.end` seals it (or drops it when vacuous), while + * interrupted/error steps stay partial until the next attempt. + * - pending tool calls defer appended messages; matching `tool.result` + * events close them, and an attempt that settles first gets synthetic + * interrupted results, exactly like engine replay. * * Without this loop-event reconstruction the timeline would only * show user prompts — the engine does not emit a synthetic @@ -112,6 +111,7 @@ export function projectContext( mode: 'model' | 'full' = 'model', ): ContextProjection { let messages: ProjectedMessage[] = []; + let modelMessages: ProjectedMessage[] = []; const usage: UsageTotals = { byScope: { session: { ...ZERO }, turn: { ...ZERO } }, byModel: {}, @@ -124,61 +124,119 @@ export function projectContext( let goal: GoalSnapshot | null = null; let swarm: { active: boolean; trigger?: string } = { active: false }; let microCutoff = 0; - // Maps step.uuid → the assistant ProjectedMessage that step is filling in. - // Cleared on context.clear / context.apply_compaction. - let openSteps = new Map(); + let currentEntry: WireEntry | undefined; + let openMessage: ProjectedMessage | undefined; + let syntheticToolOrdinal = 0; + const appendMessageEntries = new WeakMap(); + + const pushModelMessage = (message: ProjectedMessage): void => { + modelMessages.push(message); + messages.push(message); + }; + + const removeModelMessage = (message: ProjectedMessage): void => { + const modelIndex = modelMessages.indexOf(message); + if (modelIndex !== -1) modelMessages.splice(modelIndex, 1); + const displayIndex = messages.indexOf(message); + if (displayIndex !== -1) messages.splice(displayIndex, 1); + }; + + const currentLineNo = (): number => currentEntry?.lineNo ?? 0; + + const fold = createLoopEventFold({ + openAssistant: (time) => { + const event = currentEntry?.data; + const stepUuid = + event?.type === 'context.append_loop_event' && event.event.type === 'step.begin' + ? event.event.uuid + : undefined; + openMessage = { + lineNo: currentLineNo(), + time, + source: 'append_message', + message: { role: 'assistant', content: [], toolCalls: [], partial: true }, + toolStepUuids: stepUuid === undefined ? [] : [stepUuid], + }; + pushModelMessage(openMessage); + }, + appendOpenContent: (part) => { + if (openMessage === undefined) return; + openMessage.message = { + ...openMessage.message, + content: [...openMessage.message.content, part], + }; + }, + appendOpenToolCall: (call) => { + if (openMessage === undefined) return; + openMessage.message = { + ...openMessage.message, + toolCalls: [...openMessage.message.toolCalls, call], + }; + }, + dropOpenAssistant: () => { + if (openMessage === undefined) return; + removeModelMessage(openMessage); + openMessage = undefined; + }, + sealOpenAssistant: () => { + if (openMessage === undefined) return; + openMessage.message = { ...openMessage.message, partial: undefined }; + openMessage = undefined; + }, + pushToolMessage: (message, time) => { + const event = currentEntry?.data; + const directResult = + event?.type === 'context.append_loop_event' && event.event.type === 'tool.result'; + const lineNo = directResult + ? currentLineNo() + : currentLineNo() - 0.25 - syntheticToolOrdinal++ / 1000; + pushModelMessage({ + lineNo, + time, + source: 'append_message', + message: modelFacingMessage(message), + toolStepUuids: [], + }); + }, + pushMessage: (message, time) => { + const projected = appendMessageEntries.get(message) ?? { + lineNo: currentLineNo(), + time, + source: 'append_message' as const, + message, + toolStepUuids: [], + }; + projected.message = modelFacingMessage(message); + pushModelMessage(projected); + }, + }); + + const resetFold = (): void => { + fold.reset(); + openMessage = undefined; + }; for (const entry of entries) { + currentEntry = entry; + syntheticToolOrdinal = 0; const rec = entry.data; switch (rec.type) { - case 'context.append_message': - messages.push({ + case 'context.append_message': { + const message = normalizeLegacyOrigin(rec.message); + appendMessageEntries.set(message, { lineNo: entry.lineNo, time: rec.time, source: 'append_message', - message: normalizeLegacyOrigin(rec.message), + message, toolStepUuids: [], }); + fold.appendMessage(message, rec.time); break; + } case 'context.append_loop_event': { const ev = rec.event; - if (ev.type === 'step.begin') { - const message: ContextMessage = { - role: 'assistant', - content: [], - toolCalls: [], - }; - const projected: ProjectedMessage = { - lineNo: entry.lineNo, - time: rec.time, - source: 'append_message', - message, - toolStepUuids: [ev.uuid], - }; - messages.push(projected); - openSteps.set(ev.uuid, projected); - } else if (ev.type === 'content.part') { - const projected = openSteps.get(ev.stepUuid); - if (projected !== undefined) { - (projected.message.content as ContentPart[]).push(ev.part); - } - } else if (ev.type === 'tool.call') { - const projected = openSteps.get(ev.stepUuid); - if (projected !== undefined) { - const args = - typeof ev.args === 'string' - ? ev.args - : ev.args === undefined - ? null - : JSON.stringify(ev.args); - (projected.message.toolCalls as ToolCall[]).push({ - type: 'function', - id: ev.toolCallId, - name: ev.name, - arguments: args, - }); - } - } else if (ev.type === 'step.end') { + fold.loopEvent(ev, rec.time); + if (ev.type === 'step.end') { // Absolute context-window fill, mirroring the engine's token // counting state: the latest step.end usage REPLACES the // snapshot (it is not cumulative — see Task P1.7 note on byScope). @@ -193,28 +251,6 @@ export function projectContext( ev.usage.output; if (fill > 0) contextTokens = fill; } - openSteps.delete(ev.uuid); - } else if (ev.type === 'tool.result') { - // Mirror what the MODEL saw, not the raw output. This calls the - // SAME `renderToolResultForModel` the engine applies at its LLM - // projection boundary (error status prefix, empty-output - // placeholder, trailing note), so vis's model view is the real - // projection rather than a hand-kept copy. - const content = renderToolResultForModel(ev.result); - const toolMsg: ContextMessage = { - role: 'tool', - content, - toolCalls: [], - toolCallId: ev.toolCallId, - ...(ev.result.isError === true ? { isError: true } : {}), - }; - messages.push({ - lineNo: entry.lineNo, - time: rec.time, - source: 'append_message', - message: toolMsg, - toolStepUuids: [], - }); } break; } @@ -222,15 +258,16 @@ export function projectContext( contextTokens = rec.tokenCount; break; case 'context.clear': + resetFold(); + modelMessages = []; if (mode === 'model') { messages = []; - openSteps = new Map(); // Mirror the engine's clear() → legacy micro-compaction cutoff // reset (→ 0): // the message indices are wiped, so any prior cutoff is meaningless. microCutoff = 0; } else { - // Full history: keep all preceding messages and openSteps as-is, just + // Full history: keep all preceding messages, just // append a synthetic 'clear' marker inline. The original tool results // stay un-blanked, so the cutoff is not applied (the end-of-loop // blanking pass is gated on model mode). @@ -251,7 +288,17 @@ export function projectContext( contextTokens = 0; break; case 'context.apply_compaction': { - openSteps = new Map(); + let compactionInput: ReturnType; + try { + compactionInput = readContextCompactionShapeInput(rec); + } catch { + break; + } + if (mode === 'full' && rec.keptUserMessageCount !== undefined) { + fold.settle(rec.time); + } + const historyEntries = [...modelMessages]; + resetFold(); // Mirror the engine's applyCompaction // (`packages/agent-core-v2/src/agent/contextMemory/`): the live history // becomes the kept real user messages (verbatim, within a token budget @@ -259,10 +306,8 @@ export function projectContext( // marker when the pool overflowed) followed by a single user-role // summary tagged `origin.kind = 'compaction_summary'`. Assistant // messages, tool calls, and tool results are dropped. The selection - // rules (`selectCompactionUserMessages` / `selectRecentUserMessages` / - // `collectCompactableUserMessages`) are the same helpers the engine's - // context memory and the web transcript reducer apply, so all three - // views stay in sync. + // rules come from the same `buildContextCompactionShape` helper the + // engine uses during replay, so both views stay in sync. // // The v2 payload is a union of three variants: current records carry // `summary` as a string (with `contextSummary` holding the @@ -278,7 +323,11 @@ export function projectContext( : rawSummary !== undefined ? contextMessageText(rawSummary) : (contextSummary ?? ''); - const compactedCount = rec.compactedCount ?? ('count' in rec ? rec.count : 0); + const shape = buildContextCompactionShape( + historyEntries.map((message) => message.message), + compactionInput, + ); + const compactedCount = shape.compactedCount; const summaryBubble: ProjectedMessage = { lineNo: entry.lineNo, time: rec.time, @@ -293,104 +342,37 @@ export function projectContext( compaction: { compactedCount, tokensBefore: rec.tokensBefore, - tokensAfter: rec.tokensAfter, + tokensAfter: shape.tokensAfter, }, }; - const modelSummaryBubble: ProjectedMessage = - contextSummary === undefined - ? summaryBubble - : { - ...summaryBubble, - message: { - ...summaryBubble.message, - content: [{ type: 'text', text: contextSummary }], - } as ContextMessage, - }; - if (mode === 'model') { - // Rebuild the model's-eye view. New records carry `keptUserMessageCount` - // and use the kept-user selection below; legacy-tail records fall back - // to the old verbatim-tail shape. The legacy rule is the same one the - // engine's `readContextCompactionShapeInput` applies — an explicit - // `legacyTail: true`, or any record without `keptUserMessageCount` — - // unconditionally on how `compactedCount` compares to the current - // history length. - const historyEntries = messages.filter(isHistoryEntry); - if (rec.legacyTail === true || rec.keptUserMessageCount === undefined) { - // Legacy-tail record: the engine's restore reproduces the old - // `[summary, ...history.slice(compactedCount)]` semantics — a verbatim - // recent tail (assistant/tool included), not the new kept-user - // selection. Mirror that exact shape so opening an older compacted - // session in model mode shows the same tail the resumed agent still - // holds, instead of hiding it behind the new selection. - messages = [modelSummaryBubble, ...historyEntries.slice(compactedCount)]; - } else if (rec.keptHeadUserMessageCount === undefined) { - // Tail-only record: written before the head/tail split, or by new - // code whose user pool fit the budget (the two selections agree in - // that case). `realUserEntries` is filtered with the exact - // `collectCompactableUserMessages` predicate so it stays aligned with - // the selection below (genuine user input only — no injections, system - // triggers, or prior summaries). `selectRecentUserMessages` keeps a - // contiguous suffix of that subsequence, with only the oldest kept - // message possibly truncated, so each kept message maps back onto its - // original ProjectedMessage wrapper (preserving line/time); we swap in - // the (possibly truncated) message object. - const realUserEntries = historyEntries.filter( - (pm) => collectCompactableUserMessages([pm.message]).length === 1, - ); - const keptUserMessages = selectRecentUserMessages( - realUserEntries.map((pm) => pm.message), - COMPACT_USER_MESSAGE_MAX_TOKENS, - ); - const suffixStart = realUserEntries.length - keptUserMessages.length; - const keptEntries: ProjectedMessage[] = keptUserMessages.map((message, i) => { - const original = realUserEntries[suffixStart + i]!; - return original.message === message ? original : { ...original, message }; - }); - messages = [...keptEntries, modelSummaryBubble]; - } else { - // Head/tail record: mirror `selectCompactionUserMessages` and the - // elision marker `ContextMemory.applyCompaction` inserts between the - // segments. `tail` is a contiguous suffix of `realUserEntries` and - // `head` a contiguous prefix, except that the head's last item may be - // a slice of the SAME message whose end anchors the tail (the head - // extends into the tail boundary's cut-off beginning) — map that one - // onto the tail-boundary original. Fractional lineNos keep the - // synthesized entries' React keys unique; ContextTab renders in array - // order, so they never affect placement. - const realUserEntries = historyEntries.filter( - (pm) => collectCompactableUserMessages([pm.message]).length === 1, - ); - const selection = selectCompactionUserMessages( - realUserEntries.map((pm) => pm.message), - ); - const tailStart = realUserEntries.length - selection.tail.length; - const headEntries: ProjectedMessage[] = selection.head.map((message, i) => { - const original = i < tailStart ? realUserEntries[i]! : realUserEntries[tailStart]!; - if (original.message === message) return original; - return i < tailStart - ? { ...original, message } - : { ...original, lineNo: original.lineNo - 0.5, message }; - }); - const tailEntries: ProjectedMessage[] = selection.tail.map((message, i) => { - const original = realUserEntries[tailStart + i]!; - return original.message === message ? original : { ...original, message }; - }); - const markerBubble: ProjectedMessage = { - lineNo: entry.lineNo - 0.5, - time: rec.time, - source: 'append_message', - message: { - role: 'user', - content: [ - { type: 'text', text: buildCompactionElisionText(selection.omittedTokens) }, - ], - toolCalls: [], - origin: { kind: 'injection', variant: COMPACTION_ELISION_VARIANT }, - } as ContextMessage, - toolStepUuids: [], - }; - messages = [...headEntries, markerBubble, ...tailEntries, modelSummaryBubble]; + const legacyTail = rec.legacyTail === true || rec.keptUserMessageCount === undefined; + const summaryIndex = legacyTail ? 0 : shape.messages.length - 1; + const modelSummaryBubble: ProjectedMessage = { + ...summaryBubble, + message: modelFacingMessage(shape.messages[summaryIndex] ?? summaryBubble.message), + }; + const available = new Set(historyEntries); + let syntheticOrdinal = 0; + modelMessages = shape.messages.map((message, index) => { + if (index === summaryIndex) return modelSummaryBubble; + const original = historyEntries.find( + (candidate) => available.has(candidate) && candidate.message === message, + ); + if (original !== undefined) { + available.delete(original); + return original; } + syntheticOrdinal += 1; + return { + lineNo: entry.lineNo - 0.5 - syntheticOrdinal / 1000, + time: rec.time, + source: 'append_message', + message: modelFacingMessage(message), + toolStepUuids: [], + }; + }); + if (mode === 'model') { + messages = [...modelMessages]; } else { // Full history: keep ALL preceding messages, just append the summary // marker inline so the compacted prefix stays visible. @@ -401,22 +383,9 @@ export function projectContext( // index-based cutoff no longer points at the same messages. (In full // mode the blanking pass does not run, so this is a no-op there.) microCutoff = 0; - // Mirror the engine's `buildContextCompactionShape`: when the record - // omits `tokensAfter` (legacy variants), derive the post-compaction - // fill as an estimate over the RECONSTRUCTED shape instead of keeping - // the stale pre-compaction count — and reflect the derived value on - // the summary bubble, like the engine does. (In full mode the - // projected list is vis's own debug view, not the model's shape, so - // the fallback keeps the prior count instead of estimating over the - // wrong message set.) - if (rec.tokensAfter !== undefined) { - contextTokens = rec.tokensAfter; - } else if (mode === 'model') { - contextTokens = estimateTokensForMessages(messages.map((pm) => pm.message)); - if (modelSummaryBubble.compaction !== undefined) { - modelSummaryBubble.compaction.tokensAfter = contextTokens; - } - } + // `buildContextCompactionShape` also derives the post-compaction token + // count for legacy records that omit `tokensAfter`. + contextTokens = shape.tokensAfter; break; } case 'usage.record': { @@ -425,7 +394,7 @@ export function projectContext( // contextTokens; byScope/byModel are for the cumulative breakdown only. const scope = (rec.usageScope ?? 'session') as 'session' | 'turn'; addUsage(usage.byScope[scope], rec.usage); - if (!usage.byModel[rec.model]) usage.byModel[rec.model] = { ...ZERO }; + usage.byModel[rec.model] ??= { ...ZERO }; addUsage(usage.byModel[rec.model]!, rec.usage); break; } @@ -462,26 +431,29 @@ export function projectContext( case 'plan_mode.exit': planActive = false; planId = undefined; break; case 'context.undo': { - // Mirror the engine's `undo` - // (`packages/agent-core-v2/src/agent/contextMemory/`): walk from the - // end, skip `origin.kind === 'injection'`, stop at - // `origin.kind === 'compaction_summary'`, remove others, counting real - // user prompts via `isRealUserInput` until `count` is reached. Then - // leave an undo marker. + // Mirror the engine's `undo`: locate the requested user anchor while + // skipping injections, stop at a compaction summary, include an + // immediately preceding prompt-owned injection in the cut, then remove + // the entire suffix from that cut. The UI adds a marker afterwards. // - // `computeUndoCutoff` is the single source of truth for that skip/stop - // walk (shared by both modes); only the actual removal is gated on - // `'model'` mode. - const { cutoff, removedMessageCount } = computeUndoCutoff(messages, rec.count); + // `computeUndoCut` is the engine's single source of truth for that + // skip/stop walk; only the visible removal is gated on `'model'` mode. + const cut = computeUndoCut( + modelMessages.map((message) => message.message), + rec.count, + ); + const applied = isFullyUndoable(cut, rec.count); + const removedMessageCount = applied ? modelMessages.length - cut.cutIndex : 0; + if (applied) { + const firstRemoved = modelMessages[cut.cutIndex]; + modelMessages = modelMessages.slice(0, cut.cutIndex); + resetFold(); + if (mode === 'model') { + const displayCutoff = firstRemoved === undefined ? -1 : messages.indexOf(firstRemoved); + messages = displayCutoff === -1 ? [...modelMessages] : messages.slice(0, displayCutoff); + } + } if (mode === 'model') { - // Remove everything from `cutoff` onward EXCEPT injections, which the - // walk skips (they survive even when inside the undo window). Using - // the same `origin.kind === 'injection'` predicate keeps removal in - // lockstep with the counting walk above. - messages = messages.filter( - (pm, i) => i < cutoff || pm.message.origin?.kind === 'injection', - ); - openSteps = new Map(); // Mirror the engine's undo() → legacy micro-compaction cutoff reset // (to the post-undo history length): // clamp the cutoff to the post-undo HISTORY-entry count so a later append @@ -492,12 +464,11 @@ export function projectContext( // (Clamp before pushing the undo marker, which is a non-tool pseudo-message // and unaffected by blanking regardless.) With no markers, historyCount === // messages.length, so this is a no-op then. - const historyCount = messages.reduce((n, pm) => (isHistoryEntry(pm) ? n + 1 : n), 0); - microCutoff = Math.min(microCutoff, historyCount); + microCutoff = Math.min(microCutoff, modelMessages.length); } - // In 'full' mode: do NOT remove — keep the undone messages and openSteps - // as-is, only push the undo marker. `removedMessageCount` still reflects - // what WOULD have been removed. + // In 'full' mode: do NOT remove the visible messages; only push the undo + // marker. `modelMessages` still advances exactly like engine state so a + // later undo/compaction is computed from the right live history. messages.push({ lineNo: entry.lineNo, time: rec.time, @@ -604,6 +575,8 @@ export function projectContext( case 'llm.tools_snapshot': case 'llm.request': case 'mcp.tools_discovered': + case 'file_history.checkpoint': + case 'file_history.tracked': break; default: { const _exhaustive: never = rec; @@ -701,9 +674,22 @@ function isHistoryEntry(pm: ProjectedMessage): boolean { return pm.source !== 'undo' && pm.source !== 'clear'; } +function modelFacingMessage(message: ContextMessage): ContextMessage { + if (message.role !== 'tool') return message; + return { + ...message, + content: renderToolResultForModel({ + output: message.content, + isError: message.isError, + note: message.note, + }), + note: undefined, + }; +} + /** v1 wires tag background-task prompts `origin.kind === 'background_task'`; * v2 renamed the kind to 'task' (same status literals). Normalize on ingest - * so the undo walk (`isRealUserInput`) and the web see one vocabulary. */ + * so the engine's undo helper and the web see one vocabulary. */ function normalizeLegacyOrigin(message: ContextMessage): ContextMessage { const origin = message.origin as { readonly kind: string } | undefined; if (origin?.kind !== 'background_task') return message; @@ -718,34 +704,3 @@ function contextMessageText(message: ContextMessage): string { .map((part) => part.text) .join('\n'); } - -/** Single source of truth for the `context.undo` backward walk, shared by both - * projection modes. Mirrors the engine's `undo` - * (`packages/agent-core-v2/src/agent/contextMemory/`): walk - * from the end, skip `origin.kind === 'injection'` (those are KEPT even when - * they sit inside the undo window), stop at `origin.kind === 'compaction_summary'`, - * and count real user prompts via `isRealUserInput` until `count` is reached. - * - * Returns the `cutoff` (lowest index to remove from, inclusive) plus the - * `removedMessageCount` (number of non-skipped messages in the window). In - * `'model'` mode the caller removes everything from `cutoff` onward EXCEPT - * injections; in `'full'` mode only `removedMessageCount` is reported on the - * undo marker (no removal). Defining the skip/stop predicate exactly once here - * keeps the two modes from drifting. */ -function computeUndoCutoff( - messages: readonly ProjectedMessage[], - count: number, -): { cutoff: number; removedMessageCount: number } { - let removedUserCount = 0; - let removedMessageCount = 0; - let cutoff = messages.length; - for (let i = messages.length - 1; i >= 0; i--) { - const origin = messages[i]?.message.origin; - if (origin?.kind === 'injection') continue; // skip, keep - if (origin?.kind === 'compaction_summary') break; // stop - removedMessageCount++; - cutoff = i; - if (isRealUserInput(messages[i]!.message) && ++removedUserCount >= count) break; - } - return { cutoff, removedMessageCount }; -} diff --git a/apps/vis/server/src/lib/import-store.ts b/apps/vis/server/src/lib/import-store.ts index be63e832130..724b7c3b4cb 100644 --- a/apps/vis/server/src/lib/import-store.ts +++ b/apps/vis/server/src/lib/import-store.ts @@ -133,11 +133,20 @@ async function readManifest(dir: string): Promise { } } -/** Declared string fields of {@link ImportManifest}. `shellEnv` is free-form. */ +/** Declared string fields of {@link ImportManifest}. */ const MANIFEST_STRING_FIELDS = [ 'sessionId', 'exportedAt', 'kimiCodeVersion', 'wireProtocolVersion', 'os', 'nodejsVersion', 'sessionFirstActivity', 'sessionLastActivity', 'title', - 'workspaceDir', 'sessionLogPath', 'globalLogPath', 'installSource', + 'workspaceDir', 'sessionLogPath', 'globalLogPath', 'desktopLogPath', + 'webLogPath', 'desktopVersion', 'installSource', +] as const; + +const SHELL_ENV_STRING_FIELDS = [ + 'term', + 'termProgram', + 'termProgramVersion', + 'multiplexer', + 'shell', ] as const; /** @@ -153,7 +162,15 @@ function sanitizeManifest(raw: unknown): ImportManifest | null { for (const field of MANIFEST_STRING_FIELDS) { if (typeof o[field] === 'string') m[field] = o[field]; } - if (o['shellEnv'] !== undefined) m['shellEnv'] = o['shellEnv']; + const shellEnv = o['shellEnv']; + if (typeof shellEnv === 'object' && shellEnv !== null && !Array.isArray(shellEnv)) { + const source = shellEnv as Record; + const sanitized: Record = {}; + for (const field of SHELL_ENV_STRING_FIELDS) { + if (typeof source[field] === 'string') sanitized[field] = source[field]; + } + m['shellEnv'] = sanitized; + } return m as ImportManifest; } diff --git a/apps/vis/server/src/lib/session-store.ts b/apps/vis/server/src/lib/session-store.ts index 22366ce6a5d..883bfaee39a 100644 --- a/apps/vis/server/src/lib/session-store.ts +++ b/apps/vis/server/src/lib/session-store.ts @@ -22,6 +22,8 @@ export function isSafeAgentId(id: string): boolean { interface StateJson { createdAt?: string | number; updatedAt?: string | number; + cwd?: string; + workDir?: string; title?: string; isCustomTitle?: boolean; lastPrompt?: string; @@ -34,10 +36,10 @@ interface StateJson { // v1 wrote them top-level. Read labels first, top-level as fallback — // the same order the engine itself uses. agents?: Record; custom?: Record; } @@ -90,7 +92,15 @@ export async function readSessionDetail(home: string, sessionId: string): Promis } if (state.custom?.['imported_from_kimi_cli'] === true) return null; const agents = await inventoryAgents(sessionDir, state); - return { sessionId, sessionDir, workDir, state, agents, imported: false, importMeta: null }; + return { + sessionId, + sessionDir, + workDir: recoverWorkDir(state, workDir), + state, + agents, + imported: false, + importMeta: null, + }; } /** Detail for an imported bundle. Same readers as a local session, but the @@ -117,7 +127,15 @@ async function readImportedDetail(home: string, importId: string): Promise agentId: id, type: id === 'main' ? 'main' : 'independent', parentAgentId: null, + profileName: null, homedir: join(agentsDir, id), wireExists: readable, wireRecordCount: info.count, @@ -205,7 +224,7 @@ async function tryReadSummary( return { sessionId, sessionDir, - workDir, + workDir: recoverWorkDir(state, workDir), title: state.title ?? null, lastPrompt: state.lastPrompt ?? null, isCustomTitle: state.isCustomTitle ?? false, @@ -289,8 +308,9 @@ async function inventoryAgents(sessionDir: string, state: StateJson): Promise 0) return preferred; + if (typeof state.cwd === 'string' && state.cwd.length > 0) return state.cwd; + if (typeof state.workDir === 'string' && state.workDir.length > 0) return state.workDir; + const customCwd = state.custom?.['cwd']; + return typeof customCwd === 'string' && customCwd.length > 0 ? customCwd : ''; +} + function parseTs(input: string | number | undefined): number { if (typeof input === 'number') return Number.isFinite(input) ? input : 0; if (!input) return 0; diff --git a/apps/vis/server/src/lib/task-store.ts b/apps/vis/server/src/lib/task-store.ts index 9c4c62cb885..62b8e16f22f 100644 --- a/apps/vis/server/src/lib/task-store.ts +++ b/apps/vis/server/src/lib/task-store.ts @@ -2,8 +2,8 @@ // // Read-only reader for background tasks, persisted by the engine under each // spawning agent's homedir at `/tasks/.json` -// (+ `tasks//output.log`) — NOT the session root. Callers pass the -// agent homedir (`/agents/`). +// (+ `tasks//output.log`). Main-agent reads may also receive the +// legacy session root as a fallback. // // The visualizer never writes these files; it mirrors the engine's on-disk // layout (`packages/agent-core-v2/src/agent/task/persist.ts`) for reading only: @@ -12,7 +12,7 @@ // - the same legacy snake_case → current camelCase normalization, so old // sessions list identically to how the CLI would list them. -import { open, readdir, readFile, stat } from 'node:fs/promises'; +import { open, readdir, readFile } from 'node:fs/promises'; import { join } from 'node:path'; import type { @@ -50,19 +50,44 @@ function taskOutputFile(agentDir: string, taskId: string): string { */ export async function listBackgroundTasks( agentDir: string, + fallbackDir?: string, ): Promise { + const primary = await listBackgroundTasksAt(agentDir); + const out = [...primary.tasks]; + if (fallbackDir !== undefined) { + const fallback = await listBackgroundTasksAt(fallbackDir); + for (const task of fallback.tasks) { + if (!primary.reservedIds.has(task.keyId)) out.push(task); + } + } + // Newest first; tasks with no start time sort last. + out.sort((a, b) => (b.task.startedAt ?? 0) - (a.task.startedAt ?? 0)); + return out.map((entry) => entry.task); +} + +interface ListedTask { + keyId: string; + task: BackgroundTaskInfo; +} + +async function listBackgroundTasksAt( + agentDir: string, +): Promise<{ reservedIds: Set; tasks: ListedTask[] }> { const dir = tasksDirOf(agentDir); let entries: import('node:fs').Dirent[]; try { entries = await readdir(dir, { withFileTypes: true }); } catch { - return []; + return { reservedIds: new Set(), tasks: [] }; } - const out: BackgroundTaskInfo[] = []; + const reservedIds = new Set(); + const tasks: ListedTask[] = []; for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith('.json')) continue; + if (!entry.name.endsWith('.json')) continue; const id = entry.name.slice(0, -'.json'.length); if (!VALID_TASK_ID.test(id)) continue; + reservedIds.add(id); + if (!entry.isFile()) continue; let parsed: unknown; try { parsed = JSON.parse(await readFile(join(dir, entry.name), 'utf8')); @@ -71,7 +96,7 @@ export async function listBackgroundTasks( } if (!isReadablePersistedTask(parsed)) continue; try { - out.push(normalizePersistedTask(parsed)); + tasks.push({ keyId: id, task: normalizePersistedTask(parsed) }); } catch { // A record can pass the shape guard but still hold type-corrupt fields // (e.g. a legacy `stop_reason` that is a number). Honour the @@ -79,23 +104,40 @@ export async function listBackgroundTasks( continue; } } - // Newest first; tasks with no start time sort last. - out.sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0)); - return out; + return { reservedIds, tasks }; } -/** Byte size of a task's `output.log` (0 when absent or unreadable). */ -export async function taskOutputSizeBytes( +export interface TaskOutputMetadata { + exists: boolean; + size: number; +} + +/** Presence and byte size of a task's `output.log`. */ +export async function taskOutputMetadata( agentDir: string, taskId: string, -): Promise { + fallbackDir?: string, +): Promise { + const handle = await openTaskOutput(agentDir, taskId, fallbackDir); + if (handle === undefined) return { exists: false, size: 0 }; try { - return (await stat(taskOutputFile(agentDir, taskId))).size; + return { exists: true, size: (await handle.stat()).size }; } catch { - return 0; + return { exists: false, size: 0 }; + } finally { + await handle.close(); } } +/** Byte size of a task's `output.log` (0 when absent, empty, or unreadable). */ +export async function taskOutputSizeBytes( + agentDir: string, + taskId: string, + fallbackDir?: string, +): Promise { + return (await taskOutputMetadata(agentDir, taskId, fallbackDir)).size; +} + export interface TaskOutputWindow { /** Byte offset this window starts at (clamped to >= 0). */ offset: number; @@ -123,13 +165,12 @@ export async function readTaskOutput( taskId: string, offset: number, maxBytes: number, + fallbackDir?: string, ): Promise { const start = Math.max(0, Math.trunc(offset)); const limit = Math.max(0, Math.trunc(maxBytes)); - let handle; - try { - handle = await open(taskOutputFile(agentDir, taskId), 'r'); - } catch { + const handle = await openTaskOutput(agentDir, taskId, fallbackDir); + if (handle === undefined) { return { offset: start, nextOffset: start, size: 0, content: '', eof: true }; } try { @@ -150,6 +191,32 @@ export async function readTaskOutput( } } +async function openTaskOutput( + agentDir: string, + taskId: string, + fallbackDir?: string, +): Promise> | undefined> { + try { + return await open(taskOutputFile(agentDir, taskId), 'r'); + } catch (error) { + if (!isMissingPath(error) || fallbackDir === undefined) return undefined; + } + try { + return await open(taskOutputFile(fallbackDir, taskId), 'r'); + } catch { + return undefined; + } +} + +function isMissingPath(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} + // ── normalization (ported from agent-core-v2/agent/task/persist.ts) ──────── type LegacyBackgroundTaskStatus = diff --git a/apps/vis/server/src/lib/wire-reader.ts b/apps/vis/server/src/lib/wire-reader.ts index 48fefaa00b6..60aa7bb0389 100644 --- a/apps/vis/server/src/lib/wire-reader.ts +++ b/apps/vis/server/src/lib/wire-reader.ts @@ -1,7 +1,10 @@ import { createReadStream } from 'node:fs'; +import { basename, dirname } from 'node:path'; import { createInterface } from 'node:readline'; import { + isNewerWireVersion, + migrateV1_4ToV1_5, migrateWireRecord, resolveWireMigrations, type WireMigration, @@ -37,6 +40,8 @@ function bestEffortMigrations(): readonly WireMigration[] { * - below-1.0 (or otherwise unrecognized-low) — `resolveWireMigrations` * throws, so records run through the 1.0-onwards best-effort chain and a * warning is added to `warnings[]` so the UI can surface the caveat; + * - no metadata header — mirrors core-v2's recovery path by treating the + * journal as v1.4 and applying the v1.4 → v1.5 migration in memory; * - at/above the current 1.5 (including future versions) — resolves to an * empty chain, so records are passed through unchanged, with no migration * and no warning. */ @@ -46,8 +51,10 @@ export async function readAgentWire(path: string): Promise { let lineNo = 0; let metadata: WireReadResult['metadata'] | null = null; let migrations: readonly WireMigration[] = []; + let newerWireVersion = false; const records: WireEntry[] = []; const warnings: string[] = []; + const agentId = basename(dirname(path)); for await (const line of rl) { lineNo += 1; @@ -64,31 +71,40 @@ export async function readAgentWire(path: string): Promise { continue; } if (metadata === null) { - if (parsed['type'] !== 'metadata') { - throw new Error(`Wire file missing metadata header at line ${lineNo}`); - } - const pv = parsed['protocol_version']; - const ca = parsed['created_at']; - if (typeof pv !== 'string' || typeof ca !== 'number') { - throw new TypeError(`Wire metadata malformed at line ${lineNo}`); - } - try { - migrations = resolveWireMigrations(pv); - } catch (error) { + if (parsed['type'] === 'metadata') { + const pv = parsed['protocol_version']; + const ca = parsed['created_at']; + if (typeof pv !== 'string' || typeof ca !== 'number') { + throw new TypeError(`Wire metadata malformed at line ${lineNo}`); + } + newerWireVersion = isNewerWireVersion(pv); + try { + migrations = resolveWireMigrations(pv); + } catch (error) { + warnings.push( + `unrecognised protocol_version "${pv}" — parsing as best-effort (${(error as Error).message})`, + ); + migrations = bestEffortMigrations(); + } + metadata = { protocolVersion: pv, createdAt: ca }; + continue; + } else { warnings.push( - `unrecognised protocol_version "${pv}" — parsing as best-effort (${(error as Error).message})`, + `line ${lineNo}: missing metadata header — assuming protocol_version "${migrateV1_4ToV1_5.sourceVersion}"`, ); - migrations = bestEffortMigrations(); + migrations = [migrateV1_4ToV1_5]; + metadata = { + protocolVersion: migrateV1_4ToV1_5.sourceVersion, + createdAt: 0, + }; } - metadata = { protocolVersion: pv, createdAt: ca }; - continue; } - const raw = parsed as Record; + const raw = parsed; let migrated: Record; try { migrated = migrations.length === 0 - ? (structuredClone(raw) as Record) + ? structuredClone(raw) : (migrateWireRecord( raw as Record & { type: string }, migrations, @@ -99,9 +115,16 @@ export async function readAgentWire(path: string): Promise { warnings.push( `line ${lineNo}: migration failed (${(error as Error).message}); using raw record`, ); - migrated = structuredClone(raw) as Record; + migrated = structuredClone(raw); + } + const normalized = newerWireVersion + ? migrated + : normalizePlanRevisionRecord(migrated, agentId); + if (normalized === undefined) { + warnings.push(`line ${lineNo}: invalid legacy plan.revision record skipped`); + continue; } - records.push({ lineNo, data: migrated as AgentRecord, raw }); + records.push({ lineNo, data: normalized as AgentRecord, raw }); } if (metadata === null) { throw new Error('Wire file is empty (no metadata)'); @@ -109,6 +132,37 @@ export async function readAgentWire(path: string): Promise { return { metadata, records, warnings }; } +function normalizePlanRevisionRecord( + record: Record, + agentId: string, +): Record | undefined { + if (record['type'] !== 'plan.revision' || 'key' in record) return record; + const legacyPath = record['path']; + if (typeof legacyPath !== 'string') return undefined; + const key = extractLegacyPlanRevisionKey(legacyPath, agentId); + if (key === undefined) return undefined; + const { path: _path, ...rest } = record; + return { ...rest, key }; +} + +function extractLegacyPlanRevisionKey(path: string, agentId: string): string | undefined { + if (path.includes('\\')) return undefined; + const segments = path.split('/'); + if ( + segments.length < 8 || + segments[0] !== 'sessions' || + segments[3] !== 'agents' || + segments[4] !== agentId || + segments + .slice(1, 3) + .some((segment) => segment.length === 0 || segment === '.' || segment === '..') + ) { + return undefined; + } + const key = segments.slice(5).join('/'); + return /^plan\/[^/]+\/v[0-9]+\.md$/.test(key) ? key : undefined; +} + function isObject(v: unknown): v is Record { return typeof v === 'object' && v !== null && !Array.isArray(v); } diff --git a/apps/vis/server/src/routes/tasks.ts b/apps/vis/server/src/routes/tasks.ts index 894a0f5e5ac..6ff3c85f0a2 100644 --- a/apps/vis/server/src/routes/tasks.ts +++ b/apps/vis/server/src/routes/tasks.ts @@ -7,7 +7,7 @@ import { isSafeTaskId, listBackgroundTasks, readTaskOutput, - taskOutputSizeBytes, + taskOutputMetadata, } from '../lib/task-store'; /** Default output-log window size: 256 KiB. Large enough to show a whole @@ -19,9 +19,9 @@ const MAX_OUTPUT_LIMIT = 4 * 1024 * 1024; export function tasksRoute(home: string = KIMI_CODE_HOME): Hono { const r = new Hono(); - // List background tasks (process / agent / question) for a session. Tasks are - // persisted under each spawning agent's homedir (`/tasks`), NOT the - // session root, so aggregate across every agent in the session. + // List background tasks (process / agent / question) for a session. Current + // tasks live under each spawning agent's homedir; the main agent also falls + // back to the legacy session-root tasks directory. r.get('/:id/tasks', async (c) => { const id = c.req.param('id'); const detail = await readSessionDetail(home, id); @@ -30,10 +30,16 @@ export function tasksRoute(home: string = KIMI_CODE_HOME): Hono { } const entries: BackgroundTaskEntry[] = []; for (const agent of detail.agents) { - const tasks = await listBackgroundTasks(agent.homedir); + const fallbackDir = agent.agentId === 'main' ? detail.sessionDir : undefined; + const tasks = await listBackgroundTasks(agent.homedir, fallbackDir); for (const task of tasks) { - const outputSizeBytes = await taskOutputSizeBytes(agent.homedir, task.taskId); - entries.push({ task, agentId: agent.agentId, outputSizeBytes, outputExists: outputSizeBytes > 0 }); + const output = await taskOutputMetadata(agent.homedir, task.taskId, fallbackDir); + entries.push({ + task, + agentId: agent.agentId, + outputSizeBytes: output.size, + outputExists: output.exists, + }); } } // Newest first across all agents. @@ -58,17 +64,24 @@ export function tasksRoute(home: string = KIMI_CODE_HOME): Hono { if (!detail) { return c.json({ error: 'session not found', code: 'NOT_FOUND' }, 404); } - // Prefer the agent whose log actually has bytes; otherwise any agent's dir - // yields the same empty window. An explicit ?agent= short-circuits the scan. + // Prefer the agent whose log exists, including an empty log. An explicit + // ?agent= short-circuits the scan. The main agent also reads the legacy + // session-root tasks directory as its fallback. const hinted = c.req.query('agent'); - let dir = detail.agents.find((a) => a.agentId === hinted)?.homedir ?? detail.agents[0]?.homedir ?? detail.sessionDir; - for (const agent of detail.agents) { - if ((await taskOutputSizeBytes(agent.homedir, taskId)) > 0) { - dir = agent.homedir; - break; + const hintedAgent = detail.agents.find((agent) => agent.agentId === hinted); + let owner = hintedAgent ?? detail.agents[0]; + if (hintedAgent === undefined) { + for (const agent of detail.agents) { + const fallbackDir = agent.agentId === 'main' ? detail.sessionDir : undefined; + if ((await taskOutputMetadata(agent.homedir, taskId, fallbackDir)).exists) { + owner = agent; + break; + } } } - const window = await readTaskOutput(dir, taskId, offset, limit); + const dir = owner?.homedir ?? detail.sessionDir; + const fallbackDir = owner?.agentId === 'main' ? detail.sessionDir : undefined; + const window = await readTaskOutput(dir, taskId, offset, limit, fallbackDir); return c.json({ sessionId: id, taskId, diff --git a/apps/vis/server/test/lib/agent-tree.test.ts b/apps/vis/server/test/lib/agent-tree.test.ts index 619fa85ad3c..6d36bb85909 100644 --- a/apps/vis/server/test/lib/agent-tree.test.ts +++ b/apps/vis/server/test/lib/agent-tree.test.ts @@ -6,6 +6,7 @@ function info(overrides: Partial & Pick): Agent return { type: 'sub', parentAgentId: null, + profileName: null, homedir: `/tmp/${overrides.agentId}`, wireExists: true, wireRecordCount: 0, @@ -60,7 +61,7 @@ describe('agent-tree', () => { it('orders agents by numeric suffix, main first (agent-2 before agent-10)', () => { const mk = (id: string): AgentInfo => ({ agentId: id, type: id === 'main' ? 'main' : 'sub', parentAgentId: id === 'main' ? null : 'main', - homedir: '', wireExists: true, wireRecordCount: 0, wireProtocolVersion: null, swarmItem: null, + profileName: null, homedir: '', wireExists: true, wireRecordCount: 0, wireProtocolVersion: null, swarmItem: null, }); const tree = buildAgentTree([mk('main'), mk('agent-10'), mk('agent-2')]); const order = [tree[0]!.agentId, ...tree[0]!.children.map((c) => c.agentId)]; diff --git a/apps/vis/server/test/lib/context-projector.test.ts b/apps/vis/server/test/lib/context-projector.test.ts index f2b43834057..e5832b96b36 100644 --- a/apps/vis/server/test/lib/context-projector.test.ts +++ b/apps/vis/server/test/lib/context-projector.test.ts @@ -77,7 +77,7 @@ describe('context-projector', () => { event: { type: 'tool.call' as const, uuid: 'tc1', turnId: 't1', step: 0, stepUuid: 's1', - toolCallId: 'call_1', name: 'LS', args: '{"path":"/"}', + toolCallId: 'call_1', name: 'LS', args: { path: '/' }, }, }, raw: {}, @@ -86,7 +86,12 @@ describe('context-projector', () => { lineNo: 6, data: { type: 'context.append_loop_event' as const, - event: { type: 'step.end' as const, uuid: 's1', turnId: 't1', step: 0 }, + event: { + type: 'tool.result' as const, + parentUuid: 'tc1', + toolCallId: 'call_1', + result: { output: 'file1.txt\nfile2.txt' }, + }, }, raw: {}, }, @@ -94,12 +99,7 @@ describe('context-projector', () => { lineNo: 7, data: { type: 'context.append_loop_event' as const, - event: { - type: 'tool.result' as const, - parentUuid: 'tc1', - toolCallId: 'call_1', - result: { output: 'file1.txt\nfile2.txt' }, - }, + event: { type: 'step.end' as const, uuid: 's1', turnId: 't1', step: 0 }, }, raw: {}, }, @@ -128,6 +128,101 @@ describe('context-projector', () => { ]); }); + it('drops a vacuous assistant when a step ends without output', () => { + const entries = [ + { lineNo: 1, data: { type: 'context.append_loop_event' as const, + event: { type: 'step.begin' as const, uuid: 's1' } }, raw: {} }, + { lineNo: 2, data: { type: 'context.append_loop_event' as const, + event: { type: 'step.end' as const, uuid: 's1' } }, raw: {} }, + ]; + + expect(projectContext(entries as any).messages).toEqual([]); + }); + + it.each(['interrupted', 'error'] as const)( + 'keeps a %s step open until the next attempt settles it', + (finishReason) => { + const entries: Array<{ lineNo: number; data: Record; raw: object }> = [ + { lineNo: 1, data: { type: 'context.append_loop_event' as const, + event: { type: 'step.begin' as const, uuid: 's1' } }, raw: {} }, + { lineNo: 2, data: { type: 'context.append_loop_event' as const, + event: { type: 'content.part' as const, stepUuid: 's1', + part: { type: 'text' as const, text: 'partial' } } }, raw: {} }, + { lineNo: 3, data: { type: 'context.append_loop_event' as const, + event: { type: 'step.end' as const, uuid: 's1', finishReason } }, raw: {} }, + ]; + + const interrupted = projectContext(entries as any); + expect(interrupted.messages).toHaveLength(1); + expect(interrupted.messages[0]!.message.partial).toBe(true); + + entries.push( + { lineNo: 4, data: { type: 'context.append_loop_event' as const, + event: { type: 'step.begin' as const, uuid: 's2' } }, raw: {} }, + { lineNo: 5, data: { type: 'context.append_loop_event' as const, + event: { type: 'content.part' as const, stepUuid: 's2', + part: { type: 'text' as const, text: 'recovered' } } }, raw: {} }, + { lineNo: 6, data: { type: 'context.append_loop_event' as const, + event: { type: 'step.end' as const, uuid: 's2' } }, raw: {} }, + ); + const recovered = projectContext(entries as any); + expect(recovered.messages.map((message) => message.message.partial)).toEqual([ + undefined, + undefined, + ]); + expect(recovered.messages.map((message) => message.message.content[0])).toMatchObject([ + { text: 'partial' }, + { text: 'recovered' }, + ]); + }, + ); + + it('closes a pending tool call with an interrupted result at step end', () => { + const entries = [ + { lineNo: 1, data: { type: 'context.append_loop_event' as const, + event: { type: 'step.begin' as const, uuid: 's1' } }, raw: {} }, + { lineNo: 2, data: { type: 'context.append_loop_event' as const, + event: { type: 'tool.call' as const, stepUuid: 's1', toolCallId: 'c1', + name: 'Bash', args: {} } }, raw: {} }, + { lineNo: 3, data: { type: 'context.append_loop_event' as const, + event: { type: 'step.end' as const, uuid: 's1' } }, raw: {} }, + ]; + + const proj = projectContext(entries as any); + expect(proj.messages.map((message) => message.message.role)).toEqual(['assistant', 'tool']); + expect(proj.messages[1]!.message).toMatchObject({ toolCallId: 'c1', isError: true }); + expect(proj.messages[1]!.message.content[0]).toMatchObject({ + text: expect.stringContaining('interrupted before its result was recorded'), + }); + expect(proj.messages[1]!.lineNo).toBeLessThan(3); + }); + + it('defers appended messages until a pending tool result arrives while preserving line metadata', () => { + const entries = [ + { lineNo: 1, data: { type: 'context.append_loop_event' as const, + event: { type: 'step.begin' as const, uuid: 's1' } }, raw: {} }, + { lineNo: 2, data: { type: 'context.append_loop_event' as const, + event: { type: 'tool.call' as const, stepUuid: 's1', toolCallId: 'c1', + name: 'Read', args: { path: '/tmp/a' } } }, raw: {} }, + { lineNo: 3, data: { type: 'context.append_message' as const, + message: { role: 'user' as const, content: [{ type: 'text' as const, text: 'reminder' }], + toolCalls: [], origin: { kind: 'injection' as const, variant: 'test' } } }, raw: {} }, + { lineNo: 4, data: { type: 'context.append_loop_event' as const, + event: { type: 'tool.result' as const, toolCallId: 'c1', result: { output: 'ok' } } }, raw: {} }, + { lineNo: 5, data: { type: 'context.append_loop_event' as const, + event: { type: 'step.end' as const, uuid: 's1' } }, raw: {} }, + ]; + + const proj = projectContext(entries as any); + expect(proj.messages.map((message) => message.message.role)).toEqual([ + 'assistant', + 'tool', + 'user', + ]); + expect(proj.messages[2]!.message.content[0]).toMatchObject({ text: 'reminder' }); + expect(proj.messages[2]!.lineNo).toBe(3); + }); + it('does not reset contextTokens on a zero-usage step.end', () => { const entries = [ { lineNo: 1, data: { type: 'context.append_loop_event', event: { type: 'step.begin', uuid: 's1', turnId: 'T', step: 0 } }, raw: {} }, @@ -184,7 +279,12 @@ describe('context-projector', () => { lineNo: 3, data: { type: 'context.append_loop_event' as const, - event: { type: 'step.end' as const, uuid: 's1', turnId: 't1', step: 0 }, + event: { + type: 'tool.result' as const, + parentUuid: 'tc1', + toolCallId: 'call_1', + result, + }, }, raw: {}, }, @@ -192,12 +292,7 @@ describe('context-projector', () => { lineNo: 4, data: { type: 'context.append_loop_event' as const, - event: { - type: 'tool.result' as const, - parentUuid: 'tc1', - toolCallId: 'call_1', - result, - }, + event: { type: 'step.end' as const, uuid: 's1', turnId: 't1', step: 0 }, }, raw: {}, }, @@ -292,6 +387,24 @@ describe('context-projector', () => { expect(proj.messages[1]!.message.content[0]).toMatchObject({ text: 'new' }); }); + it('ignores a malformed compaction record like core-v2 restore', () => { + const entries = [ + { lineNo: 1, data: { type: 'context.append_message' as const, + message: { role: 'user' as const, content: [{ type: 'text' as const, text: 'before' }], toolCalls: [] } }, raw: {} }, + { lineNo: 2, data: { type: 'context.apply_compaction' as const, + summary: 'missing compactedCount' }, raw: {} }, + { lineNo: 3, data: { type: 'context.append_message' as const, + message: { role: 'user' as const, content: [{ type: 'text' as const, text: 'after' }], toolCalls: [] } }, raw: {} }, + ]; + + const projection = projectContext(entries as any); + + expect(projection.messages.map((message) => message.message.content[0])).toMatchObject([ + { text: 'before' }, + { text: 'after' }, + ]); + }); + it('uses contextSummary only for the model view and raw summary for full history', () => { const entries = [ { lineNo: 1, data: { type: 'context.append_message' as const, @@ -542,7 +655,7 @@ describe('context-projector', () => { expect(proj.messages[2]!.lineNo).toBe(4); }); - it('context.undo keeps injection messages inside the undo window (skip, not remove)', () => { + it('context.undo removes injection messages inside the undo window', () => { const userMsg = (text: string) => ({ role: 'user' as const, content: [{ type: 'text' as const, text }], toolCalls: [], origin: { kind: 'user' as const }, @@ -553,10 +666,10 @@ describe('context-projector', () => { }); // Layout: [u1, a1, u2, INJECTION, a2]. undo(1) walks from the end: // a2 → removed (non-injection) - // INJECTION → skipped (kept), NOT counted + // INJECTION → skipped while finding the user anchor // u2 → removed, real user prompt → count(1) reached → stop. - // The injection sits INSIDE the undo window (between the trailing real user - // prompt u2 and the cutoff) and must SURVIVE; u2 and a2 around it are gone. + // Once u2 is the cut anchor, the engine slices the whole suffix, so the + // injection inside that suffix is removed together with u2 and a2. const entries = [ { lineNo: 1, data: { type: 'context.append_message' as const, message: userMsg('u1') }, raw: {} }, { lineNo: 2, data: { type: 'context.append_message' as const, @@ -568,16 +681,43 @@ describe('context-projector', () => { { lineNo: 6, data: { type: 'context.undo' as const, count: 1 }, raw: {} }, ]; const proj = projectContext(entries as any); - // u1, a1 remain; the injection survives in place; u2 + a2 removed; undo marker last. + // u1 and a1 remain; u2, the injection, and a2 are removed; marker last. expect(proj.messages.map((m) => m.source)).toEqual([ - 'append_message', 'append_message', 'append_message', 'undo', + 'append_message', 'append_message', 'undo', ]); expect(proj.messages[0]!.message.content[0]).toMatchObject({ text: 'u1' }); expect(proj.messages[1]!.message.content[0]).toMatchObject({ text: 'a1' }); - expect(proj.messages[2]!.message.origin).toEqual({ kind: 'injection' }); - expect(proj.messages[2]!.message.content[0]).toMatchObject({ text: 'inj' }); - // removedMessageCount counts only the removed (non-skipped) messages: u2 + a2 = 2. - expect(proj.messages[3]!.undo).toEqual({ count: 1, removedMessageCount: 2 }); + expect(proj.messages[2]!.undo).toEqual({ count: 1, removedMessageCount: 3 }); + }); + + it('context.undo includes a prompt-owned injection immediately before its prompt', () => { + const entries = [ + { lineNo: 1, data: { type: 'context.append_message' as const, message: { + id: 'p1', role: 'user' as const, + content: [{ type: 'text' as const, text: 'u1' }], toolCalls: [], + origin: { kind: 'user' as const }, + } }, raw: {} }, + { lineNo: 2, data: { type: 'context.append_message' as const, message: { + role: 'user' as const, + content: [{ type: 'text' as const, text: 'owned reminder' }], toolCalls: [], + origin: { kind: 'injection' as const, variant: 'prompt-context', ownerPromptId: 'p2' }, + } }, raw: {} }, + { lineNo: 3, data: { type: 'context.append_message' as const, message: { + id: 'p2', role: 'user' as const, + content: [{ type: 'text' as const, text: 'u2' }], toolCalls: [], + origin: { kind: 'user' as const }, + } }, raw: {} }, + { lineNo: 4, data: { type: 'context.append_message' as const, message: { + role: 'assistant' as const, + content: [{ type: 'text' as const, text: 'a2' }], toolCalls: [], + } }, raw: {} }, + { lineNo: 5, data: { type: 'context.undo' as const, count: 1 }, raw: {} }, + ]; + + const proj = projectContext(entries as any); + expect(proj.messages.map((message) => message.source)).toEqual(['append_message', 'undo']); + expect(proj.messages[0]!.message.content[0]).toMatchObject({ text: 'u1' }); + expect(proj.messages[1]!.undo).toEqual({ count: 1, removedMessageCount: 3 }); }); it('micro_compaction.apply blanks tool-result content before the cutoff', () => { diff --git a/apps/vis/server/test/lib/import-store.test.ts b/apps/vis/server/test/lib/import-store.test.ts index 8d5c3872f21..3529965c94d 100644 --- a/apps/vis/server/test/lib/import-store.test.ts +++ b/apps/vis/server/test/lib/import-store.test.ts @@ -29,7 +29,16 @@ const WIRE = `${META_LINE}\n`; function validBundle(): Record { return { - 'manifest.json': JSON.stringify({ sessionId: 'session_orig', kimiCodeVersion: '0.20.2', workspaceDir: '/home/u/proj', title: 'imported demo' }), + 'manifest.json': JSON.stringify({ + sessionId: 'session_orig', + kimiCodeVersion: '0.20.2', + workspaceDir: '/home/u/proj', + title: 'imported demo', + desktopLogPath: 'logs/kimi-desktop.log', + webLogPath: 'logs/kimi-web.jsonl', + desktopVersion: '1.2.3', + shellEnv: { shell: '/bin/zsh', term: 'xterm-256color', ignored: 42 }, + }), 'state.json': JSON.stringify({ createdAt: '2026-06-01T00:00:00.000Z', updatedAt: '2026-06-01T01:00:00.000Z', title: 'imported demo', agents: { main: { homedir: '/orig/agents/main', type: 'main', parentAgentId: null } }, custom: {} }), 'agents/main/wire.jsonl': WIRE, 'logs/kimi-code.log': '2026-06-01T00:00:00.000Z INFO hello k=v\n', @@ -49,6 +58,10 @@ describe('import-store', () => { expect(meta.originalName).toBe('demo.zip'); expect(meta.manifest?.sessionId).toBe('session_orig'); expect(meta.manifest?.workspaceDir).toBe('/home/u/proj'); + expect(meta.manifest?.desktopLogPath).toBe('logs/kimi-desktop.log'); + expect(meta.manifest?.webLogPath).toBe('logs/kimi-web.jsonl'); + expect(meta.manifest?.desktopVersion).toBe('1.2.3'); + expect(meta.manifest?.shellEnv).toEqual({ shell: '/bin/zsh', term: 'xterm-256color' }); // Extracted to imported// with the session shape intact. const dir = join(home, 'imported', meta.importId); diff --git a/apps/vis/server/test/lib/session-store.test.ts b/apps/vis/server/test/lib/session-store.test.ts index dae5b4e02ca..4a440210de5 100644 --- a/apps/vis/server/test/lib/session-store.test.ts +++ b/apps/vis/server/test/lib/session-store.test.ts @@ -94,20 +94,34 @@ describe('session-store', () => { expect(sessions[0]!.mainWireRecordCount).toBe(0); }); - it('marks a session broken_main_wire when the wire metadata header is malformed', async () => { + it('treats a headerless v1.4 wire as recoverable', async () => { const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); cleanup = c; const { writeFile } = await import('node:fs/promises'); const { join } = await import('node:path'); const wirePath = join(sessionDir, 'agents', 'main', 'wire.jsonl'); - // First line is not a `metadata` record — list health used to stay - // 'ok' while readAgentWire would fail on open. await writeFile( wirePath, '{"type":"config.update","cwd":"/x","time":1}\n', ); const sessions = await listSessions(home); expect(sessions).toHaveLength(1); + expect(sessions[0]!.health).toBe('ok'); + expect(sessions[0]!.wireProtocolVersion).toBe('1.4'); + }); + + it('marks a session broken_main_wire when a metadata record is malformed', async () => { + const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const { writeFile } = await import('node:fs/promises'); + const { join } = await import('node:path'); + await writeFile( + join(sessionDir, 'agents', 'main', 'wire.jsonl'), + '{"type":"metadata","created_at":1}\n', + ); + + const sessions = await listSessions(home); + expect(sessions[0]!.health).toBe('broken_main_wire'); }); @@ -281,6 +295,24 @@ describe('session-store', () => { expect(summary!.updatedAt).toBe(state.updatedAt); }); + it('recovers the workDir from v2 state when the append index is unavailable', async () => { + const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const { readFile, rm, writeFile } = await import('node:fs/promises'); + const { join } = await import('node:path'); + const statePath = join(sessionDir, 'state.json'); + const state = JSON.parse(await readFile(statePath, 'utf8')); + state.cwd = '/workspace/from-state'; + await writeFile(statePath, JSON.stringify(state)); + await rm(join(home, 'session_index.jsonl')); + + const [summary] = await listSessions(home); + const detail = await readSessionDetail(home, 'session_fixture'); + + expect(summary!.workDir).toBe('/workspace/from-state'); + expect(detail!.workDir).toBe('/workspace/from-state'); + }); + it('surfaces swarmItem from state.json onto AgentInfo (null when absent)', async () => { const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); cleanup = c; @@ -311,7 +343,11 @@ describe('session-store', () => { state.agents['agent-1'] = { type: 'sub', parentAgentId: 'main', - labels: { parentAgentId: 'agent-0', swarmItem: 'batch item' }, + labels: { + parentAgentId: 'agent-0', + swarmItem: 'batch item', + profileName: 'explore', + }, }; await writeFile(statePath, JSON.stringify(state)); @@ -320,6 +356,7 @@ describe('session-store', () => { const nested = d!.agents.find((a) => a.agentId === 'agent-1')!; expect(nested.parentAgentId).toBe('agent-0'); expect(nested.swarmItem).toBe('batch item'); + expect(nested.profileName).toBe('explore'); // agent-0 has no labels — the top-level v1 fields still apply. const flat = d!.agents.find((a) => a.agentId === 'agent-0')!; expect(flat.parentAgentId).toBe('main'); diff --git a/apps/vis/server/test/lib/task-store.test.ts b/apps/vis/server/test/lib/task-store.test.ts index a4537fa4aa1..e18e83926ad 100644 --- a/apps/vis/server/test/lib/task-store.test.ts +++ b/apps/vis/server/test/lib/task-store.test.ts @@ -8,6 +8,7 @@ import { isSafeTaskId, listBackgroundTasks, readTaskOutput, + taskOutputMetadata, taskOutputSizeBytes, } from '../../src/lib/task-store'; @@ -118,6 +119,33 @@ describe('task-store', () => { expect(await listBackgroundTasks(sessionDir)).toEqual([]); }); + it('falls back to session-root tasks for main and lets primary keys shadow fallback', async () => { + const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const mainDir = join(sessionDir, 'agents', 'main'); + + await writeTask(sessionDir, 'bash-aaaaaaaa.json', { + taskId: 'bash-aaaaaaaa', kind: 'process', description: 'fallback shadowed', + command: 'fallback', pid: 1, exitCode: 0, status: 'completed', + detached: true, startedAt: 100, endedAt: 200, + }); + await writeTask(sessionDir, 'bash-bbbbbbbb.json', { + taskId: 'bash-bbbbbbbb', kind: 'process', description: 'fallback visible', + command: 'fallback', pid: 2, exitCode: 0, status: 'completed', + detached: true, startedAt: 200, endedAt: 300, + }); + await mkdir(join(mainDir, 'tasks'), { recursive: true }); + await writeFile(join(mainDir, 'tasks', 'bash-aaaaaaaa.json'), '{ broken'); + await writeTask(mainDir, 'bash-cccccccc.json', { + taskId: 'bash-cccccccc', kind: 'process', description: 'primary visible', + command: 'primary', pid: 3, exitCode: 0, status: 'completed', + detached: true, startedAt: 300, endedAt: 400, + }); + + const tasks = await listBackgroundTasks(mainDir, sessionDir); + expect(tasks.map((task) => task.taskId)).toEqual(['bash-cccccccc', 'bash-bbbbbbbb']); + }); + it('reads output.log byte windows with size + eof', async () => { const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); cleanup = c; @@ -145,6 +173,39 @@ describe('task-store', () => { expect(w).toMatchObject({ size: 0, content: '', eof: true }); }); + it('falls back to session-root output and treats an empty primary log as present', async () => { + const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const mainDir = join(sessionDir, 'agents', 'main'); + const fallbackOutputDir = join(sessionDir, 'tasks', 'bash-12345678'); + await mkdir(fallbackOutputDir, { recursive: true }); + await writeFile(join(fallbackOutputDir, 'output.log'), 'legacy output'); + + expect(await taskOutputMetadata(mainDir, 'bash-12345678', sessionDir)).toEqual({ + exists: true, + size: 13, + }); + expect(await readTaskOutput(mainDir, 'bash-12345678', 0, 100, sessionDir)).toMatchObject({ + size: 13, + content: 'legacy output', + eof: true, + }); + + const primaryOutputDir = join(mainDir, 'tasks', 'bash-12345678'); + await mkdir(primaryOutputDir, { recursive: true }); + await writeFile(join(primaryOutputDir, 'output.log'), ''); + + expect(await taskOutputMetadata(mainDir, 'bash-12345678', sessionDir)).toEqual({ + exists: true, + size: 0, + }); + expect(await readTaskOutput(mainDir, 'bash-12345678', 0, 100, sessionDir)).toMatchObject({ + size: 0, + content: '', + eof: true, + }); + }); + it('isSafeTaskId guards traversal', () => { expect(isSafeTaskId('bash-1a2b3c4d')).toBe(true); expect(isSafeTaskId('agent-deadbeef')).toBe(true); diff --git a/apps/vis/server/test/lib/wire-reader.test.ts b/apps/vis/server/test/lib/wire-reader.test.ts index a7dca4cea56..d135936798b 100644 --- a/apps/vis/server/test/lib/wire-reader.test.ts +++ b/apps/vis/server/test/lib/wire-reader.test.ts @@ -128,6 +128,67 @@ describe('wire-reader', () => { } }); + it('recovers a headerless journal with the same v1.4 assumption as core-v2', async () => { + const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const path = join(sessionDir, 'agents', 'main', 'wire.jsonl'); + await writeFile( + path, + JSON.stringify({ + type: 'goal.create', + agentId: 'main', + goalId: 'goal-1', + objective: 'ship', + time: 40, + }) + '\n', + ); + + const result = await readAgentWire(path); + + expect(result.metadata).toEqual({ protocolVersion: '1.4', createdAt: 0 }); + expect(result.warnings).toEqual([ + 'line 1: missing metadata header — assuming protocol_version "1.4"', + ]); + expect(result.records[0]).toMatchObject({ + lineNo: 1, + data: { type: 'goal.create', wallClockResumedAt: 40 }, + }); + }); + + it('normalizes legacy plan revision paths to the current storage key', async () => { + const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const path = join(sessionDir, 'agents', 'main', 'wire.jsonl'); + await writeFile( + path, + [ + JSON.stringify({ type: 'metadata', protocol_version: '1.5', created_at: 1 }), + JSON.stringify({ + type: 'plan.revision', + agentId: 'main', + id: 'demo-plan', + version: 2, + path: 'sessions/workspace/session_demo/agents/main/plan/demo-plan/v2.md', + sha256: 'abc', + bytes: 10, + time: 2, + }), + ].join('\n') + '\n', + ); + + const result = await readAgentWire(path); + + expect(result.records[0]!.data).toMatchObject({ + type: 'plan.revision', + key: 'plan/demo-plan/v2.md', + }); + expect(result.records[0]!.data).not.toHaveProperty('path'); + expect(result.records[0]!.raw).toHaveProperty( + 'path', + 'sessions/workspace/session_demo/agents/main/plan/demo-plan/v2.md', + ); + }); + it('collects warnings for malformed body lines', async () => { const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); cleanup = c; diff --git a/apps/vis/server/test/routes/tasks.test.ts b/apps/vis/server/test/routes/tasks.test.ts index b760b662cfd..4380fca75a1 100644 --- a/apps/vis/server/test/routes/tasks.test.ts +++ b/apps/vis/server/test/routes/tasks.test.ts @@ -52,6 +52,32 @@ describe('tasks route', () => { expect(((await res.json()) as { tasks: unknown[] }).tasks).toEqual([]); }); + it('GET /:id/tasks includes legacy main tasks and reports an empty output file as existing', async () => { + const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const dir = join(sessionDir, 'tasks'); + await mkdir(join(dir, 'bash-87654321'), { recursive: true }); + await writeFile(join(dir, 'bash-87654321.json'), JSON.stringify({ + task_id: 'bash-87654321', command: 'legacy', description: 'legacy main task', + pid: 8, started_at: 100, ended_at: 200, exit_code: 0, status: 'completed', + })); + await writeFile(join(dir, 'bash-87654321', 'output.log'), ''); + + const res = await tasksRoute(home).request('/session_fixture/tasks'); + expect(res.status).toBe(200); + const body = (await res.json()) as { + tasks: { task: { taskId: string }; agentId: string; outputSizeBytes: number; outputExists: boolean }[]; + }; + expect(body.tasks).toEqual([ + expect.objectContaining({ + task: expect.objectContaining({ taskId: 'bash-87654321' }), + agentId: 'main', + outputSizeBytes: 0, + outputExists: true, + }), + ]); + }); + it('GET /:id/tasks/:taskId/output pages by byte window', async () => { const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); cleanup = c; @@ -68,6 +94,24 @@ describe('tasks route', () => { expect(body.nextOffset).toBe(8); }); + it('GET output falls back to the legacy session-root task log', async () => { + const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const dir = join(sessionDir, 'tasks', 'bash-87654321'); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, 'output.log'), 'legacy output'); + + const res = await tasksRoute(home).request( + '/session_fixture/tasks/bash-87654321/output?offset=0&limit=100', + ); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ + size: 13, + content: 'legacy output', + eof: true, + }); + }); + it('GET output returns empty window for a task with no log', async () => { const { home, cleanup: c } = await buildSessionFixture('sample-main'); cleanup = c; diff --git a/apps/vis/web/src/components/analysis/TimelineTab.tsx b/apps/vis/web/src/components/analysis/TimelineTab.tsx index 1b082142ecd..b08dccdea21 100644 --- a/apps/vis/web/src/components/analysis/TimelineTab.tsx +++ b/apps/vis/web/src/components/analysis/TimelineTab.tsx @@ -12,7 +12,7 @@ import { import type { WireEntry } from '../../types'; import { formatBytes } from '../shared/SizePreview'; import { formatDuration, formatTokens } from '../../util/time'; -import { Pill } from '../shared/Pill'; +import { Pill, type PillTone } from '../shared/Pill'; interface TimelineTabProps { sessionId: string; @@ -54,7 +54,7 @@ export function TimelineTab({ sessionId }: TimelineTabProps) { {agents.length === 0 ? : null} {agents.map((a) => ( ))} @@ -243,12 +243,26 @@ function TurnCard({ turn }: { turn: TurnNode }) { > {open ? '▾' : '▸'} - turn {turn.index}{turn.trigger === 'steer' ? ' (steer)' : ''} + turn {turn.turnId ?? turn.index}{turn.trigger === 'steer' ? ' (steer)' : ''} {turn.originKind && turn.originKind !== 'user' ? ( {turn.originKind} ) : null} - {turn.cancelled ? cancelled : null} + {turn.outcome !== undefined ? ( + {turn.outcome} + ) : turn.cancelled ? ( + cancelled + ) : null} + {turn.stopReason !== undefined ? ( + + {turn.stopReason} + + ) : null} {turn.toolErrorCount > 0 ? {turn.toolErrorCount} err : null} {turn.promptText || '(no prompt text)'} @@ -279,6 +293,12 @@ function TurnCard({ turn }: { turn: TurnNode }) { ); } +function outcomeTone(outcome: NonNullable): PillTone { + if (outcome === 'completed') return 'success'; + if (outcome === 'failed') return 'error'; + return 'warning'; +} + function StepRow({ step, turnDurationMs }: { step: StepNode; turnDurationMs?: number }) { const widthPct = turnDurationMs && step.durationMs ? Math.max(2, (step.durationMs / turnDurationMs) * 100) : 0; return ( diff --git a/apps/vis/web/src/components/context/ContextTab.tsx b/apps/vis/web/src/components/context/ContextTab.tsx index 51cae6cc933..cb2f724369b 100644 --- a/apps/vis/web/src/components/context/ContextTab.tsx +++ b/apps/vis/web/src/components/context/ContextTab.tsx @@ -28,9 +28,16 @@ export function ContextTab({ sessionId, initialAgentId = 'main' }: ContextTabPro const agents = detail?.agents ?? []; const messages = ctx?.messages ?? []; - const session = ctx?.usage.byScope.session ?? EMPTY_USAGE; + const sessionUsage = ctx?.usage.byScope.session ?? EMPTY_USAGE; + const turnUsage = ctx?.usage.byScope.turn ?? EMPTY_USAGE; + const cumulativeUsage: TokenUsage = { + inputOther: sessionUsage.inputOther + turnUsage.inputOther, + output: sessionUsage.output + turnUsage.output, + inputCacheRead: sessionUsage.inputCacheRead + turnUsage.inputCacheRead, + inputCacheCreation: sessionUsage.inputCacheCreation + turnUsage.inputCacheCreation, + }; // Live context-window fill (latest step.end usage), distinct from the - // cumulative `session` spend the 4-segment bar breaks down. + // cumulative session-scoped + turn-scoped spend the bar breaks down. const contextTokens = ctx?.contextTokens ?? 0; const config = ctx?.config ?? {}; const permissionMode = ctx?.permission.mode ?? null; @@ -55,6 +62,7 @@ export function ContextTab({ sessionId, initialAgentId = 'main' }: ContextTabPro {agents.map((a) => ( ))} @@ -130,8 +138,8 @@ export function ContextTab({ sessionId, initialAgentId = 'main' }: ContextTabPro ) : null} {/* Live context-window fill (contextTokens) + the 4-segment cumulative - session-usage breakdown. */} - + session-scoped and turn-scoped usage breakdown. */} + {/* Message stream */}
diff --git a/apps/vis/web/src/components/state/StateTab.tsx b/apps/vis/web/src/components/state/StateTab.tsx index 0ad5d749db0..2394f34a601 100644 --- a/apps/vis/web/src/components/state/StateTab.tsx +++ b/apps/vis/web/src/components/state/StateTab.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import type { ImportInfo } from '../../types'; -import { formatAbsoluteTime, formatRelativeTime } from '../../util/time'; +import { formatAbsoluteTime, formatRelativeTime, parseTimestamp } from '../../util/time'; import { CopyButton } from '../shared/CopyButton'; import { JsonViewer } from '../shared/JsonViewer'; import { Pill } from '../shared/Pill'; @@ -16,8 +16,8 @@ interface StateJsonShape { isCustomTitle?: boolean; lastPrompt?: string; forkedFrom?: string; - createdAt?: string; - updatedAt?: string; + createdAt?: string | number; + updatedAt?: string | number; agents?: Record; custom?: Record & { imported_from_kimi_cli?: boolean }; } @@ -32,8 +32,8 @@ export function StateTab({ state, importMeta }: StateTabProps) { return (state ?? {}) as StateJsonShape; }, [state]); - const createdMs = parseIso(s.createdAt); - const updatedMs = parseIso(s.updatedAt); + const createdMs = parseTimestamp(s.createdAt); + const updatedMs = parseTimestamp(s.updatedAt); const agentIds = s.agents !== undefined ? Object.keys(s.agents) : []; const importedFromKimiCli = s.custom?.imported_from_kimi_cli === true; @@ -203,7 +203,7 @@ function ManifestCard({ meta }: { meta: ImportInfo }) { ); } -function TsValue({ ms, raw }: { ms: number | null; raw: string | undefined }) { +function TsValue({ ms, raw }: { ms: number | null; raw: string | number | undefined }) { if (ms === null) { return raw !== undefined && raw !== '' ? ( {raw} @@ -222,9 +222,3 @@ function TsValue({ ms, raw }: { ms: number | null; raw: string | undefined }) { ); } - -function parseIso(input: string | undefined): number | null { - if (input === undefined || input === '') return null; - const n = Date.parse(input); - return Number.isFinite(n) ? n : null; -} diff --git a/apps/vis/web/src/components/subagents/SubagentNode.tsx b/apps/vis/web/src/components/subagents/SubagentNode.tsx index c7b8941152b..2076396c37b 100644 --- a/apps/vis/web/src/components/subagents/SubagentNode.tsx +++ b/apps/vis/web/src/components/subagents/SubagentNode.tsx @@ -33,6 +33,11 @@ export function SubagentNode({ node, sessionId }: Props) { {node.type} {node.agentId} + {node.profileName ? ( + + {node.profileName} + + ) : null} {node.swarmItem ? ( {node.swarmItem} diff --git a/apps/vis/web/src/components/tasks/TasksTab.tsx b/apps/vis/web/src/components/tasks/TasksTab.tsx index c4493b0b266..1d8381d99c1 100644 --- a/apps/vis/web/src/components/tasks/TasksTab.tsx +++ b/apps/vis/web/src/components/tasks/TasksTab.tsx @@ -126,6 +126,9 @@ function TaskCard({ sessionId, entry }: { sessionId: string; entry: BackgroundTa )} {task.subagentType ?? (none)} + {task.stopCode !== undefined ? ( + {task.stopCode} + ) : null} ) : null} {task.kind === 'question' ? ( diff --git a/apps/vis/web/src/components/wire/WireTab.tsx b/apps/vis/web/src/components/wire/WireTab.tsx index 8b8c06d711b..f7fefa9b1bc 100644 --- a/apps/vis/web/src/components/wire/WireTab.tsx +++ b/apps/vis/web/src/components/wire/WireTab.tsx @@ -186,6 +186,7 @@ export function WireTab({ sessionId, initialAgentId = 'main' }: WireTabProps) { {agents.map((a) => ( ))} diff --git a/apps/vis/web/src/components/wire/renderers.tsx b/apps/vis/web/src/components/wire/renderers.tsx index 634ec5bbeda..2047be519ea 100644 --- a/apps/vis/web/src/components/wire/renderers.tsx +++ b/apps/vis/web/src/components/wire/renderers.tsx @@ -67,6 +67,97 @@ export const WIRE_RENDERERS: RendererMap = { }), }, + 'file_history.checkpoint': { + tone: 'lifecycle', + label: 'files·checkpoint', + headline: (r) => { + const phase = r.phase ?? 'start'; + const count = Object.keys(r.entries).length; + return { + main: ( + + turn {r.turnId} + + {count} file{count === 1 ? '' : 's'} + + + ), + right: ( + + {phase} + + ), + }; + }, + detail: (r) => ( +
+ + {r.turnId} + + + {r.phase ?? 'start'} + + + + +
+ ), + }, + + 'file_history.tracked': { + tone: 'tools', + label: 'file·tracked', + headline: (r) => ({ + main: ( + + turn {r.turnId} + {r.path} + + ), + right: ( + + {r.entry.key === null ? 'new' : `v${r.entry.version}`} + + ), + }), + detail: (r) => ( +
+ + {r.turnId} + + + {r.path} + + + {r.entry.version} + + + {r.entry.key === null ? (file did not exist) : {r.entry.key}} + + {r.entry.contentHash !== undefined ? ( + + {r.entry.contentHash} + + ) : null} + {r.entry.size !== undefined ? ( + + {r.entry.size}b + + ) : null} + {r.entry.mtimeMs !== undefined ? ( + + {new Date(r.entry.mtimeMs).toLocaleString()} + + ) : null} + {r.entry.oversize === true ? ( + + true + + ) : null} +
+ ), + }, + forked: { tone: 'lifecycle', label: 'fork', @@ -285,11 +376,21 @@ export const WIRE_RENDERERS: RendererMap = { ), + right: + r.wireLines === undefined ? undefined : ( + + L{r.wireLines.start}–{r.wireLines.end} + + ), }; }, detail: (r) => { const summaryText = compactionSummaryText(r); const compactedCount = r.compactedCount ?? ('count' in r ? r.count : 0); + const contextSummary = + 'contextSummary' in r && typeof r.contextSummary === 'string' + ? r.contextSummary + : undefined; return (
@@ -297,6 +398,17 @@ export const WIRE_RENDERERS: RendererMap = {
{summaryText}
+ {contextSummary !== undefined && contextSummary !== summaryText ? ( + + +
{contextSummary}
+
+
+ ) : null} {compactedCount} @@ -306,6 +418,33 @@ export const WIRE_RENDERERS: RendererMap = { {r.tokensAfter ?? '(n/a)'} + {r.summaryOutputTokens !== undefined ? ( + + {r.summaryOutputTokens} + + ) : null} + {r.keptUserMessageCount !== undefined ? ( + + {r.keptUserMessageCount} + + ) : null} + {r.keptHeadUserMessageCount !== undefined ? ( + + {r.keptHeadUserMessageCount} + + ) : null} + {r.droppedCount !== undefined ? ( + + {r.droppedCount} + + ) : null} + {r.wireLines !== undefined ? ( + + + {r.wireLines.start}–{r.wireLines.end} + + + ) : null}
); }, @@ -819,10 +958,18 @@ export const WIRE_RENDERERS: RendererMap = { main: ( turn {r.turnId} - + {r.reason} {r.durationMs !== undefined ? {r.durationMs}ms : null} + {r.stopReason !== undefined ? ( + + {truncate(r.stopReason, 80)} + + ) : null} ), }), diff --git a/apps/vis/web/src/lib/analysis.ts b/apps/vis/web/src/lib/analysis.ts index b84cffe4bd7..b6143e2419f 100644 --- a/apps/vis/web/src/lib/analysis.ts +++ b/apps/vis/web/src/lib/analysis.ts @@ -75,10 +75,15 @@ export interface TurnNode { steps: StepNode[]; startTime?: number; endTime?: number; - /** endTime − startTime over the turn's steps (active execution time). */ + /** Engine-reported duration, or endTime − startTime for legacy wires. */ durationMs?: number; /** promptTime − previous turn's endTime (time the agent sat idle/waiting). */ waitBeforeMs?: number; + /** Durable turn identity, available once `turn.ended` is recorded. */ + turnId?: number; + endLineNo?: number; + outcome?: 'completed' | 'cancelled' | 'failed' | 'blocked'; + stopReason?: string; /** Sum of this turn's step usages — total tokens processed (billing cost). */ tokens: TokenUsage; toolCallCount: number; @@ -200,7 +205,8 @@ function outputSize(output: unknown): number { if (Array.isArray(output)) { let n = 0; for (const part of output) { - const text = (part as { text?: string })?.text; + const candidate = part as { text?: string; think?: string } | undefined; + const text = candidate?.text ?? candidate?.think; n += typeof text === 'string' ? text.length : JSON.stringify(part ?? null).length; } return n; @@ -261,7 +267,12 @@ export function analyzeWire(entries: readonly WireEntry[]): Analysis { gapMs: t - prevTime, // A gap straddling a turn boundary is "waiting for the user"; a gap // inside a turn is the agent/tool being slow. - kind: rec.type === 'turn.prompt' || rec.type === 'turn.steer' ? 'between_turns' : 'in_turn', + kind: + rec.type === 'turn.prompt' || + (rec.type === 'turn.steer' && + (current === null || current.outcome !== undefined)) + ? 'between_turns' + : 'in_turn', }); } prevTime = t; @@ -273,10 +284,29 @@ export function analyzeWire(entries: readonly WireEntry[]): Analysis { current = startTurn('prompt', entry.lineNo, t, firstText(rec.input), rec.origin?.kind); break; case 'turn.steer': - current = startTurn('steer', entry.lineNo, t, firstText(rec.input), rec.origin?.kind); + if (current === null || current.outcome !== undefined) { + current = startTurn('steer', entry.lineNo, t, firstText(rec.input), rec.origin?.kind); + } break; case 'turn.cancel': - if (current) current.cancelled = true; + if ( + current !== null && + rec.target !== 'queued' && + (rec.turnId === undefined || current.turnId === undefined || current.turnId === rec.turnId) + ) { + current.cancelled = true; + } + break; + case 'turn.ended': + if (current !== null) { + current.turnId = rec.turnId; + current.endLineNo = entry.lineNo; + current.outcome = rec.reason; + current.stopReason = rec.stopReason; + current.cancelled ||= rec.reason === 'cancelled'; + if (t !== undefined) current.endTime = t; + if (rec.durationMs !== undefined) current.durationMs = rec.durationMs; + } break; case 'context.update_token_count': @@ -348,7 +378,14 @@ export function analyzeWire(entries: readonly WireEntry[]): Analysis { case 'context.append_loop_event': { const ev = rec.event; if (ev.type === 'step.begin') { - current ??= startTurn('prompt', entry.lineNo, t, '(no prompt record)', undefined); + if (current === null || current.outcome !== undefined) { + current = startTurn('prompt', entry.lineNo, t, '(no prompt record)', undefined); + } + const parsedTurnId = + ev.turnId === undefined ? undefined : Number.parseInt(ev.turnId, 10); + if (parsedTurnId !== undefined && Number.isInteger(parsedTurnId)) { + current.turnId ??= parsedTurnId; + } const step: StepNode = { uuid: ev.uuid, // `step` / `turnId` are optional on v2 loop events; fall back so @@ -376,10 +413,7 @@ export function analyzeWire(entries: readonly WireEntry[]): Analysis { step.llmServerDecodeMs = ev.llmServerDecodeMs; step.llmClientConsumeMs = ev.llmClientConsumeMs; if (step.beginTime !== undefined && t !== undefined) step.durationMs = t - step.beginTime; - // Steps don't carry a generic 'error' finish reason (errors are - // thrown, not recorded). 'filtered' means the provider blocked the - // response — the closest persisted step-level failure signal. - step.isError = ev.finishReason === 'filtered'; + step.isError = ev.finishReason === 'filtered' || ev.finishReason === 'error'; if ('usage' in ev && ev.usage !== undefined) { step.usage = ev.usage; if (current) addUsage(current.tokens, ev.usage); @@ -419,11 +453,13 @@ export function analyzeWire(entries: readonly WireEntry[]): Analysis { if (current) current.toolCallCount += 1; } else if (ev.type === 'content.part') { const step = stepByUuid.get(ev.stepUuid); - const part = ev.part as { type?: string; text?: string } | undefined; + const part = ev.part as { type?: string; text?: string; think?: string } | undefined; if (step && part) { - const chars = typeof part.text === 'string' ? part.text.length : 0; - if (part.type === 'think') step.content.thinkChars += chars; - else step.content.textChars += chars; + if (part.type === 'think') { + step.content.thinkChars += typeof part.think === 'string' ? part.think.length : 0; + } else { + step.content.textChars += typeof part.text === 'string' ? part.text.length : 0; + } } } else if (ev.type === 'tool.result') { const node = toolByCallId.get(ev.toolCallId); @@ -506,7 +542,11 @@ function summarize( let totalTokens = 0; let activeMs = 0; for (const turn of turns) { - if (turn.startTime !== undefined && turn.endTime !== undefined) { + if ( + turn.durationMs === undefined && + turn.startTime !== undefined && + turn.endTime !== undefined + ) { turn.durationMs = turn.endTime - turn.startTime; } stepCount += turn.steps.length; diff --git a/apps/vis/web/src/pages/SessionDetailPage.tsx b/apps/vis/web/src/pages/SessionDetailPage.tsx index 3622c6e15e9..a2b99205b0f 100644 --- a/apps/vis/web/src/pages/SessionDetailPage.tsx +++ b/apps/vis/web/src/pages/SessionDetailPage.tsx @@ -15,7 +15,7 @@ import { WireTab } from '../components/wire/WireTab'; import { Pill } from '../components/shared/Pill'; import { useSession } from '../hooks/useSession'; import { useCron, useTasks } from '../hooks/useTasks'; -import { formatAbsoluteTime, formatRelativeTime } from '../util/time'; +import { formatAbsoluteTime, formatRelativeTime, parseTimestamp } from '../util/time'; type TabId = 'wire' | 'timeline' | 'context' | 'agents' | 'tasks' | 'cron' | 'logs' | 'state'; @@ -42,8 +42,9 @@ export function SessionDetailPage() { const state = (session.state ?? null) as { title?: string; lastPrompt?: string; - updatedAt?: string; + updatedAt?: string | number; } | null; + const updatedAt = parseTimestamp(state?.updatedAt); const mainAgent = session.agents.find((a) => a.agentId === 'main') ?? null; const subagentCount = session.agents.filter((a) => a.agentId !== 'main').length; @@ -80,10 +81,9 @@ export function SessionDetailPage() {
) : null}
- {state?.updatedAt ? ( + {updatedAt !== null ? ( - updated {formatRelativeTime(Date.parse(state.updatedAt))} ·{' '} - {formatAbsoluteTime(Date.parse(state.updatedAt))} + updated {formatRelativeTime(updatedAt)} · {formatAbsoluteTime(updatedAt)} ) : null} {session.workDir ? ( diff --git a/apps/vis/web/src/pages/SubagentDetailPage.tsx b/apps/vis/web/src/pages/SubagentDetailPage.tsx index 37f9a82ebc1..fc86e60c1cf 100644 --- a/apps/vis/web/src/pages/SubagentDetailPage.tsx +++ b/apps/vis/web/src/pages/SubagentDetailPage.tsx @@ -59,6 +59,11 @@ export function SubagentDetailPage() { {agent.type} + {agent.profileName ? ( + + {agent.profileName} + + ) : null} {agent.parentAgentId !== null ? ( parent ·{' '} diff --git a/apps/vis/web/src/util/time.ts b/apps/vis/web/src/util/time.ts index f6e1349acba..e0a9212bddb 100644 --- a/apps/vis/web/src/util/time.ts +++ b/apps/vis/web/src/util/time.ts @@ -1,3 +1,10 @@ +export function parseTimestamp(value: string | number | undefined): number | null { + if (value === undefined || value === '') return null; + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +} + /** Format an epoch-ms timestamp as a short relative string ("2m ago", "3h ago"). */ export function formatRelativeTime(epochMs: number): string { if (!epochMs || !Number.isFinite(epochMs)) return '—'; diff --git a/apps/vis/web/test/analysis.test.ts b/apps/vis/web/test/analysis.test.ts index 217f5af79fe..a601528f2b1 100644 --- a/apps/vis/web/test/analysis.test.ts +++ b/apps/vis/web/test/analysis.test.ts @@ -142,4 +142,44 @@ describe('analyzeWire', () => { expect(a.summary.peakContextTokens).toBe(42); expect(a.contextSeries.map((point) => point.contextTokens)).toEqual([42]); }); + + it('keeps steering inside the active turn and folds the durable turn outcome', () => { + line = 0; + const a = analyzeWire([ + e({ type: 'turn.prompt', input: [{ type: 'text', text: 'start' }], origin: { kind: 'user' } }, 1000), + loop({ type: 'step.begin', uuid: 's1', turnId: '7', step: 0 }, 1100), + loop({ type: 'content.part', stepUuid: 's1', part: { type: 'think', think: 'reasoning' } }, 1150), + e({ type: 'turn.steer', input: [{ type: 'text', text: 'one more thing' }], origin: { kind: 'user' } }, 1200), + loop({ type: 'step.end', uuid: 's1', turnId: '7', step: 0, finishReason: 'end_turn' }, 1400), + e({ type: 'turn.ended', agentId: 'main', turnId: 7, reason: 'completed', durationMs: 450, stopReason: 'repeat_breaker' }, 1500), + e({ type: 'token_counting.turn_recorded', agentId: 'main', turnId: 7, length: 2, tokens: 50 }, 1501), + e({ type: 'turn.prompt', input: [{ type: 'text', text: 'next' }], origin: { kind: 'user' } }, 3000), + ]); + + expect(a.turns).toHaveLength(2); + expect(a.turns[0]).toMatchObject({ + turnId: 7, + endTime: 1500, + durationMs: 450, + outcome: 'completed', + stopReason: 'repeat_breaker', + }); + expect(a.turns[0]!.steps[0]!.content.thinkChars).toBe(9); + expect(a.turns[1]!.waitBeforeMs).toBe(1500); + expect(a.contextSeries.at(-1)?.turnIndex).toBe(0); + expect(a.summary.activeMs).toBe(450); + }); + + it('marks persisted error step endings as errors', () => { + line = 0; + const analysis = analyzeWire([ + e({ type: 'turn.prompt', input: [{ type: 'text', text: 'start' }], origin: { kind: 'user' } }, 1000), + loop({ type: 'step.begin', uuid: 's1', turnId: '3', step: 0 }, 1100), + loop({ type: 'step.end', uuid: 's1', turnId: '3', step: 0, finishReason: 'error' }, 1200), + e({ type: 'turn.ended', agentId: 'main', turnId: 3, reason: 'failed' }, 1250), + ]); + + expect(analysis.turns[0]?.steps[0]?.isError).toBe(true); + expect(analysis.turns[0]?.outcome).toBe('failed'); + }); }); diff --git a/apps/vis/web/test/renderers.test.ts b/apps/vis/web/test/renderers.test.ts new file mode 100644 index 00000000000..1d369f381e1 --- /dev/null +++ b/apps/vis/web/test/renderers.test.ts @@ -0,0 +1,35 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { WIRE_RENDERERS } from '../src/components/wire/renderers'; + +const HISTORICAL_OR_HEADER_TYPES = new Set([ + 'metadata', + 'context.update_token_count', + 'micro_compaction.apply', + 'staleGuard.recorded', + 'staleGuard.cleared', +]); + +describe('wire renderers', () => { + it('covers every durable record in the current core-v2 wire manifest', async () => { + const manifestPath = resolve( + import.meta.dirname, + '../../../../packages/agent-core-v2/docs/wire-manifest.d.ts', + ); + const manifest = await readFile(manifestPath, 'utf8'); + const index = /\/\/ Index \(\d+ record types\)\n((?:\/\/ .*\n)+)/.exec(manifest)?.[1]; + expect(index).toBeDefined(); + + const upstreamTypes = [...(index ?? '').matchAll(/^\/\/ (\S+)/gm)] + .map((match) => match[1]) + .toSorted(); + const renderedCurrentTypes = Object.keys(WIRE_RENDERERS) + .filter((type) => !HISTORICAL_OR_HEADER_TYPES.has(type)) + .toSorted(); + + expect(renderedCurrentTypes).toEqual(upstreamTypes); + }); +}); diff --git a/apps/vis/web/test/time.test.ts b/apps/vis/web/test/time.test.ts new file mode 100644 index 00000000000..6590bcd5b28 --- /dev/null +++ b/apps/vis/web/test/time.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest'; + +import { parseTimestamp } from '../src/util/time'; + +describe('parseTimestamp', () => { + it('accepts current epoch milliseconds and legacy ISO timestamps', () => { + expect(parseTimestamp(1_784_012_345_678)).toBe(1_784_012_345_678); + expect(parseTimestamp('2026-07-14T01:25:45.678Z')).toBe(1_783_992_345_678); + expect(parseTimestamp('invalid')).toBeNull(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index de40fc42cae..b4624ae386a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,10 +3,21 @@ import { vscodeProjects } from './apps/vscode/vitest.projects'; export default defineConfig({ test: { - projects: ['packages/*', '!packages/minidb', 'apps/kimi-code', ...vscodeProjects], + projects: [ + 'packages/*', + '!packages/minidb', + 'apps/kimi-code', + 'apps/vis/server', + 'apps/vis/web', + ...vscodeProjects, + ], coverage: { provider: 'v8', - include: ['packages/*/src/**/*.ts', 'apps/*/src/**/*.ts'], + include: [ + 'packages/*/src/**/*.ts', + 'apps/*/src/**/*.ts', + 'apps/vis/*/src/**/*.{ts,tsx}', + ], exclude: ['**/*.test.ts', '**/*.spec.ts', '**/dist/**'], reporter: ['text', 'html'], }, From 09ae416f1250495995f56a42f81a8647e2ec0ee8 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 4 Sep 2026 21:27:57 +0800 Subject: [PATCH 2/4] fix(vis): harden partial session recovery --- apps/vis/server/src/lib/session-store.ts | 66 +++++++++++------ .../vis/server/test/lib/session-store.test.ts | 71 ++++++++++++++++++- apps/vis/web/src/lib/analysis.ts | 50 +++++++++++-- apps/vis/web/test/analysis.test.ts | 56 +++++++++++++++ 4 files changed, 213 insertions(+), 30 deletions(-) diff --git a/apps/vis/server/src/lib/session-store.ts b/apps/vis/server/src/lib/session-store.ts index 883bfaee39a..e2b406705a9 100644 --- a/apps/vis/server/src/lib/session-store.ts +++ b/apps/vis/server/src/lib/session-store.ts @@ -35,12 +35,7 @@ interface StateJson { // top-level `parentAgentId` is a fixed 'main' placeholder for sub agents); // v1 wrote them top-level. Read labels first, top-level as fallback — // the same order the engine itself uses. - agents?: Record; + agents?: Record; custom?: Record; } @@ -290,7 +285,8 @@ async function inventoryAgents(sessionDir: string, state: StateJson): Promise compareAgentIds(a.agentId, b.agentId)); @@ -374,19 +374,25 @@ async function scanWire(path: string): Promise<{ count: number; protocolVersion: let protocolVersion: string | null = null; for await (const line of rl) { if (line.length === 0) continue; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) continue; + const record = parsed as Record; + if (typeof record['type'] !== 'string') continue; if (protocolVersion === null) { - let parsed: { type?: unknown; protocol_version?: unknown }; - try { - parsed = JSON.parse(line) as typeof parsed; - } catch { - throw new Error(`wire metadata is not valid JSON at line 1`); - } - if (parsed.type !== 'metadata') { + if (record['type'] !== 'metadata') { protocolVersion = '1.4'; - } else if (typeof parsed.protocol_version !== 'string') { - throw new TypeError(`wire metadata is malformed on line 1`); } else { - protocolVersion = parsed.protocol_version; + const version = record['protocol_version']; + const createdAt = record['created_at']; + if (typeof version !== 'string' || typeof createdAt !== 'number') { + throw new TypeError('wire metadata is malformed'); + } + protocolVersion = version; } } count += 1; @@ -397,6 +403,24 @@ async function scanWire(path: string): Promise<{ count: number; protocolVersion: return { count, protocolVersion }; } +function normalizeAgentType( + value: unknown, + agentId: string, +): AgentInfo['type'] { + if (value === 'main' || value === 'sub' || value === 'independent') return value; + return agentId === 'main' ? 'main' : 'sub'; +} + +function normalizeNonEmptyString(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + function recoverWorkDir(state: StateJson, preferred: string): string { if (preferred.length > 0) return preferred; if (typeof state.cwd === 'string' && state.cwd.length > 0) return state.cwd; diff --git a/apps/vis/server/test/lib/session-store.test.ts b/apps/vis/server/test/lib/session-store.test.ts index 4a440210de5..579ac0d7b98 100644 --- a/apps/vis/server/test/lib/session-store.test.ts +++ b/apps/vis/server/test/lib/session-store.test.ts @@ -110,18 +110,48 @@ describe('session-store', () => { expect(sessions[0]!.wireProtocolVersion).toBe('1.4'); }); - it('marks a session broken_main_wire when a metadata record is malformed', async () => { + it('skips untyped JSON before valid wire metadata', async () => { const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); cleanup = c; const { writeFile } = await import('node:fs/promises'); const { join } = await import('node:path'); await writeFile( join(sessionDir, 'agents', 'main', 'wire.jsonl'), - '{"type":"metadata","created_at":1}\n', + '{}\n{"type":"metadata","protocol_version":"1.5","created_at":1}\n', ); const sessions = await listSessions(home); + expect(sessions[0]!.health).toBe('ok'); + expect(sessions[0]!.wireProtocolVersion).toBe('1.5'); + expect(sessions[0]!.mainWireRecordCount).toBe(1); + }); + + it('marks a session broken_main_wire when its wire has no typed records', async () => { + const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const { writeFile } = await import('node:fs/promises'); + const { join } = await import('node:path'); + await writeFile(join(sessionDir, 'agents', 'main', 'wire.jsonl'), '{}\n{}\n'); + + const sessions = await listSessions(home); + + expect(sessions[0]!.health).toBe('broken_main_wire'); + expect(sessions[0]!.mainWireRecordCount).toBe(0); + }); + + it.each([ + '{"type":"metadata","created_at":1}\n', + '{"type":"metadata","protocol_version":"1.5","created_at":{}}\n', + ])('marks a session broken_main_wire when metadata is malformed', async (wire) => { + const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const { writeFile } = await import('node:fs/promises'); + const { join } = await import('node:path'); + await writeFile(join(sessionDir, 'agents', 'main', 'wire.jsonl'), wire); + + const sessions = await listSessions(home); + expect(sessions[0]!.health).toBe('broken_main_wire'); }); @@ -362,6 +392,43 @@ describe('session-store', () => { expect(flat.parentAgentId).toBe('main'); }); + it('normalizes untrusted agent metadata before exposing it', async () => { + const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const { readFile, writeFile } = await import('node:fs/promises'); + const { join } = await import('node:path'); + const statePath = join(sessionDir, 'state.json'); + const state = JSON.parse(await readFile(statePath, 'utf8')); + state.agents.main.type = {}; + state.agents.main.labels = { + parentAgentId: {}, + profileName: {}, + swarmItem: {}, + }; + state.agents['agent-0'].type = 'invalid'; + state.agents['agent-0'].parentAgentId = []; + state.agents['agent-0'].swarmItem = 42; + state.agents['agent-0'].labels = { profileName: ' ' }; + await writeFile(statePath, JSON.stringify(state)); + + const d = await readSessionDetail(home, 'session_fixture'); + + const main = d!.agents.find((a) => a.agentId === 'main')!; + expect(main).toMatchObject({ + type: 'main', + parentAgentId: null, + profileName: null, + swarmItem: null, + }); + const subagent = d!.agents.find((a) => a.agentId === 'agent-0')!; + expect(subagent).toMatchObject({ + type: 'sub', + parentAgentId: null, + profileName: null, + swarmItem: null, + }); + }); + it('reads the legacy session-meta/state.json path when state.json is missing', async () => { const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); cleanup = c; diff --git a/apps/vis/web/src/lib/analysis.ts b/apps/vis/web/src/lib/analysis.ts index b6143e2419f..52c985d006d 100644 --- a/apps/vis/web/src/lib/analysis.ts +++ b/apps/vis/web/src/lib/analysis.ts @@ -226,6 +226,12 @@ export function analyzeWire(entries: readonly WireEntry[]): Analysis { const configChanges: ConfigChange[] = []; let current: TurnNode | null = null; + let pendingSteer: { + lineNo: number; + time: number | undefined; + text: string; + originKind: string | undefined; + } | null = null; let contextTokens = 0; let peakContext = 0; let firstTime: number | undefined; @@ -281,11 +287,20 @@ export function analyzeWire(entries: readonly WireEntry[]): Analysis { switch (rec.type) { case 'turn.prompt': + pendingSteer = null; current = startTurn('prompt', entry.lineNo, t, firstText(rec.input), rec.origin?.kind); break; case 'turn.steer': if (current === null || current.outcome !== undefined) { + pendingSteer = null; current = startTurn('steer', entry.lineNo, t, firstText(rec.input), rec.origin?.kind); + } else { + pendingSteer = { + lineNo: entry.lineNo, + time: t, + text: firstText(rec.input), + originKind: rec.origin?.kind, + }; } break; case 'turn.cancel': @@ -378,13 +393,34 @@ export function analyzeWire(entries: readonly WireEntry[]): Analysis { case 'context.append_loop_event': { const ev = rec.event; if (ev.type === 'step.begin') { - if (current === null || current.outcome !== undefined) { - current = startTurn('prompt', entry.lineNo, t, '(no prompt record)', undefined); - } const parsedTurnId = ev.turnId === undefined ? undefined : Number.parseInt(ev.turnId, 10); - if (parsedTurnId !== undefined && Number.isInteger(parsedTurnId)) { - current.turnId ??= parsedTurnId; + const validTurnId = + parsedTurnId !== undefined && Number.isInteger(parsedTurnId) + ? parsedTurnId + : undefined; + let turn: TurnNode | null = current; + if ( + turn === null || + turn.outcome !== undefined || + (validTurnId !== undefined && + turn.turnId !== undefined && + turn.turnId !== validTurnId) + ) { + turn = pendingSteer === null + ? startTurn('prompt', entry.lineNo, t, '(no prompt record)', undefined) + : startTurn( + 'steer', + pendingSteer.lineNo, + pendingSteer.time, + pendingSteer.text, + pendingSteer.originKind, + ); + } + pendingSteer = null; + current = turn; + if (validTurnId !== undefined) { + turn.turnId ??= validTurnId; } const step: StepNode = { uuid: ev.uuid, @@ -398,8 +434,8 @@ export function analyzeWire(entries: readonly WireEntry[]): Analysis { toolCalls: [], }; stepByUuid.set(ev.uuid, step); - current.steps.push(step); - current.startTime ??= t; + turn.steps.push(step); + turn.startTime ??= t; } else if (ev.type === 'step.end') { const step = stepByUuid.get(ev.uuid); if (step) { diff --git a/apps/vis/web/test/analysis.test.ts b/apps/vis/web/test/analysis.test.ts index a601528f2b1..cb152faafb1 100644 --- a/apps/vis/web/test/analysis.test.ts +++ b/apps/vis/web/test/analysis.test.ts @@ -170,6 +170,62 @@ describe('analyzeWire', () => { expect(a.summary.activeMs).toBe(450); }); + it('uses a steer as the trigger when the next step belongs to a new turn', () => { + line = 0; + const steerLine = 4; + const a = analyzeWire([ + e({ type: 'turn.prompt', input: [{ type: 'text', text: 'start' }], origin: { kind: 'user' } }, 1000), + loop({ type: 'step.begin', uuid: 's1', turnId: '7', step: 0 }, 1100), + loop({ type: 'step.end', uuid: 's1', turnId: '7', step: 0, finishReason: 'end_turn', usage: { inputOther: 10, output: 2, inputCacheRead: 0, inputCacheCreation: 0 } }, 1200), + e({ type: 'turn.steer', input: [{ type: 'text', text: 'continue' }], origin: { kind: 'system_trigger' } }, 1300), + loop({ type: 'step.begin', uuid: 's2', turnId: '8', step: 0 }, 1400), + loop({ type: 'tool.call', uuid: 'tc2', turnId: '8', step: 0, stepUuid: 's2', toolCallId: 'c2', name: 'Read' }, 1450), + loop({ type: 'tool.result', parentUuid: 'tc2', toolCallId: 'c2', result: { output: 'done' } }, 1500), + loop({ type: 'step.end', uuid: 's2', turnId: '8', step: 0, finishReason: 'end_turn', usage: { inputOther: 20, output: 4, inputCacheRead: 5, inputCacheCreation: 1 } }, 1600), + ]); + + expect(a.turns).toHaveLength(2); + expect(a.turns[0]).toMatchObject({ + trigger: 'prompt', + turnId: 7, + steps: [{ uuid: 's1' }], + tokens: { inputOther: 10, output: 2, inputCacheRead: 0, inputCacheCreation: 0 }, + toolCallCount: 0, + }); + expect(a.turns[1]).toMatchObject({ + trigger: 'steer', + promptLineNo: steerLine, + promptTime: 1300, + promptText: 'continue', + originKind: 'system_trigger', + turnId: 8, + steps: [{ uuid: 's2' }], + tokens: { inputOther: 20, output: 4, inputCacheRead: 5, inputCacheCreation: 1 }, + toolCallCount: 1, + }); + }); + + it('splits truncated wires when step turn ids advance without a prompt record', () => { + line = 0; + const a = analyzeWire([ + e({ type: 'turn.prompt', input: [{ type: 'text', text: 'start' }], origin: { kind: 'user' } }, 1000), + loop({ type: 'step.begin', uuid: 's1', turnId: '7', step: 0 }, 1100), + loop({ type: 'step.end', uuid: 's1', turnId: '7', step: 0, finishReason: 'end_turn' }, 1200), + loop({ type: 'step.begin', uuid: 's2', turnId: '8', step: 0 }, 1300), + loop({ type: 'step.end', uuid: 's2', turnId: '8', step: 0, finishReason: 'end_turn' }, 1400), + ]); + + expect(a.turns).toHaveLength(2); + expect(a.turns[0]).toMatchObject({ turnId: 7, steps: [{ uuid: 's1' }] }); + expect(a.turns[1]).toMatchObject({ + trigger: 'prompt', + promptLineNo: 4, + promptText: '(no prompt record)', + turnId: 8, + steps: [{ uuid: 's2' }], + }); + }); + it('marks persisted error step endings as errors', () => { line = 0; const analysis = analyzeWire([ From 9ae4b21cb91dc8030352308b60ae428932c98375 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 4 Sep 2026 21:44:08 +0800 Subject: [PATCH 3/4] fix(vis): harden file history rendering --- .../vis/web/src/components/wire/renderers.tsx | 238 +++++++++++++----- apps/vis/web/test/renderers.test.ts | 116 +++++++++ 2 files changed, 290 insertions(+), 64 deletions(-) diff --git a/apps/vis/web/src/components/wire/renderers.tsx b/apps/vis/web/src/components/wire/renderers.tsx index 2047be519ea..13c4106721c 100644 --- a/apps/vis/web/src/components/wire/renderers.tsx +++ b/apps/vis/web/src/components/wire/renderers.tsx @@ -52,6 +52,87 @@ function compactionSummaryText(r: AgentRecordOf<'context.apply_compaction'>): st return ('contextSummary' in r ? r.contextSummary : undefined) ?? ''; } +type UnknownObject = Record; + +function asObject(value: unknown): UnknownObject | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as UnknownObject) + : undefined; +} + +function valuePreview(value: unknown): string { + if (value === null) return 'null'; + if (typeof value === 'string') return JSON.stringify(value); + if ( + typeof value === 'number' || + typeof value === 'boolean' || + typeof value === 'bigint' + ) { + return String(value); + } + if (value === undefined) return 'undefined'; + try { + const serialized = JSON.stringify(value); + return serialized === undefined ? `[${typeof value}]` : truncate(serialized, 80); + } catch { + return '[unserializable]'; + } +} + +function invalidValue(value: unknown): string { + return value === undefined ? '(missing)' : `(invalid: ${valuePreview(value)})`; +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value : invalidValue(value); +} + +function numberValue(value: unknown): string { + return typeof value === 'number' && Number.isFinite(value) + ? String(value) + : invalidValue(value); +} + +function checkpointPhase(value: unknown): { label: string; tone: PillTone } { + if (value === undefined || value === 'start') return { label: 'start', tone: 'lifecycle' }; + if (value === 'end') return { label: 'end', tone: 'success' }; + return { label: invalidValue(value), tone: 'warning' }; +} + +function trackedStatus(value: unknown): { label: string; tone: PillTone } { + const entry = asObject(value); + if (entry === undefined) return { label: 'entry unavailable', tone: 'warning' }; + if (entry.oversize === true) return { label: 'oversize', tone: 'warning' }; + if (entry.oversize !== undefined && typeof entry.oversize !== 'boolean') { + return { label: 'invalid entry', tone: 'warning' }; + } + if (entry.key === null) return { label: 'new', tone: 'info' }; + if (typeof entry.key !== 'string') return { label: 'invalid entry', tone: 'warning' }; + if (typeof entry.version !== 'number' || !Number.isFinite(entry.version)) { + return { label: 'invalid version', tone: 'warning' }; + } + return { label: `v${entry.version}`, tone: 'tools' }; +} + +function snapshotKey(entry: UnknownObject | undefined): { label: string; dim: boolean } { + if (entry === undefined) return { label: '(entry unavailable)', dim: true }; + if (entry.oversize === true) return { label: '(not captured: oversized)', dim: true }; + if (entry.key === null) return { label: '(file did not exist)', dim: true }; + if (typeof entry.key === 'string') return { label: entry.key, dim: false }; + return { label: invalidValue(entry.key), dim: true }; +} + +function sizeValue(value: unknown): string { + return typeof value === 'number' && Number.isFinite(value) + ? `${value}b` + : invalidValue(value); +} + +function timestampValue(value: unknown): string { + if (typeof value !== 'number' || !Number.isFinite(value)) return invalidValue(value); + return new Date(value).toLocaleString(); +} + export const WIRE_RENDERERS: RendererMap = { metadata: { tone: 'meta', @@ -71,91 +152,120 @@ export const WIRE_RENDERERS: RendererMap = { tone: 'lifecycle', label: 'files·checkpoint', headline: (r) => { - const phase = r.phase ?? 'start'; - const count = Object.keys(r.entries).length; + const record = r as unknown as UnknownObject; + const phase = checkpointPhase(record.phase); + const entries = asObject(record.entries); + const count = entries === undefined ? undefined : Object.keys(entries).length; return { main: ( - turn {r.turnId} + turn {numberValue(record.turnId)} - {count} file{count === 1 ? '' : 's'} + {count === undefined + ? 'entries unavailable' + : `${count} file${count === 1 ? '' : 's'}`} ), right: ( - - {phase} + + {phase.label} ), }; }, - detail: (r) => ( -
- - {r.turnId} - - - {r.phase ?? 'start'} - - - - -
- ), + detail: (r) => { + const record = r as unknown as UnknownObject; + const phase = checkpointPhase(record.phase); + return ( +
+ + {numberValue(record.turnId)} + + + {phase.label} + + + + +
+ ); + }, }, 'file_history.tracked': { tone: 'tools', label: 'file·tracked', - headline: (r) => ({ - main: ( - - turn {r.turnId} - {r.path} - - ), - right: ( - - {r.entry.key === null ? 'new' : `v${r.entry.version}`} - - ), - }), - detail: (r) => ( -
- - {r.turnId} - - - {r.path} - - - {r.entry.version} - - - {r.entry.key === null ? (file did not exist) : {r.entry.key}} - - {r.entry.contentHash !== undefined ? ( - - {r.entry.contentHash} + headline: (r) => { + const record = r as unknown as UnknownObject; + const status = trackedStatus(record.entry); + return { + main: ( + + turn {numberValue(record.turnId)} + {stringValue(record.path)} + + ), + right: ( + + {status.label} + + ), + }; + }, + detail: (r) => { + const record = r as unknown as UnknownObject; + const entry = asObject(record.entry); + const key = snapshotKey(entry); + const malformedOversize = + entry !== undefined && + entry.oversize !== undefined && + typeof entry.oversize !== 'boolean'; + return ( +
+ + {numberValue(record.turnId)} - ) : null} - {r.entry.size !== undefined ? ( - - {r.entry.size}b + + {stringValue(record.path)} - ) : null} - {r.entry.mtimeMs !== undefined ? ( - - {new Date(r.entry.mtimeMs).toLocaleString()} + + + {entry === undefined ? '(entry unavailable)' : numberValue(entry.version)} + - ) : null} - {r.entry.oversize === true ? ( - - true + + {key.dim ? {key.label} : {key.label}} - ) : null} -
- ), + {entry !== undefined && entry.contentHash !== undefined ? ( + + {stringValue(entry.contentHash)} + + ) : null} + {entry !== undefined && entry.size !== undefined ? ( + + {sizeValue(entry.size)} + + ) : null} + {entry !== undefined && entry.mtimeMs !== undefined ? ( + + {timestampValue(entry.mtimeMs)} + + ) : null} + {entry?.oversize === true ? ( + + + true + + + ) : malformedOversize ? ( + + {invalidValue(entry.oversize)} + + ) : null} +
+ ); + }, }, forked: { diff --git a/apps/vis/web/test/renderers.test.ts b/apps/vis/web/test/renderers.test.ts index 1d369f381e1..764272636b2 100644 --- a/apps/vis/web/test/renderers.test.ts +++ b/apps/vis/web/test/renderers.test.ts @@ -1,10 +1,40 @@ import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; +import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it } from 'vitest'; import { WIRE_RENDERERS } from '../src/components/wire/renderers'; +type CheckpointRecord = Parameters< + (typeof WIRE_RENDERERS)['file_history.checkpoint']['headline'] +>[0]; +type TrackedRecord = Parameters< + (typeof WIRE_RENDERERS)['file_history.tracked']['headline'] +>[0]; + +function checkpointRecord(overrides: Record = {}): CheckpointRecord { + return { + type: 'file_history.checkpoint', + agentId: 'main', + turnId: 7, + phase: 'start', + entries: {}, + ...overrides, + } as unknown as CheckpointRecord; +} + +function trackedRecord(overrides: Record = {}): TrackedRecord { + return { + type: 'file_history.tracked', + agentId: 'main', + turnId: 7, + path: '/workspace/example.txt', + entry: { key: 'snapshot-7', version: 2 }, + ...overrides, + } as unknown as TrackedRecord; +} + const HISTORICAL_OR_HEADER_TYPES = new Set([ 'metadata', 'context.update_token_count', @@ -32,4 +62,90 @@ describe('wire renderers', () => { expect(renderedCurrentTypes).toEqual(upstreamTypes); }); + + it('distinguishes oversized snapshots from files that did not exist', () => { + const renderer = WIRE_RENDERERS['file_history.tracked']; + const oversized = trackedRecord({ + entry: { key: null, version: 2, oversize: true, size: 10_000_000 }, + }); + const oversizedHeadline = renderer.headline(oversized); + const oversizedDetail = renderer.detail?.(oversized); + + expect(renderToStaticMarkup(oversizedHeadline.right)).toContain('oversize'); + expect(renderToStaticMarkup(oversizedHeadline.right)).not.toContain('new'); + expect(renderToStaticMarkup(oversizedDetail)).toContain('not captured: oversized'); + expect(renderToStaticMarkup(oversizedDetail)).not.toContain('file did not exist'); + + const missing = trackedRecord({ entry: { key: null, version: 2 } }); + const missingHeadline = renderer.headline(missing); + const missingDetail = renderer.detail?.(missing); + + expect(renderToStaticMarkup(missingHeadline.right)).toContain('new'); + expect(renderToStaticMarkup(missingDetail)).toContain('file did not exist'); + }); + + it.each([ + ['missing', undefined], + ['null', null], + ['scalar', 'broken'], + ['array', []], + ])('renders a checkpoint with %s entries without throwing', (_label, entries) => { + const renderer = WIRE_RENDERERS['file_history.checkpoint']; + const record = checkpointRecord({ + turnId: { unexpected: true }, + phase: { unexpected: true }, + entries, + }); + const headline = renderer.headline(record); + const detail = renderer.detail?.(record); + + expect(renderToStaticMarkup(headline.main)).toContain('entries unavailable'); + expect(renderToStaticMarkup(headline.main)).toContain('invalid'); + expect(renderToStaticMarkup(headline.right)).toContain('invalid'); + expect(() => renderToStaticMarkup(detail)).not.toThrow(); + }); + + it.each([ + ['missing', undefined], + ['null', null], + ['scalar', 'broken'], + ['array', []], + ])('renders a tracked record with a %s entry without throwing', (_label, entry) => { + const renderer = WIRE_RENDERERS['file_history.tracked']; + const record = trackedRecord({ + turnId: { unexpected: true }, + path: { unexpected: true }, + entry, + }); + const headline = renderer.headline(record); + const detail = renderer.detail?.(record); + + expect(renderToStaticMarkup(headline.main)).toContain('invalid'); + expect(renderToStaticMarkup(headline.right)).toContain('entry unavailable'); + expect(renderToStaticMarkup(detail)).toContain('entry unavailable'); + }); + + it('renders malformed tracked fields as readable text', () => { + const renderer = WIRE_RENDERERS['file_history.tracked']; + const malformed = { unexpected: true }; + const record = trackedRecord({ + turnId: malformed, + path: malformed, + entry: { + key: malformed, + version: malformed, + contentHash: malformed, + size: malformed, + mtimeMs: malformed, + oversize: malformed, + }, + }); + const headline = renderer.headline(record); + const detail = renderer.detail?.(record); + + expect(renderToStaticMarkup(headline.main)).toContain('invalid'); + expect(renderToStaticMarkup(headline.right)).toContain('invalid entry'); + expect(renderToStaticMarkup(detail)).toContain('invalid'); + expect(renderToStaticMarkup(detail)).not.toContain('[object Object]'); + }); }); From 760119fdbe2492eaeda220d59d68fcf8a12255d2 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 4 Sep 2026 22:13:28 +0800 Subject: [PATCH 4/4] fix(vis): validate persisted debug payloads --- apps/vis/server/src/lib/task-store.ts | 201 +++++++++++++----- apps/vis/server/test/lib/task-store.test.ts | 145 ++++++++++++- .../vis/web/src/components/wire/renderers.tsx | 188 ++++++++++++---- apps/vis/web/test/renderers.test.ts | 121 +++++++++++ 4 files changed, 556 insertions(+), 99 deletions(-) diff --git a/apps/vis/server/src/lib/task-store.ts b/apps/vis/server/src/lib/task-store.ts index 62b8e16f22f..4caeb71c672 100644 --- a/apps/vis/server/src/lib/task-store.ts +++ b/apps/vis/server/src/lib/task-store.ts @@ -95,14 +95,9 @@ async function listBackgroundTasksAt( continue; } if (!isReadablePersistedTask(parsed)) continue; - try { - tasks.push({ keyId: id, task: normalizePersistedTask(parsed) }); - } catch { - // A record can pass the shape guard but still hold type-corrupt fields - // (e.g. a legacy `stop_reason` that is a number). Honour the - // silently-skips contract instead of failing the whole listing. - continue; - } + const task = normalizePersistedTask(parsed); + if (task === undefined || task.taskId !== id) continue; + tasks.push({ keyId: id, task }); } return { reservedIds, tasks }; } @@ -219,81 +214,152 @@ function isMissingPath(error: unknown): boolean { // ── normalization (ported from agent-core-v2/agent/task/persist.ts) ──────── -type LegacyBackgroundTaskStatus = - | 'running' - | 'awaiting_approval' - | 'completed' - | 'failed' - | 'killed' - | 'lost'; - -interface LegacyPersistedTask { - readonly task_id: string; - readonly command: string; +type ReadablePersistedTask = Record; + +interface CurrentTaskBase { + readonly taskId: string; readonly description: string; - readonly pid: number; - readonly started_at: number; - readonly ended_at: number | null; - readonly exit_code: number | null; - readonly status: LegacyBackgroundTaskStatus; - readonly timed_out?: boolean; - readonly stop_reason?: string; - readonly timeout_ms?: number; - readonly agent_id?: string; - readonly subagent_type?: string; + readonly status: BackgroundTaskStatus; + readonly detached: boolean; + readonly startedAt: number; + readonly endedAt: number | null; + readonly stopReason?: string; + readonly terminalNotificationSuppressed?: boolean; + readonly resumeReminded?: boolean; + readonly timeoutMs?: number; +} + +const CURRENT_TASK_STATUSES: ReadonlySet = new Set([ + 'running', + 'completed', + 'failed', + 'timed_out', + 'killed', + 'lost', +]); + +function normalizePersistedTask(task: ReadablePersistedTask): BackgroundTaskInfo | undefined { + const current = isLegacyPersistedTask(task) ? legacyPersistedTaskToCurrent(task) : task; + return decodeCurrentPersistedTask(current); } -type DiskPersistedTask = BackgroundTaskInfo | LegacyPersistedTask; +function decodeCurrentPersistedTask(task: ReadablePersistedTask): BackgroundTaskInfo | undefined { + const base = decodeCurrentTaskBase(task); + if (base === undefined) return undefined; -function normalizePersistedTask(task: DiskPersistedTask): BackgroundTaskInfo { - if (isLegacyPersistedTask(task)) return legacyPersistedTaskToInfo(task); - return { ...task, detached: task.detached ?? true }; + switch (task['kind']) { + case 'process': + if ( + typeof task['command'] !== 'string' || + !isFiniteNumber(task['pid']) || + !isNullableFiniteNumber(task['exitCode']) + ) { + return undefined; + } + return { + ...base, + kind: 'process', + command: task['command'], + pid: task['pid'], + exitCode: task['exitCode'], + parentToolCallId: optionalString(task['parentToolCallId']), + }; + case 'agent': + return { + ...base, + kind: 'agent', + agentId: optionalString(task['agentId']), + subagentType: optionalString(task['subagentType']), + parentToolCallId: optionalString(task['parentToolCallId']), + model: optionalString(task['model']), + thinkingEffort: optionalString(task['thinkingEffort']), + stopCode: optionalString(task['stopCode']), + }; + case 'question': + if (!isFiniteNumber(task['questionCount'])) return undefined; + return { + ...base, + kind: 'question', + questionCount: task['questionCount'], + toolCallId: optionalString(task['toolCallId']), + }; + default: + return undefined; + } +} + +function decodeCurrentTaskBase(task: ReadablePersistedTask): CurrentTaskBase | undefined { + if ( + typeof task['taskId'] !== 'string' || + !VALID_TASK_ID.test(task['taskId']) || + typeof task['description'] !== 'string' || + !isCurrentTaskStatus(task['status']) || + !isFiniteNumber(task['startedAt']) || + !isNullableFiniteNumber(task['endedAt']) + ) { + return undefined; + } + return { + taskId: task['taskId'], + description: task['description'], + status: task['status'], + detached: optionalBoolean(task['detached']) ?? true, + startedAt: task['startedAt'], + endedAt: task['endedAt'], + stopReason: optionalString(task['stopReason']), + terminalNotificationSuppressed: optionalBoolean(task['terminalNotificationSuppressed']), + resumeReminded: optionalBoolean(task['resumeReminded']), + timeoutMs: optionalNumber(task['timeoutMs']), + }; } -function legacyPersistedTaskToInfo(task: LegacyPersistedTask): BackgroundTaskInfo { - const status = legacyStatusToCurrent(task); - const base = { +function legacyPersistedTaskToCurrent( + task: ReadablePersistedTask & { readonly task_id: string }, +): ReadablePersistedTask { + const base: ReadablePersistedTask = { taskId: task.task_id, - description: task.description, - status, + description: task['description'], + status: legacyStatusToCurrent(task), detached: true, - startedAt: task.started_at, - endedAt: task.ended_at, - stopReason: optionalNonEmptyString(task.stop_reason), - timeoutMs: typeof task.timeout_ms === 'number' ? task.timeout_ms : undefined, + startedAt: task['started_at'], + endedAt: task['ended_at'], + stopReason: optionalNonEmptyString(task['stop_reason']), + timeoutMs: optionalNumber(task['timeout_ms']), }; if (task.task_id.startsWith('agent-')) { return { ...base, kind: 'agent', - agentId: optionalNonEmptyString(task.agent_id), - subagentType: optionalNonEmptyString(task.subagent_type), + agentId: optionalNonEmptyString(task['agent_id']), + subagentType: optionalNonEmptyString(task['subagent_type']), }; } return { ...base, kind: 'process', - command: task.command, - pid: task.pid, - exitCode: task.exit_code, + command: task['command'], + pid: task['pid'], + exitCode: task['exit_code'], }; } -function legacyStatusToCurrent(task: LegacyPersistedTask): BackgroundTaskStatus { - if (task.status === 'awaiting_approval') return 'running'; - if (task.status === 'failed' && task.timed_out === true) return 'timed_out'; - return task.status; +function legacyStatusToCurrent(task: ReadablePersistedTask): unknown { + if (task['status'] === 'awaiting_approval') return 'running'; + if (task['status'] === 'failed' && task['timed_out'] === true) return 'timed_out'; + return task['status']; } -function isReadablePersistedTask(obj: unknown): obj is DiskPersistedTask { +function isReadablePersistedTask(obj: unknown): obj is ReadablePersistedTask { return ( isRecord(obj) && (typeof obj['taskId'] === 'string' || typeof obj['task_id'] === 'string') ); } -function isLegacyPersistedTask(task: DiskPersistedTask): task is LegacyPersistedTask { - return 'task_id' in task; +function isLegacyPersistedTask( + task: ReadablePersistedTask, +): task is ReadablePersistedTask & { readonly task_id: string } { + return typeof task['task_id'] === 'string'; } function isRecord(value: unknown): value is Record { @@ -305,3 +371,30 @@ function optionalNonEmptyString(value: unknown): string | undefined { const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; } + +function optionalString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function optionalBoolean(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined; +} + +function optionalNumber(value: unknown): number | undefined { + return isFiniteNumber(value) ? value : undefined; +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function isNullableFiniteNumber(value: unknown): value is number | null { + return value === null || isFiniteNumber(value); +} + +function isCurrentTaskStatus(value: unknown): value is BackgroundTaskStatus { + return ( + typeof value === 'string' && + CURRENT_TASK_STATUSES.has(value as BackgroundTaskStatus) + ); +} diff --git a/apps/vis/server/test/lib/task-store.test.ts b/apps/vis/server/test/lib/task-store.test.ts index e18e83926ad..cda201042e1 100644 --- a/apps/vis/server/test/lib/task-store.test.ts +++ b/apps/vis/server/test/lib/task-store.test.ts @@ -29,17 +29,21 @@ describe('task-store', () => { await writeTask(sessionDir, 'bash-aaaaaaaa.json', { taskId: 'bash-aaaaaaaa', kind: 'process', description: 'run build', command: 'pnpm build', pid: 4242, exitCode: 0, status: 'completed', - detached: true, startedAt: 1000, endedAt: 2000, + detached: true, startedAt: 1000, endedAt: 2000, stopReason: 'finished', + terminalNotificationSuppressed: true, resumeReminded: false, timeoutMs: 60_000, + parentToolCallId: 'tool-process', }); await writeTask(sessionDir, 'agent-bbbbbbbb.json', { taskId: 'agent-bbbbbbbb', kind: 'agent', description: 'explore repo', agentId: 'agent-1', subagentType: 'Explore', status: 'running', detached: true, startedAt: 3000, endedAt: null, + parentToolCallId: 'tool-agent', model: 'kimi-for-coding', + thinkingEffort: 'high', stopCode: 'end_turn', }); await writeTask(sessionDir, 'question-cccccccc.json', { taskId: 'question-cccccccc', kind: 'question', description: 'ask user', questionCount: 2, status: 'running', detached: false, - startedAt: 2500, endedAt: null, + startedAt: 2500, endedAt: null, toolCallId: 'tool-question', }); const tasks = await listBackgroundTasks(sessionDir); @@ -49,9 +53,142 @@ describe('task-store', () => { 'bash-aaaaaaaa', // 1000 ]); const proc = tasks.find((t) => t.kind === 'process'); - expect(proc).toMatchObject({ command: 'pnpm build', pid: 4242, exitCode: 0 }); + expect(proc).toMatchObject({ + command: 'pnpm build', + pid: 4242, + exitCode: 0, + stopReason: 'finished', + terminalNotificationSuppressed: true, + resumeReminded: false, + timeoutMs: 60_000, + parentToolCallId: 'tool-process', + }); + const agent = tasks.find((t) => t.kind === 'agent'); + expect(agent).toMatchObject({ + agentId: 'agent-1', + subagentType: 'Explore', + parentToolCallId: 'tool-agent', + model: 'kimi-for-coding', + thinkingEffort: 'high', + stopCode: 'end_turn', + }); const question = tasks.find((t) => t.kind === 'question'); - expect(question).toMatchObject({ questionCount: 2, detached: false }); + expect(question).toMatchObject({ + questionCount: 2, + toolCallId: 'tool-question', + detached: false, + }); + }); + + it('sanitizes type-corrupt optional fields on every current task kind', async () => { + const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + + await writeTask(sessionDir, 'bash-aaaaaaaa.json', { + taskId: 'bash-aaaaaaaa', kind: 'process', description: 'process', + command: 'true', pid: 1, exitCode: null, status: 'running', + detached: {}, startedAt: 100, endedAt: null, stopReason: {}, + terminalNotificationSuppressed: 'yes', resumeReminded: [], timeoutMs: '1000', + parentToolCallId: {}, + }); + await writeTask(sessionDir, 'agent-bbbbbbbb.json', { + taskId: 'agent-bbbbbbbb', kind: 'agent', description: 'agent', + status: 'failed', startedAt: 200, endedAt: 300, + agentId: {}, subagentType: [], parentToolCallId: 1, model: {}, + thinkingEffort: false, stopCode: { code: 'broken' }, + }); + await writeTask(sessionDir, 'question-cccccccc.json', { + taskId: 'question-cccccccc', kind: 'question', description: 'question', + questionCount: 2, status: 'completed', startedAt: 300, endedAt: 400, + toolCallId: {}, + }); + + const tasks = await listBackgroundTasks(sessionDir); + expect(tasks).toHaveLength(3); + + const proc = tasks.find((task) => task.kind === 'process')!; + expect(proc.detached).toBe(true); + expect(proc.stopReason).toBeUndefined(); + expect(proc.terminalNotificationSuppressed).toBeUndefined(); + expect(proc.resumeReminded).toBeUndefined(); + expect(proc.timeoutMs).toBeUndefined(); + expect(proc.parentToolCallId).toBeUndefined(); + + const agent = tasks.find((task) => task.kind === 'agent')!; + expect(agent.agentId).toBeUndefined(); + expect(agent.subagentType).toBeUndefined(); + expect(agent.parentToolCallId).toBeUndefined(); + expect(agent.model).toBeUndefined(); + expect(agent.thinkingEffort).toBeUndefined(); + expect(agent.stopCode).toBeUndefined(); + + const question = tasks.find((task) => task.kind === 'question')!; + expect(question.toolCallId).toBeUndefined(); + for (const task of tasks) { + expect(Object.values(task).some((value) => value !== null && typeof value === 'object')) + .toBe(false); + } + }); + + it('skips current tasks with invalid discriminants or required fields', async () => { + const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + + const agent = { + taskId: 'agent-00000000', kind: 'agent', description: 'valid', + status: 'running', startedAt: 100, endedAt: null, + }; + const corrupt = [ + { ...agent, taskId: 'invalid' }, + { ...agent, kind: 'unknown' }, + { ...agent, description: {} }, + { ...agent, status: 'awaiting_approval' }, + { ...agent, startedAt: '100' }, + { ...agent, endedAt: {} }, + { ...agent, kind: 'process', command: {}, pid: 1, exitCode: null }, + { ...agent, kind: 'process', command: 'true', pid: '1', exitCode: null }, + { ...agent, kind: 'process', command: 'true', pid: 1, exitCode: '0' }, + { ...agent, kind: 'question', questionCount: '1' }, + ]; + for (const [index, task] of corrupt.entries()) { + await writeTask(sessionDir, `task-0000000${index}.json`, task); + } + await writeTask(sessionDir, 'agent-ffffffff.json', { + ...agent, + taskId: 'agent-ffffffff', + }); + + expect((await listBackgroundTasks(sessionDir)).map((task) => task.taskId)).toEqual([ + 'agent-ffffffff', + ]); + }); + + it('skips task ids that disagree with their file key and keeps primary shadowing', async () => { + const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const mainDir = join(sessionDir, 'agents', 'main'); + + await writeTask(mainDir, 'bash-aaaaaaaa.json', { + taskId: 'bash-bbbbbbbb', kind: 'process', description: 'current mismatch', + command: 'true', pid: 1, exitCode: 0, status: 'completed', + startedAt: 100, endedAt: 200, + }); + await writeTask(mainDir, 'agent-cccccccc.json', { + task_id: 'agent-dddddddd', command: '', description: 'legacy mismatch', + pid: 1, started_at: 100, ended_at: 200, exit_code: 0, status: 'completed', + }); + await writeTask(sessionDir, 'bash-eeeeeeee.json', { + taskId: 'bash-eeeeeeee', kind: 'process', description: 'fallback shadowed', + command: 'true', pid: 2, exitCode: 0, status: 'completed', + startedAt: 100, endedAt: 200, + }); + await writeTask(mainDir, 'bash-eeeeeeee.json', { + taskId: 'bash-ffffffff', kind: 'process', description: 'primary mismatch', + command: 'true', pid: 3, exitCode: 0, status: 'completed', + startedAt: 100, endedAt: 200, + }); + + expect(await listBackgroundTasks(mainDir, sessionDir)).toEqual([]); }); it('normalizes legacy snake_case tasks to the current shape', async () => { diff --git a/apps/vis/web/src/components/wire/renderers.tsx b/apps/vis/web/src/components/wire/renderers.tsx index 13c4106721c..8ea9a0c4b3e 100644 --- a/apps/vis/web/src/components/wire/renderers.tsx +++ b/apps/vis/web/src/components/wire/renderers.tsx @@ -42,14 +42,54 @@ export interface WireRenderer { * over the full `RecordType` union, so TypeScript forces an entry per kind. */ type RendererMap = { [K in RecordType]: WireRenderer }; -/** Render the summary of a `context.apply_compaction` record across its v2 - * payload variants: a plain string on current records, a ContextMessage on - * the legacy variant; fall back to `contextSummary` when neither holds. */ -function compactionSummaryText(r: AgentRecordOf<'context.apply_compaction'>): string { - const summary = r.summary; - if (typeof summary === 'string') return summary; - if (summary !== undefined) return firstText(summary.content); - return ('contextSummary' in r ? r.contextSummary : undefined) ?? ''; +interface CompactionSummaryView { + label: 'summary' | 'contextSummary'; + text: string; + contextSummary?: string; + message?: UnknownObject; +} + +/** Normalize all three `context.apply_compaction` summary variants without + * trusting imported wire payloads. */ +function compactionSummaryView( + r: AgentRecordOf<'context.apply_compaction'>, +): CompactionSummaryView { + const record = r as unknown as UnknownObject; + const summary = record.summary; + const contextSummary = + typeof record.contextSummary === 'string' ? record.contextSummary : undefined; + if (typeof summary === 'string') { + return { + label: 'summary', + text: summary, + contextSummary: contextSummary === summary ? undefined : contextSummary, + }; + } + const message = asObject(summary); + const content = message?.content; + if (Array.isArray(content)) { + let text = ''; + for (const part of content) { + const candidate = asObject(part); + if (candidate?.type === 'text' && typeof candidate.text === 'string') { + text += candidate.text; + } + } + return { + label: 'summary', + text, + contextSummary: contextSummary === text ? undefined : contextSummary, + message, + }; + } + if (summary === undefined && contextSummary !== undefined) { + return { label: 'contextSummary', text: contextSummary }; + } + return { + label: 'summary', + text: invalidValue(summary), + contextSummary, + }; } type UnknownObject = Record; @@ -60,6 +100,25 @@ function asObject(value: unknown): UnknownObject | undefined { : undefined; } +function wireLineRange(value: unknown): { start: number; end: number } | undefined { + const range = asObject(value); + if (range === undefined) return undefined; + const { start, end } = range; + if ( + typeof start !== 'number' || + !Number.isFinite(start) || + !Number.isInteger(start) || + start < 0 || + typeof end !== 'number' || + !Number.isFinite(end) || + !Number.isInteger(end) || + end < 0 + ) { + return undefined; + } + return { start, end }; +} + function valuePreview(value: unknown): string { if (value === null) return 'null'; if (typeof value === 'string') return JSON.stringify(value); @@ -133,6 +192,20 @@ function timestampValue(value: unknown): string { return new Date(value).toLocaleString(); } +function optionalNumberValue(value: unknown, fallback: string): string { + return value === undefined ? fallback : numberValue(value); +} + +function compactionCount(record: UnknownObject): { label: 'compactedCount' | 'count'; value: string } { + if (record.compactedCount !== undefined) { + return { label: 'compactedCount', value: numberValue(record.compactedCount) }; + } + if (record.count !== undefined) { + return { label: 'count', value: numberValue(record.count) }; + } + return { label: 'compactedCount', value: '(missing)' }; +} + export const WIRE_RENDERERS: RendererMap = { metadata: { tone: 'meta', @@ -472,8 +545,10 @@ export const WIRE_RENDERERS: RendererMap = { // v2 payload variants: `summary` is a string on current records, a // ContextMessage on the legacy variant (which uses `count` instead of // `compactedCount`); `tokensBefore`/`tokensAfter` are optional. - const summaryText = compactionSummaryText(r); - const compactedCount = r.compactedCount ?? ('count' in r ? r.count : 0); + const record = r as unknown as UnknownObject; + const summary = compactionSummaryView(r); + const compactedCount = compactionCount(record); + const wireLines = wireLineRange(record.wireLines); return { main: ( @@ -481,77 +556,108 @@ export const WIRE_RENDERERS: RendererMap = { compacted
- summary {summaryText.length}b · {r.tokensBefore ?? '?'}→{r.tokensAfter ?? '?'} tok ·{' '} - {compactedCount} msgs + {summary.label} {summary.text.length}b · {optionalNumberValue(record.tokensBefore, '?')}→ + {optionalNumberValue(record.tokensAfter, '?')} tok ·{' '} + {compactedCount.value} msgs
), right: - r.wireLines === undefined ? undefined : ( + wireLines === undefined ? undefined : ( - L{r.wireLines.start}–{r.wireLines.end} + L{wireLines.start}–{wireLines.end} ), }; }, detail: (r) => { - const summaryText = compactionSummaryText(r); - const compactedCount = r.compactedCount ?? ('count' in r ? r.count : 0); - const contextSummary = - 'contextSummary' in r && typeof r.contextSummary === 'string' - ? r.contextSummary - : undefined; + const record = r as unknown as UnknownObject; + const summary = compactionSummaryView(r); + const compactedCount = compactionCount(record); + const wireLines = wireLineRange(record.wireLines); return (
- - -
{summaryText}
+ + +
{summary.text}
- {contextSummary !== undefined && contextSummary !== summaryText ? ( + {summary.message !== undefined ? ( + + + + ) : null} + {summary.contextSummary !== undefined ? ( -
{contextSummary}
+
+                  {summary.contextSummary}
+                
) : null} - - {compactedCount} + + {compactedCount.value} - {r.tokensBefore ?? '(n/a)'} + + {optionalNumberValue(record.tokensBefore, '(n/a)')} + - {r.tokensAfter ?? '(n/a)'} + + {optionalNumberValue(record.tokensAfter, '(n/a)')} + - {r.summaryOutputTokens !== undefined ? ( + {record.summaryOutputTokens !== undefined ? ( - {r.summaryOutputTokens} + + {numberValue(record.summaryOutputTokens)} + ) : null} - {r.keptUserMessageCount !== undefined ? ( + {record.keptUserMessageCount !== undefined ? ( - {r.keptUserMessageCount} + + {numberValue(record.keptUserMessageCount)} + ) : null} - {r.keptHeadUserMessageCount !== undefined ? ( + {record.keptHeadUserMessageCount !== undefined ? ( - {r.keptHeadUserMessageCount} + + {numberValue(record.keptHeadUserMessageCount)} + ) : null} - {r.droppedCount !== undefined ? ( + {record.droppedCount !== undefined ? ( - {r.droppedCount} + + {numberValue(record.droppedCount)} + + + ) : null} + {record.legacyTail !== undefined ? ( + + + {typeof record.legacyTail === 'boolean' + ? String(record.legacyTail) + : invalidValue(record.legacyTail)} + ) : null} - {r.wireLines !== undefined ? ( + {wireLines !== undefined ? ( - {r.wireLines.start}–{r.wireLines.end} + {wireLines.start}–{wireLines.end} ) : null} diff --git a/apps/vis/web/test/renderers.test.ts b/apps/vis/web/test/renderers.test.ts index 764272636b2..1b8dafc8670 100644 --- a/apps/vis/web/test/renderers.test.ts +++ b/apps/vis/web/test/renderers.test.ts @@ -12,6 +12,9 @@ type CheckpointRecord = Parameters< type TrackedRecord = Parameters< (typeof WIRE_RENDERERS)['file_history.tracked']['headline'] >[0]; +type CompactionRecord = Parameters< + (typeof WIRE_RENDERERS)['context.apply_compaction']['headline'] +>[0]; function checkpointRecord(overrides: Record = {}): CheckpointRecord { return { @@ -35,6 +38,16 @@ function trackedRecord(overrides: Record = {}): TrackedRecord { } as unknown as TrackedRecord; } +function compactionRecord(overrides: Record = {}): CompactionRecord { + return { + type: 'context.apply_compaction', + agentId: 'main', + summary: 'compact summary', + compactedCount: 4, + ...overrides, + } as unknown as CompactionRecord; +} + const HISTORICAL_OR_HEADER_TYPES = new Set([ 'metadata', 'context.update_token_count', @@ -148,4 +161,112 @@ describe('wire renderers', () => { expect(renderToStaticMarkup(detail)).toContain('invalid'); expect(renderToStaticMarkup(detail)).not.toContain('[object Object]'); }); + + it('renders a valid compaction wire-line range in the headline and detail', () => { + const renderer = WIRE_RENDERERS['context.apply_compaction']; + const record = compactionRecord({ wireLines: { start: 12, end: 34 } }); + const headline = renderer.headline(record); + const detail = renderer.detail?.(record); + + expect(renderToStaticMarkup(headline.right)).toContain('L12–34'); + expect(renderToStaticMarkup(detail)).toContain('wireLines'); + expect(renderToStaticMarkup(detail)).toContain('12–34'); + }); + + it('preserves each compaction summary variant and legacy field names', () => { + const renderer = WIRE_RENDERERS['context.apply_compaction']; + const dual = compactionRecord({ + summary: 'raw summary', + contextSummary: 'model summary', + }); + const dualMarkup = renderToStaticMarkup(renderer.detail?.(dual)); + expect(dualMarkup).toContain('raw summary'); + expect(dualMarkup).toContain('contextSummary'); + expect(dualMarkup).toContain('model summary'); + + const contextOnly = compactionRecord({ + summary: undefined, + contextSummary: 'context-only summary', + }); + expect(renderToStaticMarkup(renderer.headline(contextOnly).main)).toContain( + 'contextSummary', + ); + expect(renderToStaticMarkup(renderer.detail?.(contextOnly))).toContain( + 'context-only summary', + ); + + const legacy = compactionRecord({ + summary: { + role: 'user', + content: [ + { type: 'text', text: 'first' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,AAAA' } }, + { type: 'text', text: 'second' }, + ], + toolCalls: [{ type: 'function', id: 'call-1', name: 'Read', arguments: '{}' }], + origin: { kind: 'compaction_summary' }, + }, + compactedCount: undefined, + count: 3, + legacyTail: true, + }); + const legacyMarkup = renderToStaticMarkup(renderer.detail?.(legacy)); + expect(legacyMarkup).toContain('firstsecond'); + expect(legacyMarkup).toContain('summaryMessage'); + expect(legacyMarkup).toContain('toolCalls'); + expect(legacyMarkup).toContain('count'); + expect(legacyMarkup).not.toContain('compactedCount'); + expect(legacyMarkup).toContain('legacyTail'); + expect(legacyMarkup).toContain('true'); + }); + + it.each([ + ['null', null], + ['scalar', 'broken'], + ['array', [12, 34]], + ['missing start', { end: 34 }], + ['missing end', { start: 12 }], + ['NaN start', { start: Number.NaN, end: 34 }], + ['NaN end', { start: 12, end: Number.NaN }], + ['infinite start', { start: Number.POSITIVE_INFINITY, end: 34 }], + ['negative start', { start: -1, end: 34 }], + ['fractional end', { start: 12, end: 34.5 }], + ])('omits a compaction wire-line range with %s', (_label, wireLines) => { + const renderer = WIRE_RENDERERS['context.apply_compaction']; + const record = compactionRecord({ wireLines }); + const headline = renderer.headline(record); + const detail = renderer.detail?.(record); + + const headlineMarkup = renderToStaticMarkup(headline.right); + const detailMarkup = renderToStaticMarkup(detail); + expect(headlineMarkup).toBe(''); + expect(headlineMarkup).not.toContain('Lundefined'); + expect(detailMarkup).not.toContain('wireLines'); + expect(detailMarkup).not.toContain('undefined'); + }); + + it('renders malformed compaction scalars as readable text', () => { + const renderer = WIRE_RENDERERS['context.apply_compaction']; + const malformed = { unexpected: true }; + const record = compactionRecord({ + summary: malformed, + contextSummary: malformed, + compactedCount: malformed, + tokensBefore: malformed, + tokensAfter: malformed, + summaryOutputTokens: malformed, + keptUserMessageCount: malformed, + keptHeadUserMessageCount: malformed, + droppedCount: malformed, + }); + + const headline = renderer.headline(record); + const detail = renderer.detail?.(record); + const markup = [headline.main, headline.right, detail] + .map((node) => renderToStaticMarkup(node)) + .join(''); + + expect(markup).toContain('invalid'); + expect(markup).not.toContain('[object Object]'); + }); });