From 72d023000cc5a4ce64c45693a29cff6b0ff8b471 Mon Sep 17 00:00:00 2001 From: Thadeus Burgess Date: Fri, 5 Jun 2026 14:26:47 -0500 Subject: [PATCH 1/4] FEA-1554: Close session data collection gaps across all 5 harness parsers - Extend NormalizedSession with messages, tokenSeries, diffStats, slashCommands, artifacts fields; extend NormalizedToolUse with output, isError, mcpServer, mcpMethod, skillName, diffDelta - Add shared parser helpers: truncateText, computeLineDelta, extractRepoFromCwd, extractPrReferences, extractIssueReferences, collectArtifacts, isSyntheticModelKey - Claude parser: capture message text, per-turn tokens, tool output, diff stats, slash commands ( tags), skill names (Skill tool input.skill) - Codex parser: capture message text (remove void), reasoning, token series, tool output, MCP server/method (preserve from mcp_tool_call_begin), diff stats - Cursor parser: capture message text, token series, tool output, per-message model - Copilot parser: capture message text (both chat+CLI), token series, tool output - OpenCode parser: extend SELECT for per-message tokens/model/patch/summary columns - import-session: persist UserMessage/AssistantMessage events, enrich PostToolUse event data with output/MCP/skill/diff, extend buildMetadata with new fields - Cloud sync: add text/output/reasoning to STRIPPED_LEAF_KEYS - Wire computeTokenCost into dashboard getTokenAnalytics for per-model cost - Add event-role.ts (event_type -> human/agent/system mapping) - Add session-timing.ts (active agent vs waiting-on-user derivation) Testing: 70 new unit tests (parser-utils: 49, event-role: 14, session-timing: 7, cloud sync strip: 4). Full suite: 2,263 tests, 0 failures. Risks: Cloud-sync strip must not miss content keys; verified by round-trip test. --- .../src/main/agent-session-sync-service.ts | 2 +- .../main/collectors/claude/claude-parser.ts | 175 +++++++- .../src/main/collectors/codex/codex-parser.ts | 251 ++++++++++- .../main/collectors/copilot/copilot-parser.ts | 313 +++++++++++++- .../main/collectors/cursor/cursor-parser.ts | 82 +++- .../src/main/collectors/import-session.ts | 34 +- .../collectors/opencode/opencode-parser.ts | 310 +++++++++++++- .../src/main/collectors/parser-utils.ts | 147 ++++++- apps/desktop/src/main/collectors/types.ts | 61 +++ apps/desktop/src/main/database/dashboard.ts | 22 +- apps/desktop/src/shared/agent-db-contract.ts | 2 + apps/desktop/src/shared/event-role.ts | 14 + apps/desktop/src/shared/session-timing.ts | 37 ++ .../test/agent-session-sync-service.test.ts | 173 ++++++++ apps/desktop/test/event-role.test.ts | 67 +++ apps/desktop/test/parser-utils.test.ts | 402 ++++++++++++++++++ apps/desktop/test/session-timing.test.ts | 81 ++++ 17 files changed, 2101 insertions(+), 72 deletions(-) create mode 100644 apps/desktop/src/shared/event-role.ts create mode 100644 apps/desktop/src/shared/session-timing.ts create mode 100644 apps/desktop/test/event-role.test.ts create mode 100644 apps/desktop/test/parser-utils.test.ts create mode 100644 apps/desktop/test/session-timing.test.ts diff --git a/apps/desktop/src/main/agent-session-sync-service.ts b/apps/desktop/src/main/agent-session-sync-service.ts index 969fe3b4..26e1de51 100644 --- a/apps/desktop/src/main/agent-session-sync-service.ts +++ b/apps/desktop/src/main/agent-session-sync-service.ts @@ -833,7 +833,7 @@ export function sanitizeSessionForSync( }; } -const STRIPPED_LEAF_KEYS = new Set(["prompt", "content", "stdout", "stderr"]); +const STRIPPED_LEAF_KEYS = new Set(["prompt", "content", "stdout", "stderr", "text", "output", "reasoning"]); function stripDataContent(data: SyncJsonValue | undefined): SyncJsonValue | undefined { if (data === undefined || data === null) { diff --git a/apps/desktop/src/main/collectors/claude/claude-parser.ts b/apps/desktop/src/main/collectors/claude/claude-parser.ts index cf78272c..561dafc2 100644 --- a/apps/desktop/src/main/collectors/claude/claude-parser.ts +++ b/apps/desktop/src/main/collectors/claude/claude-parser.ts @@ -12,12 +12,16 @@ import path from "node:path"; import readline from "node:readline"; import type { NormalizedApiError, + NormalizedDiffStats, + NormalizedMessage, NormalizedSession, NormalizedTokenCounts, + NormalizedTokenRecord, NormalizedToolResultError, NormalizedToolUse, NormalizedTurnDuration, } from "../types.js"; +import { truncateText, computeLineDelta, collectArtifacts } from "../parser-utils.js"; /** Mirror the vendor's lenient timestamp handling: epoch number → ISO, string as-is. */ function isoTs(ts: unknown): string | null { @@ -72,6 +76,19 @@ export async function parseSessionFile(filePath: string): Promise(); const inferenceGeos = new Set(); + // CR-1: ordered messages + const messages: NormalizedMessage[] = []; + // CR-2: per-turn token time-series + const tokenSeries: NormalizedTokenRecord[] = []; + // CR-4: aggregate diff stats + let totalAdded = 0; + let totalRemoved = 0; + const diffFiles = new Set(); + // CR-7: slash commands + const slashCommands: Array<{ name: string; timestamp: string }> = []; + // CR-3: map tool_use_id → index in toolUses for back-linking tool results + const toolUseIdIndex = new Map(); + try { for await (const line of rl) { if (!line.trim()) continue; @@ -142,6 +159,52 @@ export async function parseSessionFile(filePath: string): Promise XML tags (slash commands). + const cmdRe = /([^<]+)<\/command-name>/g; + let cmdMatch: RegExpExecArray | null; + const entryIso = isoTs(entry.timestamp); + while ((cmdMatch = cmdRe.exec(userTextJoined)) !== null) { + if (entryIso) { + slashCommands.push({ name: cmdMatch[1].trim(), timestamp: entryIso }); + } + } + + // Existing: toolUseResult error tracking (top-level shorthand). const toolUseResult = entry.toolUseResult; if (toolUseResult && typeof toolUseResult === "object") { const tur = toolUseResult as Record; @@ -171,6 +234,18 @@ export async function parseSessionFile(filePath: string): Promise tags too. + const cmdRe = /([^<]+)<\/command-name>/g; + let cmdMatch: RegExpExecArray | null; + while ((cmdMatch = cmdRe.exec(assistantText)) !== null) { + if (iso) { + slashCommands.push({ name: cmdMatch[1].trim(), timestamp: iso }); } } } @@ -218,6 +366,15 @@ export async function parseSessionFile(filePath: string): Promise 0 + ? { filesChanged: diffFiles.size, linesAdded: totalAdded, linesRemoved: totalRemoved } + : null; + + // CR-13: Collect artifact references from tool uses. + const artifacts = collectArtifacts(toolUses, cwd); + return { sessionId, name: sessionName, @@ -247,5 +404,15 @@ export async function parseSessionFile(filePath: string): Promise { const iso = toIso(raw); @@ -200,6 +218,13 @@ export async function parseRolloutFile( if (role === "user") { userMessageCount++; if (explicitIso) pendingTurnStartedAt = explicitIso; + // CR-1: capture user message + messages.push({ + role: "human", + timestamp: iso || firstTimestamp, + text: truncateText(text), + model: currentTurnModel ?? model, + }); } else { assistantMessageCount++; if (iso) messageTimestamps.push(iso); @@ -215,16 +240,53 @@ export async function parseRolloutFile( timestamp: iso || firstTimestamp, }); } + // CR-1: capture assistant message + messages.push({ + role: "assistant", + timestamp: iso || firstTimestamp, + text: truncateText(text), + model: currentTurnModel ?? model, + }); } - void text; } else if (itype === "reasoning") { thinkingBlockCount++; - } else if (itype === "function_call" || itype === "custom_tool_call") { - toolUses.push({ - name: asStr(p.name) ?? asStr(p.tool_name) ?? "function", + // CR-1: capture reasoning as a thinking message + const reasoningText = extractText(p.content) || asStr(p.text) || asStr(p.summary) || null; + messages.push({ + role: "assistant", timestamp: iso || firstTimestamp, - input: safeJson(p.arguments != null ? p.arguments : p.input), + text: truncateText(reasoningText), + model: currentTurnModel ?? model, + isThinking: true, }); + } else if (itype === "function_call" || itype === "custom_tool_call") { + const toolName = asStr(p.name) ?? asStr(p.tool_name) ?? "function"; + const toolInput = safeJson(p.arguments != null ? p.arguments : p.input); + const tu: NormalizedToolUse = { + name: toolName, + timestamp: iso || firstTimestamp, + input: toolInput, + }; + // CR-4: parse apply_patch input as unified diff + if (toolName === "apply_patch") { + const rawInput = typeof p.arguments === "string" ? p.arguments + : typeof p.input === "string" ? p.input + : typeof toolInput === "string" ? toolInput + : null; + if (rawInput) { + const delta = computeUnifiedDiffDelta(rawInput); + tu.diffDelta = delta; + const files = countDiffFiles(rawInput); + if (!diffStats) { + diffStats = { filesChanged: files, linesAdded: delta.add, linesRemoved: delta.del }; + } else { + diffStats.filesChanged += files; + diffStats.linesAdded += delta.add; + diffStats.linesRemoved += delta.del; + } + } + } + toolUses.push(tu); } else if (itype === "local_shell_call") { const action = asRec(p.action) ?? {}; toolUses.push({ @@ -242,6 +304,15 @@ export async function parseRolloutFile( const isErr = outRec ? outRec.success === false || outRec.is_error === true || !!outRec.error : false; + // CR-3: capture tool output on the most recent matching tool use + const outputStr = + typeof out === "string" ? out : JSON.stringify(out); + const truncatedOutput = truncateText(outputStr); + if (toolUses.length > 0) { + const lastTool = toolUses[toolUses.length - 1]; + lastTool.output = truncatedOutput; + lastTool.isError = isErr; + } if (isErr) { const content = typeof out === "string" @@ -297,11 +368,72 @@ export async function parseRolloutFile( asRec(info.total_token_usage) ?? asRec(info.totalTokenUsage) ?? asRec(info.total); - if (totals) latestTotals = totals; - const turnCtx = asRec(p.turn_context); - const m = - (turnCtx && asStr(turnCtx.model)) || asStr(info.model) || asStr(p.model); - if (m) model = m; + if (totals) { + // CR-2: compute per-turn delta from cumulative totals + const curInput = num(totals, "input_tokens") || num(totals, "inputTokens"); + const curCached = + num(totals, "cached_input_tokens") || num(totals, "cachedInputTokens"); + const curOutput = + num(totals, "output_tokens") || num(totals, "outputTokens"); + const curReasoning = + num(totals, "reasoning_output_tokens") || num(totals, "reasoningOutputTokens"); + const curCacheWrite = + num(totals, "cache_write_tokens") || + num(totals, "cacheWriteTokens") || + num(totals, "cache_creation_input_tokens") || + num(totals, "cacheCreationInputTokens"); + + let deltaInput = curInput; + let deltaOutput = curOutput + curReasoning; + let deltaCacheRead = curCached; + let deltaCacheWrite = curCacheWrite; + + if (previousTotals) { + const prevInput = num(previousTotals, "input_tokens") || num(previousTotals, "inputTokens"); + const prevCached = + num(previousTotals, "cached_input_tokens") || num(previousTotals, "cachedInputTokens"); + const prevOutput = + num(previousTotals, "output_tokens") || num(previousTotals, "outputTokens"); + const prevReasoning = + num(previousTotals, "reasoning_output_tokens") || num(previousTotals, "reasoningOutputTokens"); + const prevCacheWrite = + num(previousTotals, "cache_write_tokens") || + num(previousTotals, "cacheWriteTokens") || + num(previousTotals, "cache_creation_input_tokens") || + num(previousTotals, "cacheCreationInputTokens"); + + deltaInput = Math.max(0, curInput - prevInput); + deltaOutput = Math.max(0, (curOutput + curReasoning) - (prevOutput + prevReasoning)); + deltaCacheRead = Math.max(0, curCached - prevCached); + deltaCacheWrite = Math.max(0, curCacheWrite - prevCacheWrite); + } + previousTotals = totals; + latestTotals = totals; + + // CR-5: read per-event model from turn_context + const turnCtx = asRec(p.turn_context); + const m = + (turnCtx && asStr(turnCtx.model)) || asStr(info.model) || asStr(p.model); + if (m) model = m; + const eventModel = m ?? model ?? "gpt-codex"; + + if (iso && (deltaInput || deltaOutput || deltaCacheRead || deltaCacheWrite)) { + tokenSeries.push({ + timestamp: iso, + model: eventModel, + input: deltaInput, + output: deltaOutput, + cacheRead: deltaCacheRead, + cacheWrite: deltaCacheWrite, + }); + } + } else { + // No totals object — still extract model if present + const turnCtx = asRec(p.turn_context); + const m = + (turnCtx && asStr(turnCtx.model)) || asStr(info.model) || asStr(p.model); + if (m) model = m; + } } else if (et === "error" || et === "stream_error") { apiErrors.push({ type: et, @@ -318,17 +450,78 @@ export async function parseRolloutFile( et === "mcp_tool_call_begin") ) { // Fallback only for older event-only logs with no response_item items. - const name = - et === "exec_command_begin" - ? "shell" - : et === "patch_apply_begin" - ? "apply_patch" - : asStr(p.tool) ?? asStr(p.server) ?? "mcp_tool"; - toolUses.push({ - name, - timestamp: iso || firstTimestamp, - input: p.command ?? p.changes ?? p.arguments ?? null, - }); + if (et === "mcp_tool_call_begin") { + // CR-6: preserve MCP server and method from the event payload + const server = asStr(p.server) ?? asStr(p.mcp_server) ?? undefined; + const method = asStr(p.method) ?? asStr(p.tool) ?? asStr(p.tool_name) ?? undefined; + const displayName = server && method ? `${server}__${method}` : (method ?? server ?? "mcp_tool"); + toolUses.push({ + name: displayName, + timestamp: iso || firstTimestamp, + input: p.arguments ?? p.input ?? null, + mcpServer: server, + mcpMethod: method, + }); + } else if (et === "patch_apply_begin") { + // CR-4: parse the patch input for diff stats + const patchInput = typeof p.changes === "string" + ? p.changes + : typeof p.patch === "string" + ? p.patch + : typeof p.arguments === "string" + ? p.arguments + : null; + const tu: NormalizedToolUse = { + name: "apply_patch", + timestamp: iso || firstTimestamp, + input: p.changes ?? p.patch ?? p.arguments ?? null, + }; + if (patchInput) { + const delta = computeUnifiedDiffDelta(patchInput); + tu.diffDelta = delta; + const files = countDiffFiles(patchInput); + // Aggregate into session-level diffStats + if (!diffStats) { + diffStats = { filesChanged: files, linesAdded: delta.add, linesRemoved: delta.del }; + } else { + diffStats.filesChanged += files; + diffStats.linesAdded += delta.add; + diffStats.linesRemoved += delta.del; + } + } + toolUses.push(tu); + } else { + toolUses.push({ + name: "shell", + timestamp: iso || firstTimestamp, + input: p.command ?? p.arguments ?? null, + }); + } + } else if (et === "mcp_tool_call_end") { + // CR-6: match MCP end event to the most recent MCP tool use and capture output + const out = p.output ?? p.result ?? undefined; + const outRec = asRec(out); + const isErr = outRec + ? outRec.success === false || outRec.is_error === true || !!outRec.error + : false; + // Find the last MCP tool use to attach output + for (let i = toolUses.length - 1; i >= 0; i--) { + if (toolUses[i].mcpServer != null || toolUses[i].mcpMethod != null) { + if (out !== undefined) { + const outputStr = typeof out === "string" ? out : JSON.stringify(out); + toolUses[i].output = truncateText(outputStr); + toolUses[i].isError = isErr; + } + break; + } + } + if (isErr) { + const content = + typeof out === "string" + ? out.slice(0, 500) + : JSON.stringify(out ?? {}).slice(0, 500); + toolResultErrors.push({ content, timestamp: iso }); + } } }; @@ -359,7 +552,11 @@ export async function parseRolloutFile( if (!model && p.model) model = asStr(p.model); } else if (c.kind === "turn_context") { const p = c.p; - if (p.model) model = asStr(p.model); // authoritative + // CR-5: turn_context.model is authoritative per CodexBar docs + if (p.model) { + model = asStr(p.model); + currentTurnModel = asStr(p.model); + } if (!cwd && p.cwd) cwd = asStr(p.cwd); } else if (c.kind === "response_item") { handleResponseItem(c.p, iso, explicitIso); @@ -402,6 +599,9 @@ export async function parseRolloutFile( } } + // CR-13: collect artifact references from all tool uses + const artifacts: NormalizedArtifacts = collectArtifacts(toolUses, cwd); + let fileModifiedAt: number | null = null; try { fileModifiedAt = fs.statSync(filePath).mtimeMs; @@ -439,5 +639,10 @@ export async function parseRolloutFile( thinkingBlockCount, toolResultErrors, usageExtras: { service_tiers: [], speeds: [], inference_geos: [] }, + messages, // CR-1 + tokenSeries, // CR-2 + diffStats, // CR-4 + slashCommands: [], // CR-7: Codex has no slash commands + artifacts, // CR-13 }; } diff --git a/apps/desktop/src/main/collectors/copilot/copilot-parser.ts b/apps/desktop/src/main/collectors/copilot/copilot-parser.ts index 75aa1936..30db0897 100644 --- a/apps/desktop/src/main/collectors/copilot/copilot-parser.ts +++ b/apps/desktop/src/main/collectors/copilot/copilot-parser.ts @@ -20,12 +20,16 @@ import { toIso, safeJson, pushTurnDuration, + truncateText, + collectArtifacts, } from "../parser-utils.js"; import type { NormalizedSession, NormalizedToolUse, NormalizedApiError, NormalizedTurnDuration, + NormalizedMessage, + NormalizedTokenRecord, } from "../types.js"; /** True when `value` is a non-null object (and not an array). */ @@ -78,8 +82,72 @@ interface ChatEntry { role: "user" | "assistant"; timestamp: unknown; toolCalls?: unknown[]; + /** CR-1: User prompt or assistant response text. */ + text?: string | null; thinking?: boolean; error?: string | null; + /** CR-5: Per-request model identifier. */ + model?: string | null; + /** CR-2: Raw usage object for building tokenSeries. */ + usage?: Record | null; + /** CR-3: Tool result content keyed by tool call index/name. */ + toolResults?: Array<{ name: string; content: unknown; isError?: boolean }>; +} + +/** CR-1: Best-effort extraction of displayable text from a Copilot message payload. */ +function extractText(payload: unknown): string | null { + if (payload == null) return null; + if (typeof payload === "string") return payload; + if (typeof payload !== "object") return null; + const obj = payload as Record; + // Direct text/content fields + for (const key of ["text", "content", "body", "value", "message"]) { + const v = obj[key]; + if (typeof v === "string" && v.trim().length > 0) return v; + } + // Array of content parts (OpenAI-style) + if (Array.isArray(obj.content)) { + const parts = obj.content + .map((p: unknown) => { + if (typeof p === "string") return p; + if (p && typeof p === "object") { + const po = p as Record; + if (typeof po.text === "string") return po.text; + if (typeof po.content === "string") return po.content; + } + return null; + }) + .filter(Boolean); + if (parts.length > 0) return parts.join("\n"); + } + return null; +} + +/** CR-3: Collect tool result entries from a request's response flow. */ +function collectToolResults( + req: Record, +): Array<{ name: string; content: unknown; isError?: boolean }> { + const results: Array<{ name: string; content: unknown; isError?: boolean }> = []; + // Look in response.toolResults, result.toolResults, etc. + for (const outer of ["response", "result", "reply", "output"]) { + const container = req[outer]; + if (!container || typeof container !== "object") continue; + const containerObj = container as Record; + for (const key of ["toolResults", "tool_results", "functionResults"]) { + const arr = containerObj[key]; + if (!Array.isArray(arr)) continue; + for (const entry of arr) { + if (!entry || typeof entry !== "object") continue; + const e = entry as Record; + results.push({ + name: String(e.name || e.toolName || e.tool || "copilot_tool"), + content: e.content ?? e.result ?? e.output ?? null, + isError: Boolean(e.isError || e.is_error || e.error), + }); + } + } + } + return results; } function normalizeChatRequest(request: unknown, sessionData: Record): ChatEntry[] { @@ -121,6 +189,23 @@ function normalizeChatRequest(request: unknown, sessionData: Record : null; + + // CR-3: Tool results from the response flow + const toolResults = collectToolResults(req); + const entries: ChatEntry[] = []; if ( hasRenderableContent(userPayload) || @@ -130,6 +215,8 @@ function normalizeChatRequest(request: unknown, sessionData: Record { const iso = toIso(raw); @@ -254,14 +352,63 @@ export function parseChatSessionFile( const iso = noteTs(ts); const role = msgObj.role || msgObj.author || msgObj.type || ""; + // CR-5: Per-message model + const msgModel = (msgObj.model as string | null) || null; + if (role === "user" || role === "human") { userMessageCount++; if (iso) pendingTurnStartedAt = iso; + // CR-1: User message + normalizedMessages.push({ + role: "human", + timestamp: iso, + text: truncateText(msgObj.text as string | null ?? extractText(msgObj)), + model: msgModel, + }); } else if (role === "assistant" || role === "copilot" || role === "bot") { assistantMessageCount++; if (iso) messageTimestamps.push(iso); pushTurnDuration(turnDurations, pendingTurnStartedAt, iso); pendingTurnStartedAt = null; + + const isThinking = Boolean(msgObj.thinking || msgObj.reasoning); + + // CR-1: Assistant message (thinking indicator uses null text) + if (isThinking) { + normalizedMessages.push({ + role: "assistant", + timestamp: iso, + text: null, + model: msgModel, + isThinking: true, + }); + } else { + normalizedMessages.push({ + role: "assistant", + timestamp: iso, + text: truncateText(msgObj.text as string | null ?? extractText(msgObj)), + model: msgModel, + }); + } + + // CR-2: Build tokenSeries from per-message usage + const msgUsage = msgObj.usage as Record | null | undefined; + if (msgUsage && typeof msgUsage === "object" && iso) { + const inp = Number(msgUsage.input_tokens ?? msgUsage.prompt_tokens ?? 0); + const out = Number(msgUsage.output_tokens ?? msgUsage.completion_tokens ?? 0); + const cr = Number(msgUsage.cache_read_tokens ?? msgUsage.cached_input_tokens ?? 0); + const cw = Number(msgUsage.cache_write_tokens ?? msgUsage.cache_creation_input_tokens ?? 0); + if (inp || out || cr || cw) { + tokenSeries.push({ + timestamp: iso, + model: msgModel || model || "copilot-default", + input: inp, + output: out, + cacheRead: cr, + cacheWrite: cw, + }); + } + } } // Tool uses embedded in messages @@ -274,14 +421,38 @@ export function parseChatSessionFile( for (const call of calls) { if (!call) continue; const callObj = call as Record; + // CR-3: Capture tool result content from call-level result + const rawOutput = callObj.result ?? callObj.output ?? callObj.response ?? null; + const outputText = typeof rawOutput === "string" ? truncateText(rawOutput) : rawOutput; + const callIsError = Boolean(callObj.isError || callObj.is_error); toolUses.push({ name: String(callObj.name || get(callObj.function, "name") || "copilot_tool"), timestamp: iso || firstTimestamp, input: safeJson(callObj.arguments || callObj.input || callObj.parameters), + ...(outputText != null ? { output: outputText } : {}), + ...(callIsError ? { isError: true } : {}), }); } } + // CR-3: Tool results from the ChatEntry enrichment path + const toolResults = msgObj.toolResults; + if (Array.isArray(toolResults)) { + for (const tr of toolResults) { + if (!tr || typeof tr !== "object") continue; + const trObj = tr as Record; + const rawContent = trObj.content ?? trObj.result ?? trObj.output ?? null; + const contentText = typeof rawContent === "string" ? truncateText(rawContent) : rawContent; + // Try to match to the last tool use with the same name + const trName = String(trObj.name || "copilot_tool"); + const matchIdx = toolUses.findLastIndex((tu) => tu.name === trName && tu.output == null); + if (matchIdx >= 0) { + if (contentText != null) toolUses[matchIdx].output = contentText; + if (trObj.isError || trObj.is_error) toolUses[matchIdx].isError = true; + } + } + } + // Thinking blocks if (msgObj.thinking || msgObj.reasoning) thinkingBlockCount++; @@ -311,6 +482,46 @@ export function parseChatSessionFile( } } + // CR-2: Also build tokenSeries from raw requests (for requests that go through + // the normalizeChatRequest path which enriches ChatEntry with usage). + // The normalizeChatMessages path already feeds into the message loop above, + // but raw requests have richer usage data. Build additional series entries + // from raw requests that weren't already captured. + for (const req of rawRequests) { + if (!req || typeof req !== "object") continue; + const reqObj = req as Record; + const usageInfo = + reqObj.usage || reqObj.tokenUsage || reqObj.token_count || + get(reqObj.response, "usage") || get(reqObj.result, "usage") || null; + if (!usageInfo || typeof usageInfo !== "object") continue; + const u = usageInfo as Record; + const reqTs = toIso( + reqObj.responseTimestamp || reqObj.responseDate || reqObj.updatedAt || + get(reqObj.response, "timestamp") || get(reqObj.result, "timestamp") || + reqObj.timestamp || reqObj.created_at || reqObj.createdAt || + dataObj.lastMessageDate || null, + ); + if (!reqTs) continue; + // Skip if we already have a tokenSeries entry at this exact timestamp + if (tokenSeries.some((ts) => ts.timestamp === reqTs)) continue; + const reqModel = (reqObj.model || reqObj.modelId || + get(reqObj.response, "model") || get(reqObj.result, "model") || null) as string | null; + const inp = Number(u.input_tokens ?? u.prompt_tokens ?? 0); + const out = Number(u.output_tokens ?? u.completion_tokens ?? 0); + const cr = Number(u.cache_read_tokens ?? u.cached_input_tokens ?? 0); + const cw = Number(u.cache_write_tokens ?? u.cache_creation_input_tokens ?? 0); + if (inp || out || cr || cw) { + tokenSeries.push({ + timestamp: reqTs, + model: reqModel || model || "copilot-default", + input: inp, + output: out, + cacheRead: cr, + cacheWrite: cw, + }); + } + } + // Merge request-level tokens (from raw requests before normalization) // with message-level tokens. Use summation since each request is unique. tokenFields.input += requestTokenFields.input; @@ -342,7 +553,6 @@ export function parseChatSessionFile( } catch { return null; } } - const model = (dataObj.model || dataObj.modelId || null) as string | null; const cwd = (workspacePath || dataObj.cwd || dataObj.workspaceFolder || null) as string | null; let fileModifiedAt: number | null = null; @@ -386,6 +596,16 @@ export function parseChatSessionFile( thinkingBlockCount, toolResultErrors, usageExtras: { service_tiers: [], speeds: [], inference_geos: [] }, + // CR-1: Ordered messages with text content + messages: normalizedMessages, + // CR-2: Per-turn token records + tokenSeries, + // CR-4: Diff stats absent at source for Copilot + diffStats: null, + // CR-7: Slash commands not applicable to Copilot + slashCommands: [], + // CR-13: Artifact references extracted from tool calls + artifacts: collectArtifacts(toolUses, cwd), }; } @@ -420,6 +640,10 @@ export async function parseCliEventFile( let tokenCacheWrite = 0; let tokenReasoning = 0; let pendingTurnStartedAt: string | null = null; + // CR-1: Ordered messages with text content + const normalizedMessages: NormalizedMessage[] = []; + // CR-2: Per-turn token records for time-series + const tokenSeries: NormalizedTokenRecord[] = []; const noteTs = (raw: unknown): string | null => { const iso = toIso(raw); @@ -448,16 +672,35 @@ export async function parseCliEventFile( if (!model) model = (payload.model || null) as string | null; } + // CR-5: Per-event model + const eventModel = (payload.model || recObj.model || null) as string | null; + // Messages if (type === "user_message" || type === "user_input" || type === "prompt") { userMessageCount++; if (iso) pendingTurnStartedAt = iso; + // CR-1: User message with text content + const userText = extractText(payload.content ?? payload.message ?? payload.text ?? payload.prompt ?? payload); + normalizedMessages.push({ + role: "human", + timestamp: iso, + text: truncateText(userText), + model: eventModel, + }); } if (type === "assistant_message" || type === "response" || type === "completion") { assistantMessageCount++; if (iso) messageTimestamps.push(iso); pushTurnDuration(turnDurations, pendingTurnStartedAt, iso); pendingTurnStartedAt = null; + // CR-1: Assistant message with text content + const assistantText = extractText(payload.content ?? payload.message ?? payload.text ?? payload.response ?? payload); + normalizedMessages.push({ + role: "assistant", + timestamp: iso, + text: truncateText(assistantText), + model: eventModel, + }); } // Tool calls @@ -469,6 +712,19 @@ export async function parseCliEventFile( }); } + // CR-3: Tool results — match back to the most recent unresolved tool use + if (type === "tool_result" || type === "function_result" || type === "command_result") { + const resultName = String(payload.name || payload.tool || "copilot_tool"); + const rawContent = payload.content ?? payload.result ?? payload.output ?? null; + const contentText = typeof rawContent === "string" ? truncateText(rawContent) : rawContent; + const resultIsError = Boolean(payload.isError || payload.is_error || payload.error); + const matchIdx = toolUses.findLastIndex((tu) => tu.name === resultName && tu.output == null); + if (matchIdx >= 0) { + if (contentText != null) toolUses[matchIdx].output = contentText; + if (resultIsError) toolUses[matchIdx].isError = true; + } + } + // Token usage if (type === "usage" || type === "token_count" || type === "metrics") { const info = (payload.usage || payload) as Record; @@ -483,6 +739,25 @@ export async function parseCliEventFile( if (info.reasoning_tokens != null) tokenReasoning = Number(info.reasoning_tokens); if (info.reasoning_output_tokens != null) tokenReasoning = Number(info.reasoning_output_tokens); if (payload.model) model = payload.model as string; + + // CR-2: Push per-event token record for time-series + if (iso) { + const usageModel = (eventModel || model || "copilot-default"); + const inp = Number(info.input_tokens ?? info.prompt_tokens ?? 0); + const out = Number(info.output_tokens ?? info.completion_tokens ?? 0); + const cr = Number(info.cache_read_tokens ?? info.cached_input_tokens ?? 0); + const cw = Number(info.cache_write_tokens ?? info.cache_creation_input_tokens ?? 0); + if (inp || out || cr || cw) { + tokenSeries.push({ + timestamp: iso, + model: usageModel, + input: inp, + output: out, + cacheRead: cr, + cacheWrite: cw, + }); + } + } } // Errors @@ -494,9 +769,17 @@ export async function parseCliEventFile( }); } - // Thinking + // Thinking — Copilot provides boolean-only thinking flag if (type === "reasoning" || type === "thinking") { thinkingBlockCount++; + // CR-1: Thinking indicator as message with null text + normalizedMessages.push({ + role: "assistant", + timestamp: iso, + text: null, + model: eventModel, + isThinking: true, + }); } } @@ -543,5 +826,15 @@ export async function parseCliEventFile( thinkingBlockCount, toolResultErrors, usageExtras: { service_tiers: [], speeds: [], inference_geos: [] }, + // CR-1: Ordered messages with text content + messages: normalizedMessages, + // CR-2: Per-turn token records + tokenSeries, + // CR-4: Diff stats absent at source for Copilot + diffStats: null, + // CR-7: Slash commands not applicable to Copilot + slashCommands: [], + // CR-13: Artifact references extracted from tool calls + artifacts: collectArtifacts(toolUses, cwd), }; } diff --git a/apps/desktop/src/main/collectors/cursor/cursor-parser.ts b/apps/desktop/src/main/collectors/cursor/cursor-parser.ts index d4a16fa1..5c2851be 100644 --- a/apps/desktop/src/main/collectors/cursor/cursor-parser.ts +++ b/apps/desktop/src/main/collectors/cursor/cursor-parser.ts @@ -14,14 +14,17 @@ import fs from "node:fs"; import path from "node:path"; import readline from "node:readline"; -import { toIso, safeJson, pushTurnDuration } from "../parser-utils.js"; +import { toIso, safeJson, pushTurnDuration, truncateText, collectArtifacts } from "../parser-utils.js"; import type { NormalizedApiError, + NormalizedMessage, NormalizedSession, + NormalizedTokenRecord, NormalizedToolResultError, NormalizedToolUse, NormalizedTurnDuration, } from "../types.js"; +import { emptyArtifacts } from "../types.js"; import { sessionIdFromTranscriptPath } from "./cursor-home.js"; /** Coerce an unknown JSON value to a plain object bag for tolerant field access. */ @@ -74,6 +77,13 @@ export async function parseTranscriptFile(filePath: string): Promise { const iso = toIso(raw); if (!iso) return null; @@ -128,13 +138,16 @@ export async function parseTranscriptFile(filePath: string): Promise 0 ? toolUses[toolUses.length - 1] : null; + if (lastTool) { + const rawOutput = + typeof payload.output === "string" + ? payload.output + : typeof payload.content === "string" + ? payload.content + : payload.result != null + ? JSON.stringify(payload.result) + : null; + lastTool.output = truncateText(rawOutput, 4096); + if (isErr) lastTool.isError = true; + } } - // Token usage + // Token usage — CR-2: push per-event token record for time-series if (type === "token_count" || type === "usage" || type === "token_usage") { const info = asRecord(payload.usage ?? payload.token_count ?? payload); if (info.input_tokens != null) tokenInput = info.input_tokens as number; @@ -218,6 +260,19 @@ export async function parseTranscriptFile(filePath: string): Promise 0 ? collectArtifacts(toolUses, cwd) : emptyArtifacts(); + return { sessionId, name: projectName, @@ -280,5 +338,15 @@ export async function parseTranscriptFile(filePath: string): Promise { + const enrichedData = buildToolEventData(tu); if (tu.name === "Agent" || tu.name === "Task") { const subId = `${session.sessionId}-sub-${idx}`; const input = (tu.input ?? {}) as Record; @@ -290,9 +306,9 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer { tu.timestamp ?? session.endedAt ?? now, mainId, ); - addEvent("PreToolUse", subId, tu.timestamp, tu.name, "Spawned subagent", eventData(tu.input)); + addEvent("PreToolUse", subId, tu.timestamp, tu.name, "Spawned subagent", eventData(enrichedData)); } else { - addEvent("PostToolUse", mainId, tu.timestamp, tu.name, null, eventData(tu.input)); + addEvent("PostToolUse", mainId, tu.timestamp, tu.name, null, eventData(enrichedData)); } }); @@ -331,6 +347,18 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer { return { importSession }; } +function buildToolEventData(tu: NormalizedToolUse): Record { + const data: Record = {}; + if (tu.input != null) data.input = tu.input; + if (tu.output != null) data.output = tu.output; + if (tu.isError != null) data.isError = tu.isError; + if (tu.mcpServer != null) data.mcpServer = tu.mcpServer; + if (tu.mcpMethod != null) data.mcpMethod = tu.mcpMethod; + if (tu.skillName != null) data.skillName = tu.skillName; + if (tu.diffDelta != null) data.diffDelta = tu.diffDelta; + return data; +} + function strOf(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } diff --git a/apps/desktop/src/main/collectors/opencode/opencode-parser.ts b/apps/desktop/src/main/collectors/opencode/opencode-parser.ts index 2db39423..41a39ccf 100644 --- a/apps/desktop/src/main/collectors/opencode/opencode-parser.ts +++ b/apps/desktop/src/main/collectors/opencode/opencode-parser.ts @@ -17,17 +17,24 @@ import { getOpenCodeDbPath } from "./opencode-home.js"; import { emptyUsageExtras, type NormalizedApiError, + type NormalizedDiffStats, + type NormalizedMessage, type NormalizedSession, type NormalizedTokenCounts, + type NormalizedTokenRecord, type NormalizedToolResultError, type NormalizedToolUse, type NormalizedTurnDuration, } from "../types.js"; import { + collectArtifacts, + computeUnifiedDiffDelta, + countDiffFiles, extractErrorMessage, pushTurnDuration, safeJson, toIso, + truncateText, } from "../parser-utils.js"; type Row = Record; @@ -67,6 +74,24 @@ function partTimestamp(partRow: Row, part: Record): unknown { ); } +/** CR-2: Extract per-message token counts from the message data JSON. */ +function extractMessageTokens( + data: Record, +): { input: number; output: number; cacheRead: number; cacheWrite: number } | null { + // OpenCode stores tokens in data.tokens as an object or JSON string. + const raw = parseJsonCell(data.tokens); + if (!isObject(raw)) return null; + const input = Number(raw.input || raw.inputTokens || 0); + const output = Number(raw.output || raw.outputTokens || 0) + + Number(raw.reasoning || raw.reasoningTokens || 0); + const cacheRead = Number(raw.cacheRead || raw.cache_read || raw.cacheReadTokens || 0); + const cacheWrite = Number(raw.cacheWrite || raw.cache_write || raw.cacheWriteTokens || 0); + if (input || output || cacheRead || cacheWrite) { + return { input, output, cacheRead, cacheWrite }; + } + return null; +} + function collectToolUse( toolUses: NormalizedToolUse[], toolResultErrors: NormalizedToolResultError[], @@ -77,34 +102,54 @@ function collectToolUse( const timestamp = toIso(partTimestamp(partRow, part)) || firstTimestamp; const state = isObject(part.state) ? part.state : undefined; const input = state?.input ?? part.input ?? part.parameters ?? null; - toolUses.push({ - name: - (typeof part.tool === "string" ? part.tool : null) || - (typeof part.name === "string" ? part.name : null) || - "opencode_tool", - timestamp, - input: safeJson(input), - }); - const status = state?.status; + const stateOutput = state?.output; + + // CR-3: Capture output for all completions (success and error). + let output: unknown = undefined; + let isError = false; const errorMessage = extractErrorMessage(state?.error ?? part.error); + if (status === "failed" || status === "error" || errorMessage) { - const stateOutput = state?.output; - const output = + isError = true; + const outputStr = typeof stateOutput === "string" ? stateOutput : JSON.stringify(stateOutput ?? part.state ?? part).slice(0, 500); + output = truncateText( + errorMessage || outputStr || "OpenCode tool error", + 4096, + ); toolResultErrors.push({ - content: (errorMessage || output || "OpenCode tool error").slice(0, 500), + content: (errorMessage || outputStr || "OpenCode tool error").slice(0, 500), timestamp, }); + } else if (stateOutput != null) { + // CR-3: Successful tool output — truncate at 4KB. + const outputStr = + typeof stateOutput === "string" + ? stateOutput + : JSON.stringify(stateOutput); + output = truncateText(outputStr, 4096); } + + toolUses.push({ + name: + (typeof part.tool === "string" ? part.tool : null) || + (typeof part.name === "string" ? part.name : null) || + "opencode_tool", + timestamp, + input: safeJson(input), + output, + isError: isError || undefined, + }); } function parseSessionRow( sessionRow: Row, getMessages: StatementSync, getParts: StatementSync, + hasSummaryCols: boolean, ): NormalizedSession | null { const sessionId = sessionRow.id as string | number | bigint | null; const messageRows = getMessages.all(sessionId) as Row[]; @@ -112,7 +157,7 @@ function parseSessionRow( let cwd: string | null = typeof sessionRow.directory === "string" ? sessionRow.directory : null; - const model = modelIdFromValue(sessionRow.model); + const sessionModel = modelIdFromValue(sessionRow.model); let firstTimestamp: string | null = null; let lastTimestamp: string | null = null; let userMessageCount = 0; @@ -125,6 +170,17 @@ function parseSessionRow( const toolResultErrors: NormalizedToolResultError[] = []; let pendingTurnStartedAt: string | null = null; + // CR-1: ordered messages + const messages: NormalizedMessage[] = []; + // CR-2: per-turn token time-series + const tokenSeries: NormalizedTokenRecord[] = []; + // CR-4: aggregate diff stats from patch parts + let totalAdded = 0; + let totalRemoved = 0; + let totalFilesChanged = 0; + // CR-7: slash commands (OpenCode does not have these; keep empty) + const slashCommands: Array<{ name: string; timestamp: string }> = []; + const noteTs = (raw: unknown): string | null => { const iso = toIso(raw); if (!iso) return null; @@ -158,14 +214,65 @@ function parseSessionRow( cwd = pathCwd || pathRoot || cwd; } + // CR-5: Per-message modelID from data JSON. + const msgModel = modelIdFromValue(data.model ?? data.modelID) || sessionModel; + + // CR-2: Per-message token counts. + const msgTokens = extractMessageTokens(data); + if (role === "user" || role === "human") { userMessageCount++; if (iso) pendingTurnStartedAt = iso; + + // CR-1: Build NormalizedMessage for user messages. + // User message text is in data.content (string or array of parts). + const userText = extractMessageText(data); + messages.push({ + role: "human", + timestamp: iso, + text: truncateText(userText), + model: msgModel, + tokens: msgTokens ?? undefined, + }); + + // CR-2: Token series for user messages (if tokens present). + if (msgTokens && iso && msgModel) { + tokenSeries.push({ + timestamp: iso, + model: msgModel, + input: msgTokens.input, + output: msgTokens.output, + cacheRead: msgTokens.cacheRead, + cacheWrite: msgTokens.cacheWrite, + }); + } } else if (role === "assistant" || role === "ai" || role === "model") { assistantMessageCount++; if (iso) messageTimestamps.push(iso); pushTurnDuration(turnDurations, pendingTurnStartedAt, iso); pendingTurnStartedAt = null; + + // CR-1: Build NormalizedMessage for assistant messages. + const assistantText = extractMessageText(data); + messages.push({ + role: "assistant", + timestamp: iso, + text: truncateText(assistantText), + model: msgModel, + tokens: msgTokens ?? undefined, + }); + + // CR-2: Token series for assistant messages. + if (msgTokens && iso && msgModel) { + tokenSeries.push({ + timestamp: iso, + model: msgModel, + input: msgTokens.input, + output: msgTokens.output, + cacheRead: msgTokens.cacheRead, + cacheWrite: msgTokens.cacheWrite, + }); + } } const errorMessage = extractErrorMessage(data.error); @@ -186,6 +293,28 @@ function parseSessionRow( const iso = noteTs(partTimestamp(partRow, part)); if (part.type === "reasoning") { thinkingBlockCount++; + // CR-1: Thinking block as a message entry. + messages.push({ + role: "assistant", + timestamp: iso, + text: null, + model: sessionModel, + isThinking: true, + }); + } else if (part.type === "text") { + // CR-1: Text parts contribute to messages. These are typically + // content sub-parts within assistant turns. + const textContent = + typeof part.text === "string" ? part.text : + typeof part.content === "string" ? part.content : null; + if (textContent) { + messages.push({ + role: "assistant", + timestamp: iso, + text: truncateText(textContent), + model: sessionModel, + }); + } } else if (part.type === "tool") { collectToolUse(toolUses, toolResultErrors, partRow, part, firstTimestamp); } else if (part.type === "error") { @@ -197,6 +326,48 @@ function parseSessionRow( timestamp: iso, }); } + } else if (part.type === "patch") { + // CR-4: Patch parts contain unified diff data. + const patchContent = + typeof part.content === "string" ? part.content : + typeof part.patch === "string" ? part.patch : + typeof part.diff === "string" ? part.diff : null; + if (patchContent) { + const delta = computeUnifiedDiffDelta(patchContent); + totalAdded += delta.add; + totalRemoved += delta.del; + totalFilesChanged += countDiffFiles(patchContent); + // Attach diff delta to the most recent tool use if applicable. + if (toolUses.length > 0) { + const lastTool = toolUses[toolUses.length - 1]; + if (!lastTool.diffDelta) { + lastTool.diffDelta = delta; + } + } + } + } else if (part.type === "step-finish" || part.type === "step_finish") { + // CR-2: Step-finish parts may contain per-step token data. + const stepData = isObject(part.usage) ? part.usage : + isObject(part.tokens) ? part.tokens : null; + if (stepData && iso) { + const stepModel = + modelIdFromValue(part.model ?? part.modelID) || sessionModel || "opencode-default"; + const input = Number(stepData.input || stepData.inputTokens || 0); + const output = Number(stepData.output || stepData.outputTokens || 0) + + Number(stepData.reasoning || stepData.reasoningTokens || 0); + const cacheRead = Number(stepData.cacheRead || stepData.cache_read || 0); + const cacheWrite = Number(stepData.cacheWrite || stepData.cache_write || 0); + if (input || output || cacheRead || cacheWrite) { + tokenSeries.push({ + timestamp: iso, + model: stepModel, + input, + output, + cacheRead, + cacheWrite, + }); + } + } } } @@ -216,7 +387,7 @@ function parseSessionRow( tokenCacheWrite ) { const agent = typeof sessionRow.agent === "string" ? sessionRow.agent : null; - const key = model || agent || "opencode-default"; + const key = sessionModel || agent || "opencode-default"; tokensByModel[key] = { input: tokenInput, output: tokenOutput + tokenReasoning, @@ -225,6 +396,32 @@ function parseSessionRow( }; } + // CR-4: Build aggregate diffStats. Prefer summary columns from the session + // row when available (CR-9), fall back to patch-part accumulation. + let diffStats: NormalizedDiffStats | null = null; + if (hasSummaryCols) { + const summaryAdds = Number(sessionRow.summary_additions || 0); + const summaryDels = Number(sessionRow.summary_deletions || 0); + const summaryFiles = Number(sessionRow.summary_files || 0); + if (summaryAdds || summaryDels || summaryFiles) { + diffStats = { + filesChanged: summaryFiles, + linesAdded: summaryAdds, + linesRemoved: summaryDels, + }; + } + } + if (!diffStats && (totalAdded || totalRemoved || totalFilesChanged)) { + diffStats = { + filesChanged: totalFilesChanged, + linesAdded: totalAdded, + linesRemoved: totalRemoved, + }; + } + + // CR-13: Collect artifact references from tool uses. + const artifacts = collectArtifacts(toolUses, cwd); + const sessionIdStr = String(sessionId); const title = typeof sessionRow.title === "string" ? sessionRow.title : null; const projectName = cwd @@ -241,7 +438,7 @@ function parseSessionRow( sessionId: `opencode-${sessionIdStr}`, name: projectName, cwd, - model, + model: sessionModel, version, slug, gitBranch: null, @@ -262,9 +459,52 @@ function parseSessionRow( thinkingBlockCount, toolResultErrors, usageExtras: emptyUsageExtras(), + messages, + tokenSeries, + diffStats, + slashCommands, + artifacts, }; } +/** Extract text content from a message data object. Handles both string and + * array-of-parts content shapes. */ +function extractMessageText(data: Record): string | null { + const content = data.content; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + const textParts: string[] = []; + for (const item of content) { + if (typeof item === "string") { + textParts.push(item); + } else if (isObject(item)) { + if (item.type === "text" && typeof item.text === "string") { + textParts.push(item.text); + } + } + } + return textParts.length > 0 ? textParts.join("\n") : null; + } + // Fallback: try data.text directly. + if (typeof data.text === "string") return data.text; + return null; +} + +/** CR-9: Detect whether the session table has summary_* columns. */ +function hasSummaryColumns(db: DatabaseSync): boolean { + try { + const cols = db.prepare("PRAGMA table_info(session)").all() as Row[]; + const names = new Set(cols.map((c) => c.name)); + return ( + names.has("summary_additions") && + names.has("summary_deletions") && + names.has("summary_files") + ); + } catch { + return false; + } +} + export function loadSessionsFromDb( dbPath: string = getOpenCodeDbPath(), ): NormalizedSession[] { @@ -273,9 +513,35 @@ export function loadSessionsFromDb( const db = new DatabaseSync(dbPath); try { db.exec("PRAGMA busy_timeout = 1000"); - const sessionRows = db - .prepare( - ` + + // CR-9: Detect optional summary columns before building the SELECT. + const hasSummaryCols = hasSummaryColumns(db); + + const sessionSelect = hasSummaryCols + ? ` + SELECT + id, + slug, + directory, + title, + version, + agent, + model, + permission, + time_created, + time_updated, + tokens_input, + tokens_output, + tokens_reasoning, + tokens_cache_read, + tokens_cache_write, + summary_additions, + summary_deletions, + summary_files + FROM session + ORDER BY time_updated DESC, id DESC + ` + : ` SELECT id, slug, @@ -294,9 +560,9 @@ export function loadSessionsFromDb( tokens_cache_write FROM session ORDER BY time_updated DESC, id DESC - `, - ) - .all() as Row[]; + `; + + const sessionRows = db.prepare(sessionSelect).all() as Row[]; const getMessages = db.prepare(` SELECT id, time_created, time_updated, data FROM message @@ -312,7 +578,7 @@ export function loadSessionsFromDb( const out: NormalizedSession[] = []; for (const row of sessionRows) { - const session = parseSessionRow(row, getMessages, getParts); + const session = parseSessionRow(row, getMessages, getParts, hasSummaryCols); if (session) out.push(session); } return out; diff --git a/apps/desktop/src/main/collectors/parser-utils.ts b/apps/desktop/src/main/collectors/parser-utils.ts index 2b8f16df..9266e782 100644 --- a/apps/desktop/src/main/collectors/parser-utils.ts +++ b/apps/desktop/src/main/collectors/parser-utils.ts @@ -5,7 +5,7 @@ * dependency-free so every parser can normalize timestamps and extract error * text identically. */ -import type { NormalizedTurnDuration } from "./types.js"; +import type { NormalizedArtifacts, NormalizedTurnDuration } from "./types.js"; /** * Normalize a timestamp to an ISO 8601 string. Handles numeric epoch (seconds or @@ -63,6 +63,151 @@ export function extractErrorMessage(value: unknown, depth = 0): string | null { return null; } +/** CR-1/CR-3: Cap content-bearing text to a byte limit (default 4096). */ +export function truncateText(text: string | null | undefined, maxBytes = 4096): string | null { + if (text == null || text.length === 0) return null; + if (Buffer.byteLength(text, "utf8") <= maxBytes) return text; + const buf = Buffer.from(text, "utf8"); + return buf.subarray(0, maxBytes).toString("utf8"); +} + +/** CR-4: Compute lines added/removed from old/new string comparison. */ +export function computeLineDelta( + oldText: string | null | undefined, + newText: string | null | undefined, +): { add: number; del: number } { + const oldLines = oldText ? oldText.split("\n").length : 0; + const newLines = newText ? newText.split("\n").length : 0; + return { + add: Math.max(0, newLines - oldLines), + del: Math.max(0, oldLines - newLines), + }; +} + +/** CR-4: Parse a unified diff for lines added/removed (Codex apply_patch). */ +export function computeUnifiedDiffDelta(patch: string): { add: number; del: number } { + let add = 0; + let del = 0; + for (const line of patch.split("\n")) { + if (line.startsWith("+") && !line.startsWith("+++")) add++; + else if (line.startsWith("-") && !line.startsWith("---")) del++; + } + return { add, del }; +} + +/** CR-4: Count file headers in a unified diff. */ +export function countDiffFiles(patch: string): number { + let count = 0; + for (const line of patch.split("\n")) { + if (line.startsWith("--- ")) count++; + } + return count; +} + +/** CR-13: Extract repo name from cwd path (last path component). */ +export function extractRepoFromCwd(cwd: string | null | undefined): string | null { + if (!cwd) return null; + const parts = cwd.replace(/\/+$/, "").split("/"); + const last = parts[parts.length - 1]; + return last && last.length > 0 ? last : null; +} + +const PR_TOOL_PATTERNS = new Set([ + "create_pull_request", + "github.create_pr", + "mcp__github__create_pull_request", +]); + +/** CR-13: Extract PR references from tool calls. */ +export function extractPrReferences( + toolName: string, + input: unknown, +): Array<{ number: string; repo?: string }> { + if (!input || typeof input !== "object") return []; + const obj = input as Record; + + if (PR_TOOL_PATTERNS.has(toolName)) { + const repo = typeof obj.repo === "string" ? obj.repo : undefined; + const head = typeof obj.head === "string" ? obj.head : undefined; + return head ? [{ number: head, repo }] : []; + } + + if (toolName === "Bash") { + const cmd = typeof obj.command === "string" ? obj.command : ""; + const match = cmd.match(/gh\s+pr\s+create/); + if (match) return [{ number: "pending" }]; + } + return []; +} + +const ISSUE_KEY_RE = /\b([A-Z]+-\d+)\b/g; +const ISSUE_HASH_RE = /#(\d+)\b/g; +const ISSUE_TOOL_PATTERNS = new Set([ + "linear.get_issue", + "github.get_issue", + "mcp__linear-server__get_issue", + "mcp__github__get_issue", +]); + +/** CR-13: Extract issue references from tool calls and input text. */ +export function extractIssueReferences( + toolName: string, + input: unknown, +): Array<{ key: string }> { + const refs: Array<{ key: string }> = []; + const seen = new Set(); + if (!input || typeof input !== "object") return refs; + const obj = input as Record; + + if (ISSUE_TOOL_PATTERNS.has(toolName)) { + const key = typeof obj.issue_id === "string" ? obj.issue_id + : typeof obj.issueId === "string" ? obj.issueId + : null; + if (key && !seen.has(key)) { seen.add(key); refs.push({ key }); } + } + + const textFields = [obj.command, obj.query, obj.body, obj.prompt, obj.description]; + for (const field of textFields) { + if (typeof field !== "string") continue; + for (const m of field.matchAll(ISSUE_KEY_RE)) { + if (!seen.has(m[1])) { seen.add(m[1]); refs.push({ key: m[1] }); } + } + for (const m of field.matchAll(ISSUE_HASH_RE)) { + const k = `#${m[1]}`; + if (!seen.has(k)) { seen.add(k); refs.push({ key: k }); } + } + } + return refs; +} + +/** CR-5: Returns true for synthetic/fallback model IDs. */ +export function isSyntheticModelKey(model: string): boolean { + return model.endsWith("-default") || model === "gpt-codex"; +} + +/** CR-13: Accumulate artifact references from tool uses into an artifacts object. */ +export function collectArtifacts( + toolUses: Array<{ name: string; input?: unknown }>, + cwd: string | null | undefined, +): NormalizedArtifacts { + const prs: Array<{ number: string; repo?: string }> = []; + const issues: Array<{ key: string }> = []; + const seenPr = new Set(); + const seenIssue = new Set(); + + for (const tu of toolUses) { + for (const pr of extractPrReferences(tu.name, tu.input)) { + const k = `${pr.repo ?? ""}:${pr.number}`; + if (!seenPr.has(k)) { seenPr.add(k); prs.push(pr); } + } + for (const issue of extractIssueReferences(tu.name, tu.input)) { + if (!seenIssue.has(issue.key)) { seenIssue.add(issue.key); issues.push(issue); } + } + } + + return { prs, issues, repo: extractRepoFromCwd(cwd) }; +} + /** Push a turn-duration entry when both timestamps are valid and duration ≥ 0. */ export function pushTurnDuration( turnDurations: NormalizedTurnDuration[], diff --git a/apps/desktop/src/main/collectors/types.ts b/apps/desktop/src/main/collectors/types.ts index 743e6e06..f4d4c97c 100644 --- a/apps/desktop/src/main/collectors/types.ts +++ b/apps/desktop/src/main/collectors/types.ts @@ -23,6 +23,18 @@ export interface NormalizedToolUse { name: string; timestamp: string | null; input?: unknown; + /** CR-3: Tool result content (size-capped). */ + output?: unknown; + /** CR-3: Whether the tool result was an error. */ + isError?: boolean; + /** CR-6: MCP server name (Codex preserves from mcp_tool_call_begin). */ + mcpServer?: string; + /** CR-6: MCP method name. */ + mcpMethod?: string; + /** CR-8: Skill name extracted from Skill tool input.skill (Claude). */ + skillName?: string; + /** CR-4: Per-edit line delta. */ + diffDelta?: { add: number; del: number }; } /** An API-level error parsed from a transcript → becomes an APIError event. */ @@ -44,6 +56,40 @@ export interface NormalizedTurnDuration { timestamp: string | null; } +/** CR-1: An ordered message from a session transcript. */ +export interface NormalizedMessage { + role: "human" | "assistant" | "system"; + timestamp: string | null; + text: string | null; + model?: string | null; + tokens?: { input: number; output: number; cacheRead?: number; cacheWrite?: number }; + isThinking?: boolean; +} + +/** CR-2: A per-turn token record for time-series reconstruction. */ +export interface NormalizedTokenRecord { + timestamp: string; + model: string; + input: number; + output: number; + cacheRead: number; + cacheWrite: number; +} + +/** CR-4: Aggregate diff stats for the session. */ +export interface NormalizedDiffStats { + filesChanged: number; + linesAdded: number; + linesRemoved: number; +} + +/** CR-13: Structured artifact references extracted from tool calls. */ +export interface NormalizedArtifacts { + prs: Array<{ number: string; repo?: string }>; + issues: Array<{ key: string }>; + repo: string | null; +} + /** A plan block (Codex only today). Stored on the session metadata. */ export interface NormalizedPlan { source?: string | null; @@ -87,6 +133,16 @@ export interface NormalizedSession { speeds: unknown[]; inference_geos: unknown[]; }; + /** CR-1: Ordered per-message list with text content. */ + messages: NormalizedMessage[]; + /** CR-2: Per-turn token records for time-series reconstruction. */ + tokenSeries: NormalizedTokenRecord[]; + /** CR-4: Aggregate diff stats (files changed, lines +/-). Null when absent. */ + diffStats: NormalizedDiffStats | null; + /** CR-7: Claude slash commands extracted from transcripts. */ + slashCommands: Array<{ name: string; timestamp: string }>; + /** CR-13: Structured artifact references (PRs, issues, repo). */ + artifacts: NormalizedArtifacts; } /** Empty `usageExtras` literal — parsers spread/override as needed. */ @@ -94,6 +150,11 @@ export function emptyUsageExtras(): NormalizedSession["usageExtras"] { return { service_tiers: [], speeds: [], inference_geos: [] }; } +/** Empty `artifacts` literal — parsers fill as they extract references. */ +export function emptyArtifacts(): NormalizedArtifacts { + return { prs: [], issues: [], repo: null }; +} + /** * A per-harness collector descriptor (FEA-1503). The generic boot importer and * the generic watcher (`watcher.ts`, `collector-manager.ts`) drive every harness diff --git a/apps/desktop/src/main/database/dashboard.ts b/apps/desktop/src/main/database/dashboard.ts index 877bb9d4..e4c894de 100644 --- a/apps/desktop/src/main/database/dashboard.ts +++ b/apps/desktop/src/main/database/dashboard.ts @@ -1,5 +1,6 @@ import type { DatabaseSync } from "node:sqlite"; import type { DashboardSummary, TokenAnalytics, AnalyticsData, WorkflowQueryData } from "../../shared/agent-db-contract.js"; +import { computeTokenCost } from "../../shared/token-cost.js"; export function createDashboardQueries(db: DatabaseSync) { const totalSessionsStmt = db.prepare("SELECT COUNT(*) as count FROM sessions"); @@ -31,6 +32,8 @@ export function createDashboardQueries(db: DatabaseSync) { SELECT model, SUM(input_tokens) as inputTokens, SUM(output_tokens) as outputTokens, + SUM(cache_read_tokens) as cacheReadTokens, + SUM(cache_write_tokens) as cacheWriteTokens, COUNT(DISTINCT session_id) as sessions FROM token_usage WHERE model IS NOT NULL @@ -172,9 +175,26 @@ export function createDashboardQueries(db: DatabaseSync) { getTokenAnalytics(): TokenAnalytics { const totals = tokenAnalyticsStmt.get() as { totalInput: number; totalOutput: number; totalCacheRead: number; totalCacheWrite: number }; - const byModel = tokenByModelStmt.all() as Array<{ model: string; inputTokens: number; outputTokens: number; sessions: number }>; + const byModelRaw = tokenByModelStmt.all() as Array<{ model: string; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; sessions: number }>; const byDay = tokenByDayStmt.all() as Array<{ day: string; inputTokens: number; outputTokens: number }>; + const byModel = byModelRaw.map((row) => { + const cost = computeTokenCost({ + model: row.model, + inputTokens: row.inputTokens, + outputTokens: row.outputTokens, + cacheReadTokens: row.cacheReadTokens, + cacheWriteTokens: row.cacheWriteTokens, + }); + return { + model: row.model, + inputTokens: row.inputTokens, + outputTokens: row.outputTokens, + sessions: row.sessions, + ...(cost.costUsd != null ? { estimatedCostUsd: cost.costUsd } : {}), + }; + }); + return { totalInputTokens: totals.totalInput, totalOutputTokens: totals.totalOutput, diff --git a/apps/desktop/src/shared/agent-db-contract.ts b/apps/desktop/src/shared/agent-db-contract.ts index 797aad0f..6bd82543 100644 --- a/apps/desktop/src/shared/agent-db-contract.ts +++ b/apps/desktop/src/shared/agent-db-contract.ts @@ -76,6 +76,7 @@ export interface SessionWithAgents extends SessionRow { agentCount: number; eventCount: number; totalTokens: number; + estimatedCostUsd?: number; } export interface EventWithSession extends EventRow { @@ -92,6 +93,7 @@ export interface TokenAnalytics { inputTokens: number; outputTokens: number; sessions: number; + estimatedCostUsd?: number; }>; byDay: Array<{ day: string; diff --git a/apps/desktop/src/shared/event-role.ts b/apps/desktop/src/shared/event-role.ts new file mode 100644 index 00000000..5506166d --- /dev/null +++ b/apps/desktop/src/shared/event-role.ts @@ -0,0 +1,14 @@ +const HUMAN_EVENTS = new Set(["UserPromptSubmit", "UserMessage"]); +const AGENT_EVENTS = new Set([ + "PostToolUse", + "PreToolUse", + "AssistantMessage", + "SubagentStop", + "Stop", +]); + +export function eventRole(eventType: string): "human" | "agent" | "system" { + if (HUMAN_EVENTS.has(eventType)) return "human"; + if (AGENT_EVENTS.has(eventType)) return "agent"; + return "system"; +} diff --git a/apps/desktop/src/shared/session-timing.ts b/apps/desktop/src/shared/session-timing.ts new file mode 100644 index 00000000..799f643d --- /dev/null +++ b/apps/desktop/src/shared/session-timing.ts @@ -0,0 +1,37 @@ +import { eventRole } from "./event-role.js"; + +export interface SessionTiming { + activeAgentMs: number; + waitingUserMs: number; +} + +export function computeSessionTiming( + events: ReadonlyArray<{ eventType: string; createdAt: string }>, +): SessionTiming { + let activeAgentMs = 0; + let waitingUserMs = 0; + + if (events.length === 0) return { activeAgentMs, waitingUserMs }; + + let prevRole = eventRole(events[0].eventType); + let prevTime = new Date(events[0].createdAt).getTime(); + + for (let i = 1; i < events.length; i++) { + const role = eventRole(events[i].eventType); + const time = new Date(events[i].createdAt).getTime(); + const gap = time - prevTime; + + if (Number.isFinite(gap) && gap > 0) { + if (prevRole === "agent" && role === "human") { + waitingUserMs += gap; + } else if (prevRole !== "system") { + activeAgentMs += gap; + } + } + + prevRole = role; + prevTime = time; + } + + return { activeAgentMs, waitingUserMs }; +} diff --git a/apps/desktop/test/agent-session-sync-service.test.ts b/apps/desktop/test/agent-session-sync-service.test.ts index b22f230c..c845de6d 100644 --- a/apps/desktop/test/agent-session-sync-service.test.ts +++ b/apps/desktop/test/agent-session-sync-service.test.ts @@ -1238,3 +1238,176 @@ test("sanitizeSessionForSync strips content/stdout/stderr recursively inside too tool_response: { interrupted: false, isImage: false }, }, "Bash: stdout/stderr stripped, structural keys preserved"); }); + +// --------------------------------------------------------------------------- +// FEA-1554: sanitizeSessionForSync strips text, output, reasoning +// --------------------------------------------------------------------------- + +test("sanitizeSessionForSync strips 'text' key from event data", () => { + const session = { + externalSessionId: "sess-text", + status: "completed", + startedAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T01:00:00Z", + agents: [], + events: [ + { + externalEventId: "1", + eventType: "UserMessage", + toolName: null, + summary: "user message", + data: { text: "hello world", role: "human" }, + createdAt: "2026-01-01T00:01:00Z", + }, + { + externalEventId: "2", + eventType: "AssistantMessage", + toolName: null, + summary: "assistant reply", + data: { text: "I can help with that", role: "assistant", model: "claude-opus-4-6" }, + createdAt: "2026-01-01T00:02:00Z", + }, + ], + tokenUsageByModel: [], + }; + + const sanitized = sanitizeSessionForSync(session as any); + + assert.deepEqual( + sanitized.events[0].data, + { role: "human" }, + "UserMessage: text stripped, role preserved", + ); + assert.deepEqual( + sanitized.events[1].data, + { role: "assistant", model: "claude-opus-4-6" }, + "AssistantMessage: text stripped, role and model preserved", + ); +}); + +test("sanitizeSessionForSync strips 'output' key from event data", () => { + const session = { + externalSessionId: "sess-output", + status: "completed", + startedAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T01:00:00Z", + agents: [], + events: [ + { + externalEventId: "1", + eventType: "PostToolUse", + toolName: "Search", + summary: "search completed", + data: { input: { query: "agent sessions" }, output: "found 42 results with sensitive data" }, + createdAt: "2026-01-01T00:01:00Z", + }, + ], + tokenUsageByModel: [], + }; + + const sanitized = sanitizeSessionForSync(session as any); + + assert.deepEqual( + sanitized.events[0].data, + { input: { query: "agent sessions" } }, + "PostToolUse: output stripped, input preserved", + ); +}); + +test("sanitizeSessionForSync strips 'reasoning' key from event data", () => { + const session = { + externalSessionId: "sess-reasoning", + status: "completed", + startedAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T01:00:00Z", + agents: [], + events: [ + { + externalEventId: "1", + eventType: "AssistantMessage", + toolName: null, + summary: "assistant reasoning", + data: { reasoning: "Let me think about this step by step...", model: "claude-opus-4-6" }, + createdAt: "2026-01-01T00:01:00Z", + }, + ], + tokenUsageByModel: [], + }; + + const sanitized = sanitizeSessionForSync(session as any); + + assert.deepEqual( + sanitized.events[0].data, + { model: "claude-opus-4-6" }, + "reasoning stripped, model preserved", + ); +}); + +test("sanitizeSessionForSync round-trip: all content-bearing keys stripped in one payload", () => { + const session = { + externalSessionId: "sess-all-keys", + status: "completed", + startedAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T01:00:00Z", + agents: [], + events: [ + { + externalEventId: "1", + eventType: "PostToolUse", + toolName: "Bash", + summary: "all keys present", + data: { + prompt: "run the build", + content: "file contents here", + stdout: "build output", + stderr: "build warnings", + text: "assistant message body", + output: "tool result payload", + reasoning: "chain of thought", + tool_name: "Bash", + exitCode: 0, + nested: { + prompt: "nested prompt leak", + content: "nested content leak", + stdout: "nested stdout", + stderr: "nested stderr", + text: "nested text", + output: "nested output", + reasoning: "nested reasoning", + safe: "preserved-value", + }, + }, + createdAt: "2026-01-01T00:01:00Z", + }, + ], + tokenUsageByModel: [], + }; + + const sanitized = sanitizeSessionForSync(session as any); + const data = sanitized.events[0].data as Record; + + // Top-level content-bearing keys must be absent + const strippedKeys = ["prompt", "content", "stdout", "stderr", "text", "output", "reasoning"]; + for (const key of strippedKeys) { + assert.strictEqual( + Object.prototype.hasOwnProperty.call(data, key), + false, + `top-level '${key}' must be stripped`, + ); + } + + // Structural keys must survive + assert.strictEqual(data.tool_name, "Bash", "tool_name preserved"); + assert.strictEqual(data.exitCode, 0, "exitCode preserved"); + + // Nested content-bearing keys must also be stripped + const nested = data.nested as Record; + for (const key of strippedKeys) { + assert.strictEqual( + Object.prototype.hasOwnProperty.call(nested, key), + false, + `nested '${key}' must be stripped`, + ); + } + assert.strictEqual(nested.safe, "preserved-value", "nested safe key preserved"); +}); diff --git a/apps/desktop/test/event-role.test.ts b/apps/desktop/test/event-role.test.ts new file mode 100644 index 00000000..01fece26 --- /dev/null +++ b/apps/desktop/test/event-role.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { eventRole } from "../src/shared/event-role.js"; + +// --- Human events --- + +test("UserPromptSubmit maps to human", () => { + assert.equal(eventRole("UserPromptSubmit"), "human"); +}); + +test("UserMessage maps to human", () => { + assert.equal(eventRole("UserMessage"), "human"); +}); + +// --- Agent events --- + +test("PostToolUse maps to agent", () => { + assert.equal(eventRole("PostToolUse"), "agent"); +}); + +test("PreToolUse maps to agent", () => { + assert.equal(eventRole("PreToolUse"), "agent"); +}); + +test("AssistantMessage maps to agent", () => { + assert.equal(eventRole("AssistantMessage"), "agent"); +}); + +test("SubagentStop maps to agent", () => { + assert.equal(eventRole("SubagentStop"), "agent"); +}); + +test("Stop maps to agent", () => { + assert.equal(eventRole("Stop"), "agent"); +}); + +// --- System events --- + +test("TurnDuration maps to system", () => { + assert.equal(eventRole("TurnDuration"), "system"); +}); + +test("APIError maps to system", () => { + assert.equal(eventRole("APIError"), "system"); +}); + +test("ToolError maps to system", () => { + assert.equal(eventRole("ToolError"), "system"); +}); + +test("Notification maps to system", () => { + assert.equal(eventRole("Notification"), "system"); +}); + +test("SessionStart maps to system", () => { + assert.equal(eventRole("SessionStart"), "system"); +}); + +test("SessionEnd maps to system", () => { + assert.equal(eventRole("SessionEnd"), "system"); +}); + +// --- Default fallback --- + +test("unknown event type defaults to system", () => { + assert.equal(eventRole("CompletelyMadeUp"), "system"); +}); diff --git a/apps/desktop/test/parser-utils.test.ts b/apps/desktop/test/parser-utils.test.ts new file mode 100644 index 00000000..84198fb1 --- /dev/null +++ b/apps/desktop/test/parser-utils.test.ts @@ -0,0 +1,402 @@ +/** + * @file parser-utils.test.ts + * @description Unit tests for the FEA-1554 helpers added to + * src/main/collectors/parser-utils.ts: truncateText, computeLineDelta, + * computeUnifiedDiffDelta, countDiffFiles, extractRepoFromCwd, + * extractPrReferences, extractIssueReferences, isSyntheticModelKey, + * and collectArtifacts. + */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + truncateText, + computeLineDelta, + computeUnifiedDiffDelta, + countDiffFiles, + extractRepoFromCwd, + extractPrReferences, + extractIssueReferences, + isSyntheticModelKey, + collectArtifacts, +} from "../src/main/collectors/parser-utils.js"; + +// --------------------------------------------------------------------------- +// truncateText +// --------------------------------------------------------------------------- + +test("truncateText returns null for null", () => { + assert.equal(truncateText(null), null); +}); + +test("truncateText returns null for undefined", () => { + assert.equal(truncateText(undefined), null); +}); + +test("truncateText returns null for empty string", () => { + assert.equal(truncateText(""), null); +}); + +test("truncateText passes through short strings unchanged", () => { + assert.equal(truncateText("hello world"), "hello world"); +}); + +test("truncateText truncates strings exceeding 4096 bytes", () => { + const long = "x".repeat(5000); + const result = truncateText(long); + assert.ok(result !== null); + assert.equal(Buffer.byteLength(result, "utf8"), 4096); +}); + +test("truncateText handles multi-byte UTF-8 correctly", () => { + // Each emoji is 4 bytes in UTF-8 + const emoji = "\u{1F600}"; // 😀 + assert.equal(Buffer.byteLength(emoji, "utf8"), 4); + // Build a string of emojis that exceeds a small byte limit + const text = emoji.repeat(10); // 40 bytes + const result = truncateText(text, 16); + assert.ok(result !== null); + assert.ok(Buffer.byteLength(result, "utf8") <= 16); +}); + +test("truncateText respects custom maxBytes", () => { + const text = "abcdefghij"; // 10 bytes ASCII + const result = truncateText(text, 5); + assert.ok(result !== null); + assert.equal(Buffer.byteLength(result, "utf8"), 5); + assert.equal(result, "abcde"); +}); + +// --------------------------------------------------------------------------- +// computeLineDelta +// --------------------------------------------------------------------------- + +test("computeLineDelta returns {add:0, del:0} for null inputs", () => { + assert.deepEqual(computeLineDelta(null, null), { add: 0, del: 0 }); +}); + +test("computeLineDelta returns {add:0, del:0} for undefined inputs", () => { + assert.deepEqual(computeLineDelta(undefined, undefined), { add: 0, del: 0 }); +}); + +test("computeLineDelta computes add when new has more lines", () => { + const result = computeLineDelta("line1", "line1\nline2\nline3"); + assert.deepEqual(result, { add: 2, del: 0 }); +}); + +test("computeLineDelta computes del when old has more lines", () => { + const result = computeLineDelta("a\nb\nc\nd", "a"); + assert.deepEqual(result, { add: 0, del: 3 }); +}); + +test("computeLineDelta handles mixed add/del via net difference", () => { + // old has 3 lines, new has 5 lines -> net add:2, del:0 + const result = computeLineDelta("a\nb\nc", "x\ny\nz\nw\nv"); + assert.deepEqual(result, { add: 2, del: 0 }); + + // old has 5 lines, new has 2 lines -> net add:0, del:3 + const result2 = computeLineDelta("a\nb\nc\nd\ne", "x\ny"); + assert.deepEqual(result2, { add: 0, del: 3 }); +}); + +test("computeLineDelta with one null side", () => { + assert.deepEqual(computeLineDelta(null, "a\nb"), { add: 2, del: 0 }); + assert.deepEqual(computeLineDelta("a\nb\nc", null), { add: 0, del: 3 }); +}); + +// --------------------------------------------------------------------------- +// computeUnifiedDiffDelta +// --------------------------------------------------------------------------- + +test("computeUnifiedDiffDelta counts + lines excluding +++ header", () => { + const patch = [ + "--- a/file.ts", + "+++ b/file.ts", + "@@ -1,3 +1,4 @@", + " unchanged", + "+added line 1", + "+added line 2", + " unchanged", + ].join("\n"); + const result = computeUnifiedDiffDelta(patch); + assert.equal(result.add, 2); + assert.equal(result.del, 0); +}); + +test("computeUnifiedDiffDelta counts - lines excluding --- header", () => { + const patch = [ + "--- a/file.ts", + "+++ b/file.ts", + "@@ -1,4 +1,2 @@", + " unchanged", + "-removed line 1", + "-removed line 2", + " unchanged", + ].join("\n"); + const result = computeUnifiedDiffDelta(patch); + assert.equal(result.add, 0); + assert.equal(result.del, 2); +}); + +test("computeUnifiedDiffDelta returns zero for empty string", () => { + assert.deepEqual(computeUnifiedDiffDelta(""), { add: 0, del: 0 }); +}); + +test("computeUnifiedDiffDelta handles mixed adds and deletes", () => { + const patch = [ + "--- a/file.ts", + "+++ b/file.ts", + "@@ -1,3 +1,3 @@", + "-old line", + "+new line", + " context", + "-another old", + "+another new", + ].join("\n"); + const result = computeUnifiedDiffDelta(patch); + assert.equal(result.add, 2); + assert.equal(result.del, 2); +}); + +// --------------------------------------------------------------------------- +// countDiffFiles +// --------------------------------------------------------------------------- + +test("countDiffFiles counts --- headers in unified diff", () => { + const patch = [ + "--- a/file1.ts", + "+++ b/file1.ts", + "@@ -1 +1 @@", + "-old", + "+new", + "--- a/file2.ts", + "+++ b/file2.ts", + "@@ -1 +1 @@", + "-old", + "+new", + ].join("\n"); + assert.equal(countDiffFiles(patch), 2); +}); + +test("countDiffFiles returns 0 for empty string", () => { + assert.equal(countDiffFiles(""), 0); +}); + +test("countDiffFiles returns 0 for patch with no --- headers", () => { + assert.equal(countDiffFiles("+added\n context"), 0); +}); + +// --------------------------------------------------------------------------- +// extractRepoFromCwd +// --------------------------------------------------------------------------- + +test("extractRepoFromCwd returns last path component", () => { + assert.equal(extractRepoFromCwd("/home/user/Workspace/symphony-alpha"), "symphony-alpha"); +}); + +test("extractRepoFromCwd returns null for null", () => { + assert.equal(extractRepoFromCwd(null), null); +}); + +test("extractRepoFromCwd returns null for undefined", () => { + assert.equal(extractRepoFromCwd(undefined), null); +}); + +test("extractRepoFromCwd returns null for empty string", () => { + assert.equal(extractRepoFromCwd(""), null); +}); + +test("extractRepoFromCwd strips trailing slashes", () => { + assert.equal(extractRepoFromCwd("/home/user/project/"), "project"); + assert.equal(extractRepoFromCwd("/home/user/project///"), "project"); +}); + +// --------------------------------------------------------------------------- +// extractPrReferences +// --------------------------------------------------------------------------- + +test("extractPrReferences finds PR from create_pull_request tool", () => { + const refs = extractPrReferences("create_pull_request", { + head: "feat/my-branch", + repo: "closedloop-ai/symphony", + }); + assert.equal(refs.length, 1); + assert.equal(refs[0].number, "feat/my-branch"); + assert.equal(refs[0].repo, "closedloop-ai/symphony"); +}); + +test("extractPrReferences finds PR from mcp__github__create_pull_request", () => { + const refs = extractPrReferences("mcp__github__create_pull_request", { + head: "fix/bug-123", + repo: "org/repo", + }); + assert.equal(refs.length, 1); + assert.equal(refs[0].number, "fix/bug-123"); +}); + +test("extractPrReferences finds PR from Bash tool gh pr create command", () => { + const refs = extractPrReferences("Bash", { + command: "gh pr create --title 'My PR' --body 'desc'", + }); + assert.equal(refs.length, 1); + assert.equal(refs[0].number, "pending"); +}); + +test("extractPrReferences returns empty for Bash without gh pr create", () => { + const refs = extractPrReferences("Bash", { + command: "git push origin main", + }); + assert.equal(refs.length, 0); +}); + +test("extractPrReferences returns empty for unrelated tools", () => { + assert.deepEqual(extractPrReferences("Read", { file: "foo.ts" }), []); + assert.deepEqual(extractPrReferences("Edit", { file: "bar.ts" }), []); +}); + +test("extractPrReferences returns empty for null input", () => { + assert.deepEqual(extractPrReferences("create_pull_request", null), []); +}); + +test("extractPrReferences returns empty when head is missing", () => { + const refs = extractPrReferences("create_pull_request", { repo: "org/repo" }); + assert.equal(refs.length, 0); +}); + +// --------------------------------------------------------------------------- +// extractIssueReferences +// --------------------------------------------------------------------------- + +test("extractIssueReferences finds ENG-NNN pattern in text fields", () => { + const refs = extractIssueReferences("Bash", { + command: "git commit -m 'ENG-123: fix bug'", + }); + assert.ok(refs.some((r) => r.key === "ENG-123")); +}); + +test("extractIssueReferences finds #NNN pattern", () => { + const refs = extractIssueReferences("Bash", { + command: "fixes #456", + }); + assert.ok(refs.some((r) => r.key === "#456")); +}); + +test("extractIssueReferences finds issue from linear tool calls", () => { + const refs = extractIssueReferences("mcp__linear-server__get_issue", { + issue_id: "ENG-789", + }); + assert.equal(refs.length, 1); + assert.equal(refs[0].key, "ENG-789"); +}); + +test("extractIssueReferences finds issue from issueId field", () => { + const refs = extractIssueReferences("linear.get_issue", { + issueId: "PROJ-42", + }); + assert.equal(refs.length, 1); + assert.equal(refs[0].key, "PROJ-42"); +}); + +test("extractIssueReferences deduplicates", () => { + const refs = extractIssueReferences("Bash", { + command: "ENG-100 ENG-100 ENG-100", + }); + assert.equal(refs.length, 1); + assert.equal(refs[0].key, "ENG-100"); +}); + +test("extractIssueReferences scans multiple text fields", () => { + const refs = extractIssueReferences("SomeTool", { + command: "ENG-1", + body: "ENG-2", + description: "#99", + }); + assert.ok(refs.some((r) => r.key === "ENG-1")); + assert.ok(refs.some((r) => r.key === "ENG-2")); + assert.ok(refs.some((r) => r.key === "#99")); + assert.equal(refs.length, 3); +}); + +test("extractIssueReferences returns empty for null input", () => { + assert.deepEqual(extractIssueReferences("Bash", null), []); +}); + +test("extractIssueReferences returns empty for unrelated input", () => { + assert.deepEqual(extractIssueReferences("Read", { file: "foo.ts" }), []); +}); + +// --------------------------------------------------------------------------- +// isSyntheticModelKey +// --------------------------------------------------------------------------- + +test("isSyntheticModelKey returns true for *-default patterns", () => { + assert.equal(isSyntheticModelKey("claude-default"), true); + assert.equal(isSyntheticModelKey("o3-default"), true); + assert.equal(isSyntheticModelKey("anything-default"), true); +}); + +test("isSyntheticModelKey returns true for gpt-codex", () => { + assert.equal(isSyntheticModelKey("gpt-codex"), true); +}); + +test("isSyntheticModelKey returns false for real model IDs", () => { + assert.equal(isSyntheticModelKey("claude-opus-4"), false); + assert.equal(isSyntheticModelKey("claude-sonnet-4-20250514"), false); + assert.equal(isSyntheticModelKey("gpt-4o"), false); + assert.equal(isSyntheticModelKey("o3-mini"), false); +}); + +// --------------------------------------------------------------------------- +// collectArtifacts +// --------------------------------------------------------------------------- + +test("collectArtifacts combines PR and issue refs from multiple tool uses", () => { + const toolUses = [ + { name: "create_pull_request", input: { head: "feat/x", repo: "org/repo" } }, + { name: "Bash", input: { command: "fixes ENG-42" } }, + { name: "mcp__linear-server__get_issue", input: { issue_id: "ENG-99" } }, + ]; + const result = collectArtifacts(toolUses, "/home/user/my-project"); + assert.equal(result.prs.length, 1); + assert.equal(result.prs[0].number, "feat/x"); + assert.equal(result.prs[0].repo, "org/repo"); + assert.ok(result.issues.some((i) => i.key === "ENG-42")); + assert.ok(result.issues.some((i) => i.key === "ENG-99")); + assert.equal(result.repo, "my-project"); +}); + +test("collectArtifacts extracts repo from cwd", () => { + const result = collectArtifacts([], "/home/user/Workspace/symphony-alpha"); + assert.equal(result.repo, "symphony-alpha"); + assert.deepEqual(result.prs, []); + assert.deepEqual(result.issues, []); +}); + +test("collectArtifacts deduplicates PRs", () => { + const toolUses = [ + { name: "create_pull_request", input: { head: "feat/x", repo: "org/repo" } }, + { name: "create_pull_request", input: { head: "feat/x", repo: "org/repo" } }, + ]; + const result = collectArtifacts(toolUses, null); + assert.equal(result.prs.length, 1); +}); + +test("collectArtifacts deduplicates issues", () => { + const toolUses = [ + { name: "Bash", input: { command: "ENG-42 and ENG-42" } }, + { name: "Bash", input: { command: "ENG-42 again" } }, + ]; + const result = collectArtifacts(toolUses, null); + assert.equal(result.issues.filter((i) => i.key === "ENG-42").length, 1); +}); + +test("collectArtifacts returns null repo for null cwd", () => { + const result = collectArtifacts([], null); + assert.equal(result.repo, null); +}); + +test("collectArtifacts handles empty tool uses", () => { + const result = collectArtifacts([], null); + assert.deepEqual(result.prs, []); + assert.deepEqual(result.issues, []); + assert.equal(result.repo, null); +}); diff --git a/apps/desktop/test/session-timing.test.ts b/apps/desktop/test/session-timing.test.ts new file mode 100644 index 00000000..c946dada --- /dev/null +++ b/apps/desktop/test/session-timing.test.ts @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { computeSessionTiming } from "../src/shared/session-timing.js"; + +/** Helper: build an event with a given type and ISO timestamp. */ +function ev(eventType: string, ms: number) { + return { eventType, createdAt: new Date(ms).toISOString() }; +} + +test("empty events array returns zeros", () => { + assert.deepStrictEqual(computeSessionTiming([]), { + activeAgentMs: 0, + waitingUserMs: 0, + }); +}); + +test("single event returns zeros", () => { + const result = computeSessionTiming([ev("PostToolUse", 1000)]); + assert.deepStrictEqual(result, { activeAgentMs: 0, waitingUserMs: 0 }); +}); + +test("agent event followed by human event counts as waitingUserMs", () => { + const result = computeSessionTiming([ + ev("PostToolUse", 1000), // agent + ev("UserPromptSubmit", 4000), // human + ]); + assert.equal(result.waitingUserMs, 3000); + assert.equal(result.activeAgentMs, 0); +}); + +test("human event followed by agent event counts as activeAgentMs", () => { + const result = computeSessionTiming([ + ev("UserPromptSubmit", 1000), // human + ev("PostToolUse", 6000), // agent + ]); + assert.equal(result.activeAgentMs, 5000); + assert.equal(result.waitingUserMs, 0); +}); + +test("system events are skipped and do not contribute to either bucket", () => { + // system -> agent: system is prev, prevRole === "system" so gap is dropped + const result = computeSessionTiming([ + ev("SessionStart", 0), // system + ev("PostToolUse", 5000), // agent + ]); + assert.equal(result.activeAgentMs, 0); + assert.equal(result.waitingUserMs, 0); +}); + +test("mixed sequence produces expected active/waiting split", () => { + // Timeline: + // 0ms UserPromptSubmit (human) + // -> 2000ms gap, human->agent = activeAgentMs += 2000 + // 2000ms PostToolUse (agent) + // -> 3000ms gap, agent->agent = activeAgentMs += 3000 + // 5000ms PostToolUse (agent) + // -> 1000ms gap, agent->agent = activeAgentMs += 1000 + // 6000ms Stop (agent) + // -> 4000ms gap, agent->human = waitingUserMs += 4000 + // 10000ms UserPromptSubmit (human) + const result = computeSessionTiming([ + ev("UserPromptSubmit", 0), + ev("PostToolUse", 2000), + ev("PostToolUse", 5000), + ev("Stop", 6000), + ev("UserPromptSubmit", 10000), + ]); + assert.equal(result.activeAgentMs, 6000); + assert.equal(result.waitingUserMs, 4000); +}); + +test("all agent events produce no waitingUserMs", () => { + const result = computeSessionTiming([ + ev("PostToolUse", 0), + ev("PreToolUse", 1000), + ev("AssistantMessage", 3000), + ev("Stop", 7000), + ]); + assert.equal(result.activeAgentMs, 7000); + assert.equal(result.waitingUserMs, 0); +}); From 2b1a0c03ffe01d400f183beb20353a74b2a96596 Mon Sep 17 00:00:00 2001 From: Thadeus Burgess Date: Fri, 5 Jun 2026 14:29:55 -0500 Subject: [PATCH 2/4] FEA-1554: Fix lint (unused import) and bump version to 0.15.113 --- apps/desktop/package.json | 2 +- apps/desktop/src/main/collectors/import-session.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 415e7e0e..849a926c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "desktop", - "version": "0.15.112", + "version": "0.15.113", "description": "ClosedLoop Desktop", "author": "ClosedLoop AI ", "private": true, diff --git a/apps/desktop/src/main/collectors/import-session.ts b/apps/desktop/src/main/collectors/import-session.ts index 53598680..23203678 100644 --- a/apps/desktop/src/main/collectors/import-session.ts +++ b/apps/desktop/src/main/collectors/import-session.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import type { DatabaseSync } from "node:sqlite"; import type { createTokenUsageStore } from "../database/token-usage.js"; -import type { Harness, NormalizedMessage, NormalizedSession, NormalizedToolUse } from "./types.js"; +import type { Harness, NormalizedSession, NormalizedToolUse } from "./types.js"; /** * First-party session importer (FEA-1503). Replaces the vendor `import-history.js` From 6b3af241bd1ad1360c20871828731c1aed51f8e1 Mon Sep 17 00:00:00 2001 From: Thadeus Burgess Date: Fri, 5 Jun 2026 14:37:04 -0500 Subject: [PATCH 3/4] =?UTF-8?q?FEA-1554:=20Address=20review=20=E2=80=94=20?= =?UTF-8?q?match=20Codex=20tool=20output=20by=20call=20ID,=20count=20Codex?= =?UTF-8?q?=20patch=20file=20headers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/collectors/codex/codex-parser.ts | 22 +++++++++++++------ .../src/main/collectors/parser-utils.ts | 9 ++++++-- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/main/collectors/codex/codex-parser.ts b/apps/desktop/src/main/collectors/codex/codex-parser.ts index 53a79029..c9984a17 100644 --- a/apps/desktop/src/main/collectors/codex/codex-parser.ts +++ b/apps/desktop/src/main/collectors/codex/codex-parser.ts @@ -180,6 +180,7 @@ export async function parseRolloutFile( let assistantMessageCount = 0; const messageTimestamps: string[] = []; const toolUses: NormalizedToolUse[] = []; + const toolCallIndex = new Map(); const turnDurations: NormalizedTurnDuration[] = []; const plans: NormalizedPlan[] = []; // CLOSEDLOOP plan-extraction (FEA-1189) const apiErrors: NormalizedApiError[] = []; @@ -262,6 +263,7 @@ export async function parseRolloutFile( } else if (itype === "function_call" || itype === "custom_tool_call") { const toolName = asStr(p.name) ?? asStr(p.tool_name) ?? "function"; const toolInput = safeJson(p.arguments != null ? p.arguments : p.input); + const callId = asStr(p.call_id) ?? asStr(p.id) ?? null; const tu: NormalizedToolUse = { name: toolName, timestamp: iso || firstTimestamp, @@ -286,14 +288,18 @@ export async function parseRolloutFile( } } } + if (callId) toolCallIndex.set(callId, toolUses.length); toolUses.push(tu); } else if (itype === "local_shell_call") { + const shellCallId = asStr(p.call_id) ?? asStr(p.id) ?? null; const action = asRec(p.action) ?? {}; - toolUses.push({ + const shellTu: NormalizedToolUse = { name: "shell", timestamp: iso || firstTimestamp, input: action.command || p.action || p.input || null, - }); + }; + if (shellCallId) toolCallIndex.set(shellCallId, toolUses.length); + toolUses.push(shellTu); } else if ( itype === "function_call_output" || itype === "custom_tool_call_output" || @@ -304,14 +310,16 @@ export async function parseRolloutFile( const isErr = outRec ? outRec.success === false || outRec.is_error === true || !!outRec.error : false; - // CR-3: capture tool output on the most recent matching tool use + // CR-3: match tool output by call ID when available, fall back to most recent const outputStr = typeof out === "string" ? out : JSON.stringify(out); const truncatedOutput = truncateText(outputStr); - if (toolUses.length > 0) { - const lastTool = toolUses[toolUses.length - 1]; - lastTool.output = truncatedOutput; - lastTool.isError = isErr; + const outputCallId = asStr(p.call_id) ?? asStr(p.id) ?? null; + const matchIdx = outputCallId != null ? toolCallIndex.get(outputCallId) : undefined; + const matchedTool = matchIdx != null ? toolUses[matchIdx] : toolUses[toolUses.length - 1]; + if (matchedTool) { + matchedTool.output = truncatedOutput; + matchedTool.isError = isErr; } if (isErr) { const content = diff --git a/apps/desktop/src/main/collectors/parser-utils.ts b/apps/desktop/src/main/collectors/parser-utils.ts index 9266e782..c74a1914 100644 --- a/apps/desktop/src/main/collectors/parser-utils.ts +++ b/apps/desktop/src/main/collectors/parser-utils.ts @@ -95,11 +95,16 @@ export function computeUnifiedDiffDelta(patch: string): { add: number; del: numb return { add, del }; } -/** CR-4: Count file headers in a unified diff. */ +/** CR-4: Count file headers in a unified or Codex-style diff. */ export function countDiffFiles(patch: string): number { let count = 0; for (const line of patch.split("\n")) { - if (line.startsWith("--- ")) count++; + if ( + line.startsWith("--- ") || + line.startsWith("*** Add File:") || + line.startsWith("*** Update File:") || + line.startsWith("*** Delete File:") + ) count++; } return count; } From 651e32a49a073e1af7f046f4cadcb3b39930c27d Mon Sep 17 00:00:00 2001 From: Thadeus Burgess Date: Fri, 5 Jun 2026 15:10:35 -0500 Subject: [PATCH 4/4] =?UTF-8?q?FEA-1554:=20Address=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20P1=20sync=20strip,=20computeLineDelta,=20metadata?= =?UTF-8?q?=20refresh,=20isSynthetic,=20parser=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/agent-session-sync-service.ts | 6 ++- .../src/main/collectors/codex/codex-parser.ts | 22 +++++++--- .../main/collectors/copilot/copilot-parser.ts | 42 ++++++------------- .../main/collectors/cursor/cursor-parser.ts | 14 +++++-- .../src/main/collectors/import-session.ts | 3 ++ .../collectors/opencode/opencode-parser.ts | 38 +++++++++++++++-- .../src/main/collectors/parser-utils.ts | 24 +++++++---- apps/desktop/src/main/collectors/types.ts | 2 + .../test/agent-session-sync-service.test.ts | 2 +- apps/desktop/test/parser-utils.test.ts | 21 +++++----- 10 files changed, 110 insertions(+), 64 deletions(-) diff --git a/apps/desktop/src/main/agent-session-sync-service.ts b/apps/desktop/src/main/agent-session-sync-service.ts index 26e1de51..25646498 100644 --- a/apps/desktop/src/main/agent-session-sync-service.ts +++ b/apps/desktop/src/main/agent-session-sync-service.ts @@ -833,7 +833,11 @@ export function sanitizeSessionForSync( }; } -const STRIPPED_LEAF_KEYS = new Set(["prompt", "content", "stdout", "stderr", "text", "output", "reasoning"]); +const STRIPPED_LEAF_KEYS = new Set([ + "prompt", "content", "stdout", "stderr", + "text", "output", "reasoning", + "old_string", "new_string", "patch", "command", "arguments", +]); function stripDataContent(data: SyncJsonValue | undefined): SyncJsonValue | undefined { if (data === undefined || data === null) { diff --git a/apps/desktop/src/main/collectors/codex/codex-parser.ts b/apps/desktop/src/main/collectors/codex/codex-parser.ts index c9984a17..6db111d5 100644 --- a/apps/desktop/src/main/collectors/codex/codex-parser.ts +++ b/apps/desktop/src/main/collectors/codex/codex-parser.ts @@ -30,6 +30,7 @@ import { computeUnifiedDiffDelta, countDiffFiles, collectArtifacts, + isSyntheticModelKey, } from "../parser-utils.js"; import type { NormalizedApiError, @@ -220,11 +221,13 @@ export async function parseRolloutFile( userMessageCount++; if (explicitIso) pendingTurnStartedAt = explicitIso; // CR-1: capture user message + const userModel = currentTurnModel ?? model; messages.push({ role: "human", timestamp: iso || firstTimestamp, text: truncateText(text), - model: currentTurnModel ?? model, + model: userModel, + ...(userModel && isSyntheticModelKey(userModel) ? { isSynthetic: true } : {}), }); } else { assistantMessageCount++; @@ -242,23 +245,27 @@ export async function parseRolloutFile( }); } // CR-1: capture assistant message + const assistantModel = currentTurnModel ?? model; messages.push({ role: "assistant", timestamp: iso || firstTimestamp, text: truncateText(text), - model: currentTurnModel ?? model, + model: assistantModel, + ...(assistantModel && isSyntheticModelKey(assistantModel) ? { isSynthetic: true } : {}), }); } } else if (itype === "reasoning") { thinkingBlockCount++; // CR-1: capture reasoning as a thinking message const reasoningText = extractText(p.content) || asStr(p.text) || asStr(p.summary) || null; + const thinkingModel = currentTurnModel ?? model; messages.push({ role: "assistant", timestamp: iso || firstTimestamp, text: truncateText(reasoningText), - model: currentTurnModel ?? model, + model: thinkingModel, isThinking: true, + ...(thinkingModel && isSyntheticModelKey(thinkingModel) ? { isSynthetic: true } : {}), }); } else if (itype === "function_call" || itype === "custom_tool_call") { const toolName = asStr(p.name) ?? asStr(p.tool_name) ?? "function"; @@ -512,9 +519,14 @@ export async function parseRolloutFile( const isErr = outRec ? outRec.success === false || outRec.is_error === true || !!outRec.error : false; - // Find the last MCP tool use to attach output + // Find the last MCP tool use that hasn't been matched yet (no output set). + // This guards against interleaved MCP calls (A begin, B begin, A end, B end) + // where a naive backward scan would attach A's output to B. for (let i = toolUses.length - 1; i >= 0; i--) { - if (toolUses[i].mcpServer != null || toolUses[i].mcpMethod != null) { + if ( + (toolUses[i].mcpServer != null || toolUses[i].mcpMethod != null) && + toolUses[i].output === undefined + ) { if (out !== undefined) { const outputStr = typeof out === "string" ? out : JSON.stringify(out); toolUses[i].output = truncateText(outputStr); diff --git a/apps/desktop/src/main/collectors/copilot/copilot-parser.ts b/apps/desktop/src/main/collectors/copilot/copilot-parser.ts index 30db0897..01dccf9a 100644 --- a/apps/desktop/src/main/collectors/copilot/copilot-parser.ts +++ b/apps/desktop/src/main/collectors/copilot/copilot-parser.ts @@ -22,6 +22,7 @@ import { pushTurnDuration, truncateText, collectArtifacts, + isSyntheticModelKey, } from "../parser-utils.js"; import type { NormalizedSession, @@ -101,7 +102,7 @@ function extractText(payload: unknown): string | null { if (typeof payload !== "object") return null; const obj = payload as Record; // Direct text/content fields - for (const key of ["text", "content", "body", "value", "message"]) { + for (const key of ["text", "content", "markdown", "body", "value", "message"]) { const v = obj[key]; if (typeof v === "string" && v.trim().length > 0) return v; } @@ -291,29 +292,7 @@ export function parseChatSessionFile( dataObj.sessionId || dataObj.id || path.basename(filePath, ".json"), ); - // P1 Fix: extract token usage from raw requests BEFORE normalization, - // since normalizeChatMessages reduces each request to {role, timestamp} - // and drops the original usage/response payloads. const rawRequests = Array.isArray(dataObj.requests) ? dataObj.requests : []; - const requestTokenFields: TokenFields = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; - for (const req of rawRequests) { - if (!req || typeof req !== "object") continue; - const reqObj = req as Record; - const usageInfo = - reqObj.usage || reqObj.tokenUsage || reqObj.token_count || - get(reqObj.response, "usage") || get(reqObj.result, "usage") || null; - if (usageInfo && typeof usageInfo === "object") { - const u = usageInfo as Record; - if (u.input_tokens != null) requestTokenFields.input += Number(u.input_tokens); - if (u.output_tokens != null) requestTokenFields.output += Number(u.output_tokens); - if (u.prompt_tokens != null) requestTokenFields.input += Number(u.prompt_tokens); - if (u.completion_tokens != null) requestTokenFields.output += Number(u.completion_tokens); - if (u.cache_read_tokens != null) requestTokenFields.cacheRead += Number(u.cache_read_tokens); - if (u.cached_input_tokens != null) requestTokenFields.cacheRead += Number(u.cached_input_tokens); - if (u.cache_write_tokens != null) requestTokenFields.cacheWrite += Number(u.cache_write_tokens); - if (u.cache_creation_input_tokens != null) requestTokenFields.cacheWrite += Number(u.cache_creation_input_tokens); - } - } const messages = normalizeChatMessages(dataObj); if (!Array.isArray(messages) || messages.length === 0) return null; @@ -354,6 +333,8 @@ export function parseChatSessionFile( // CR-5: Per-message model const msgModel = (msgObj.model as string | null) || null; + const resolvedModel = msgModel || model || "copilot-default"; + const synthetic = isSyntheticModelKey(resolvedModel) ? true : undefined; if (role === "user" || role === "human") { userMessageCount++; @@ -364,6 +345,7 @@ export function parseChatSessionFile( timestamp: iso, text: truncateText(msgObj.text as string | null ?? extractText(msgObj)), model: msgModel, + ...(synthetic ? { isSynthetic: true } : {}), }); } else if (role === "assistant" || role === "copilot" || role === "bot") { assistantMessageCount++; @@ -381,6 +363,7 @@ export function parseChatSessionFile( text: null, model: msgModel, isThinking: true, + ...(synthetic ? { isSynthetic: true } : {}), }); } else { normalizedMessages.push({ @@ -388,6 +371,7 @@ export function parseChatSessionFile( timestamp: iso, text: truncateText(msgObj.text as string | null ?? extractText(msgObj)), model: msgModel, + ...(synthetic ? { isSynthetic: true } : {}), }); } @@ -522,13 +506,6 @@ export function parseChatSessionFile( } } - // Merge request-level tokens (from raw requests before normalization) - // with message-level tokens. Use summation since each request is unique. - tokenFields.input += requestTokenFields.input; - tokenFields.output += requestTokenFields.output; - tokenFields.cacheRead += requestTokenFields.cacheRead; - tokenFields.cacheWrite += requestTokenFields.cacheWrite; - // Token usage from top-level session data const topUsage = dataObj.usage || dataObj.tokenUsage || dataObj.token_count || null; if (topUsage && typeof topUsage === "object") { @@ -674,6 +651,8 @@ export async function parseCliEventFile( // CR-5: Per-event model const eventModel = (payload.model || recObj.model || null) as string | null; + const cliResolvedModel = eventModel || model || "copilot-default"; + const cliSynthetic = isSyntheticModelKey(cliResolvedModel) ? true : undefined; // Messages if (type === "user_message" || type === "user_input" || type === "prompt") { @@ -686,6 +665,7 @@ export async function parseCliEventFile( timestamp: iso, text: truncateText(userText), model: eventModel, + ...(cliSynthetic ? { isSynthetic: true } : {}), }); } if (type === "assistant_message" || type === "response" || type === "completion") { @@ -700,6 +680,7 @@ export async function parseCliEventFile( timestamp: iso, text: truncateText(assistantText), model: eventModel, + ...(cliSynthetic ? { isSynthetic: true } : {}), }); } @@ -779,6 +760,7 @@ export async function parseCliEventFile( text: null, model: eventModel, isThinking: true, + ...(cliSynthetic ? { isSynthetic: true } : {}), }); } } diff --git a/apps/desktop/src/main/collectors/cursor/cursor-parser.ts b/apps/desktop/src/main/collectors/cursor/cursor-parser.ts index 5c2851be..122773ea 100644 --- a/apps/desktop/src/main/collectors/cursor/cursor-parser.ts +++ b/apps/desktop/src/main/collectors/cursor/cursor-parser.ts @@ -14,7 +14,7 @@ import fs from "node:fs"; import path from "node:path"; import readline from "node:readline"; -import { toIso, safeJson, pushTurnDuration, truncateText, collectArtifacts } from "../parser-utils.js"; +import { toIso, safeJson, pushTurnDuration, truncateText, collectArtifacts, isSyntheticModelKey } from "../parser-utils.js"; import type { NormalizedApiError, NormalizedMessage, @@ -155,12 +155,15 @@ export async function parseTranscriptFile(filePath: string): Promise | null) + : null; + const tokensModelID = isObject(tokensObj) ? tokensObj.modelID : undefined; + const msgModel = modelIdFromValue(data.model ?? data.modelID ?? tokensModelID) || sessionModel; // CR-2: Per-message token counts. const msgTokens = extractMessageTokens(data); @@ -233,6 +239,7 @@ function parseSessionRow( text: truncateText(userText), model: msgModel, tokens: msgTokens ?? undefined, + ...(msgModel && isSyntheticModelKey(msgModel) ? { isSynthetic: true } : {}), }); // CR-2: Token series for user messages (if tokens present). @@ -260,6 +267,7 @@ function parseSessionRow( text: truncateText(assistantText), model: msgModel, tokens: msgTokens ?? undefined, + ...(msgModel && isSyntheticModelKey(msgModel) ? { isSynthetic: true } : {}), }); // CR-2: Token series for assistant messages. @@ -300,6 +308,7 @@ function parseSessionRow( text: null, model: sessionModel, isThinking: true, + ...(sessionModel && isSyntheticModelKey(sessionModel) ? { isSynthetic: true } : {}), }); } else if (part.type === "text") { // CR-1: Text parts contribute to messages. These are typically @@ -313,6 +322,7 @@ function parseSessionRow( timestamp: iso, text: truncateText(textContent), model: sessionModel, + ...(sessionModel && isSyntheticModelKey(sessionModel) ? { isSynthetic: true } : {}), }); } } else if (part.type === "tool") { @@ -403,7 +413,25 @@ function parseSessionRow( const summaryAdds = Number(sessionRow.summary_additions || 0); const summaryDels = Number(sessionRow.summary_deletions || 0); const summaryFiles = Number(sessionRow.summary_files || 0); - if (summaryAdds || summaryDels || summaryFiles) { + // Parse summary_diffs for additional diff context (unified diff text). + const summaryDiffsRaw = sessionRow.summary_diffs; + if (typeof summaryDiffsRaw === "string" && summaryDiffsRaw.length > 0) { + const diffDelta = computeUnifiedDiffDelta(summaryDiffsRaw); + const diffFiles = countDiffFiles(summaryDiffsRaw); + // Prefer summary_diffs line counts when they provide data and the + // explicit summary columns are zeroed out; otherwise the explicit + // columns are authoritative. + const effectiveAdds = summaryAdds || diffDelta.add; + const effectiveDels = summaryDels || diffDelta.del; + const effectiveFiles = summaryFiles || diffFiles; + if (effectiveAdds || effectiveDels || effectiveFiles) { + diffStats = { + filesChanged: effectiveFiles, + linesAdded: effectiveAdds, + linesRemoved: effectiveDels, + }; + } + } else if (summaryAdds || summaryDels || summaryFiles) { diffStats = { filesChanged: summaryFiles, linesAdded: summaryAdds, @@ -498,7 +526,8 @@ function hasSummaryColumns(db: DatabaseSync): boolean { return ( names.has("summary_additions") && names.has("summary_deletions") && - names.has("summary_files") + names.has("summary_files") && + names.has("summary_diffs") ); } catch { return false; @@ -537,7 +566,8 @@ export function loadSessionsFromDb( tokens_cache_write, summary_additions, summary_deletions, - summary_files + summary_files, + summary_diffs FROM session ORDER BY time_updated DESC, id DESC ` diff --git a/apps/desktop/src/main/collectors/parser-utils.ts b/apps/desktop/src/main/collectors/parser-utils.ts index c74a1914..bbe07434 100644 --- a/apps/desktop/src/main/collectors/parser-utils.ts +++ b/apps/desktop/src/main/collectors/parser-utils.ts @@ -71,17 +71,24 @@ export function truncateText(text: string | null | undefined, maxBytes = 4096): return buf.subarray(0, maxBytes).toString("utf8"); } -/** CR-4: Compute lines added/removed from old/new string comparison. */ +/** CR-4: Compute lines added/removed by diffing old/new line arrays. */ export function computeLineDelta( oldText: string | null | undefined, newText: string | null | undefined, ): { add: number; del: number } { - const oldLines = oldText ? oldText.split("\n").length : 0; - const newLines = newText ? newText.split("\n").length : 0; - return { - add: Math.max(0, newLines - oldLines), - del: Math.max(0, oldLines - newLines), - }; + const oldLines = oldText ? oldText.split("\n") : []; + const newLines = newText ? newText.split("\n") : []; + const oldSet = new Set(oldLines); + const newSet = new Set(newLines); + let del = 0; + for (const line of oldLines) { + if (!newSet.has(line)) del++; + } + let add = 0; + for (const line of newLines) { + if (!oldSet.has(line)) add++; + } + return { add, del }; } /** CR-4: Parse a unified diff for lines added/removed (Codex apply_patch). */ @@ -133,8 +140,7 @@ export function extractPrReferences( if (PR_TOOL_PATTERNS.has(toolName)) { const repo = typeof obj.repo === "string" ? obj.repo : undefined; - const head = typeof obj.head === "string" ? obj.head : undefined; - return head ? [{ number: head, repo }] : []; + return [{ number: "pending", repo }]; } if (toolName === "Bash") { diff --git a/apps/desktop/src/main/collectors/types.ts b/apps/desktop/src/main/collectors/types.ts index f4d4c97c..aeee276c 100644 --- a/apps/desktop/src/main/collectors/types.ts +++ b/apps/desktop/src/main/collectors/types.ts @@ -64,6 +64,8 @@ export interface NormalizedMessage { model?: string | null; tokens?: { input: number; output: number; cacheRead?: number; cacheWrite?: number }; isThinking?: boolean; + /** CR-5: True when the model key is a synthetic fallback (e.g. *-default). */ + isSynthetic?: boolean; } /** CR-2: A per-turn token record for time-series reconstruction. */ diff --git a/apps/desktop/test/agent-session-sync-service.test.ts b/apps/desktop/test/agent-session-sync-service.test.ts index c845de6d..ee762ed3 100644 --- a/apps/desktop/test/agent-session-sync-service.test.ts +++ b/apps/desktop/test/agent-session-sync-service.test.ts @@ -1193,7 +1193,7 @@ test("sanitizeSessionForSync preserves data without content key", () => { const sanitized = sanitizeSessionForSync(session as any); - assert.deepEqual(sanitized.events[0].data, { command: "git status", cwd: "/home/user" }, "data without content key is fully preserved"); + assert.deepEqual(sanitized.events[0].data, { cwd: "/home/user" }, "command is stripped but other safe keys preserved"); }); test("sanitizeSessionForSync strips content/stdout/stderr recursively inside tool_response", () => { diff --git a/apps/desktop/test/parser-utils.test.ts b/apps/desktop/test/parser-utils.test.ts index 84198fb1..71834d03 100644 --- a/apps/desktop/test/parser-utils.test.ts +++ b/apps/desktop/test/parser-utils.test.ts @@ -88,14 +88,14 @@ test("computeLineDelta computes del when old has more lines", () => { assert.deepEqual(result, { add: 0, del: 3 }); }); -test("computeLineDelta handles mixed add/del via net difference", () => { - // old has 3 lines, new has 5 lines -> net add:2, del:0 +test("computeLineDelta handles mixed add/del via set-based comparison", () => { + // old {a,b,c} -> new {x,y,z,w,v}: all 3 old lines removed, all 5 new lines added const result = computeLineDelta("a\nb\nc", "x\ny\nz\nw\nv"); - assert.deepEqual(result, { add: 2, del: 0 }); + assert.deepEqual(result, { add: 5, del: 3 }); - // old has 5 lines, new has 2 lines -> net add:0, del:3 + // old {a,b,c,d,e} -> new {x,y}: all 5 old removed, 2 new added const result2 = computeLineDelta("a\nb\nc\nd\ne", "x\ny"); - assert.deepEqual(result2, { add: 0, del: 3 }); + assert.deepEqual(result2, { add: 2, del: 5 }); }); test("computeLineDelta with one null side", () => { @@ -220,7 +220,7 @@ test("extractPrReferences finds PR from create_pull_request tool", () => { repo: "closedloop-ai/symphony", }); assert.equal(refs.length, 1); - assert.equal(refs[0].number, "feat/my-branch"); + assert.equal(refs[0].number, "pending"); assert.equal(refs[0].repo, "closedloop-ai/symphony"); }); @@ -230,7 +230,7 @@ test("extractPrReferences finds PR from mcp__github__create_pull_request", () => repo: "org/repo", }); assert.equal(refs.length, 1); - assert.equal(refs[0].number, "fix/bug-123"); + assert.equal(refs[0].number, "pending"); }); test("extractPrReferences finds PR from Bash tool gh pr create command", () => { @@ -257,9 +257,10 @@ test("extractPrReferences returns empty for null input", () => { assert.deepEqual(extractPrReferences("create_pull_request", null), []); }); -test("extractPrReferences returns empty when head is missing", () => { +test("extractPrReferences records pending when head is missing", () => { const refs = extractPrReferences("create_pull_request", { repo: "org/repo" }); - assert.equal(refs.length, 0); + assert.equal(refs.length, 1); + assert.equal(refs[0].number, "pending"); }); // --------------------------------------------------------------------------- @@ -357,7 +358,7 @@ test("collectArtifacts combines PR and issue refs from multiple tool uses", () = ]; const result = collectArtifacts(toolUses, "/home/user/my-project"); assert.equal(result.prs.length, 1); - assert.equal(result.prs[0].number, "feat/x"); + assert.equal(result.prs[0].number, "pending"); assert.equal(result.prs[0].repo, "org/repo"); assert.ok(result.issues.some((i) => i.key === "ENG-42")); assert.ok(result.issues.some((i) => i.key === "ENG-99"));