From 49e1e99d165a65e685e3337002efb0a7c9a9bf29 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:53:47 +0530 Subject: [PATCH] fix(x/renderer): load headless transcripts via the turn runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background-task and live-note history views called the legacy runs:fetch for ids that are now turn ids (ENOENT after stage 6). New shared loader src/lib/agent-transcript.ts fetches sessions:getTurn first and falls back to runs:fetch so pre-migration histories stay readable (fallback dies with the runs runtime in stage 7); turn transcripts render through the same buildTurnConversation used by the chat views. Unit-tested (turnToTranscript mapping + failure surfacing). The chat-log download used runs:downloadLog with what is now a session id. Added sessions:downloadLog (concatenates the session's turn logs into one JSONL via the save dialog); the sidebar tries it first and falls back to runs:downloadLog for legacy background tabs. Remaining renderer runs:* callers are code-mode only (use-code-chat, runs:events feed) — the deliberate stage-7 carve-out. Co-Authored-By: Claude Fable 5 --- apps/x/apps/main/src/ipc.ts | 31 +++++++ .../renderer/src/components/bg-tasks-view.tsx | 70 ++++++--------- .../renderer/src/components/chat-sidebar.tsx | 9 +- .../src/components/live-note-sidebar.tsx | 20 ++--- .../renderer/src/lib/agent-transcript.test.ts | 28 ++++++ .../apps/renderer/src/lib/agent-transcript.ts | 89 +++++++++++++++++++ apps/x/packages/shared/src/ipc.ts | 8 ++ 7 files changed, 198 insertions(+), 57 deletions(-) create mode 100644 apps/x/apps/renderer/src/lib/agent-transcript.test.ts create mode 100644 apps/x/apps/renderer/src/lib/agent-transcript.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index c290486e..89520999 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -907,6 +907,37 @@ export function setupIpcHandlers() { await container.resolve('sessions').deleteSession(args.sessionId); return { success: true }; }, + 'sessions:downloadLog': async (event, args) => { + // Concatenate the session's turn logs into one JSONL for debugging. + const sessions = container.resolve('sessions'); + const state = await sessions.getSession(args.sessionId); + const win = BrowserWindow.fromWebContents(event.sender); + const result = await dialog.showSaveDialog(win!, { + defaultPath: `${args.sessionId}.jsonl.log`, + filters: [ + { name: 'Chat Log', extensions: ['log'] }, + { name: 'JSONL', extensions: ['jsonl'] }, + { name: 'All Files', extensions: ['*'] }, + ], + }); + if (result.canceled || !result.filePath) { + return { success: false }; + } + try { + const lines: string[] = []; + for (const ref of state.turns) { + const turn = await sessions.getTurn(ref.turnId); + for (const turnEvent of turn.events) { + lines.push(JSON.stringify(turnEvent)); + } + } + await fs.writeFile(result.filePath, lines.join('\n') + '\n'); + return { success: true }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to download chat log'; + return { success: false, error: message }; + } + }, 'runs:downloadLog': async (event, args) => { const runFileName = `${args.runId}.jsonl`; if (path.basename(runFileName) !== runFileName) { diff --git a/apps/x/apps/renderer/src/components/bg-tasks-view.tsx b/apps/x/apps/renderer/src/components/bg-tasks-view.tsx index c48df79e..45e23169 100644 --- a/apps/x/apps/renderer/src/components/bg-tasks-view.tsx +++ b/apps/x/apps/renderer/src/components/bg-tasks-view.tsx @@ -6,9 +6,7 @@ import { Pencil, Check, PanelRightClose, PanelRightOpen, Sparkles, Code2, FolderOpen, LayoutTemplate, } from 'lucide-react' -import type { z } from 'zod' import type { BackgroundTask, BackgroundTaskSummary, Triggers } from '@x/shared/dist/background-task.js' -import type { Run } from '@x/shared/dist/runs.js' import { Button } from '@/components/ui/button' import { Switch } from '@/components/ui/switch' import { Input } from '@/components/ui/input' @@ -17,7 +15,7 @@ import { useBackgroundTaskAgentStatus } from '@/hooks/use-bg-task-agent-status' import { formatRelativeTime } from '@/lib/relative-time' import { toast } from '@/lib/toast' import type { ConversationItem } from '@/lib/chat-conversation' -import { runLogToConversation } from '@/lib/run-to-conversation' +import { fetchAgentRunTranscript, type AgentRunTranscript } from '@/lib/agent-transcript' import { CompactConversation } from '@/components/compact-conversation' import { RichMarkdownViewer } from '@/components/rich-markdown-viewer' import { HtmlFileViewer } from '@/components/html-file-viewer' @@ -970,10 +968,9 @@ function SetupTab({ // Runs history tab — list + drill-down transcript view // // Source of truth: `bg-tasks//runs.log` — a plain-text file with one -// runId per line (newest first). The actual transcripts live at the global -// `$WorkDir/runs/.jsonl`, so this tab fetches runIds via the bg-task -// IPC, then loads each Run through the standard `runs:fetch`. No bg-task- -// specific transcript path or schema needed. +// turn id per line (newest first). Transcripts live in the turn runtime's +// storage; this tab fetches ids via the bg-task IPC, then loads each through +// the shared agent-transcript loader (turn-first, legacy-run fallback). // --------------------------------------------------------------------------- interface RunRowSummary { @@ -984,27 +981,6 @@ interface RunRowSummary { error?: string } -// Pull the bits we want to display for a row out of a full Run's event log. -function summarizeRun(run: z.infer): RunRowSummary { - const out: RunRowSummary = { runId: run.id, createdAt: run.createdAt, trigger: run.subUseCase } - for (const event of run.log) { - if (event.type === 'error' && typeof event.error === 'string') { - out.error = event.error - } else if (event.type === 'message' && event.message?.role === 'assistant') { - const content = event.message.content - if (typeof content === 'string') { - out.summary = content - } else if (Array.isArray(content)) { - const text = content - .filter((p) => p.type === 'text') - .map((p) => ('text' in p ? p.text : '')) - .join('') - if (text) out.summary = text - } - } - } - return out -} function RunsHistoryTab({ slug, task }: { slug: string; task: BackgroundTask }) { const [rows, setRows] = useState([]) @@ -1016,19 +992,25 @@ function RunsHistoryTab({ slug, task }: { slug: string; task: BackgroundTask }) setLoading(true) try { const { runIds } = await window.ipc.invoke('bg-task:listRunIds', { slug, limit: 100 }) - // Fetch each Run in parallel via the canonical IPC. Runs whose - // jsonl no longer exists (deleted manually, never written, …) are - // dropped silently. + // Fetch transcripts in parallel (turn-first, legacy-run + // fallback). Ids whose files no longer exist keep a bare row so + // the user knows the run happened. const settled = await Promise.allSettled( - runIds.map(runId => window.ipc.invoke('runs:fetch', { runId })) + runIds.map(runId => fetchAgentRunTranscript(runId)) ) const next: RunRowSummary[] = [] for (let i = 0; i < settled.length; i++) { const r = settled[i] - if (r.status === 'fulfilled' && r.value) { - next.push(summarizeRun(r.value)) + if (r.status === 'fulfilled') { + const t = r.value + next.push({ + runId: t.id, + ...(t.createdAt === undefined ? {} : { createdAt: t.createdAt }), + ...(t.trigger === undefined ? {} : { trigger: t.trigger }), + ...(t.summary === undefined ? {} : { summary: t.summary }), + ...(t.error === undefined ? {} : { error: t.error }), + }) } else { - // Keep the row visible with just the id so the user knows it exists. next.push({ runId: runIds[i] }) } } @@ -1134,7 +1116,7 @@ function RunTranscriptView({ isInFlight: boolean onBack: () => void }) { - const [run, setRun] = useState | null>(null) + const [transcript, setTranscript] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -1144,15 +1126,13 @@ function RunTranscriptView({ setError(null) void (async () => { try { - // Bg-task transcripts now live at the global runs/ location — - // same path resolution as every other run, no special handling. - const r = await window.ipc.invoke('runs:fetch', { runId }) + const t = await fetchAgentRunTranscript(runId) if (cancelled) return - setRun(r) + setTranscript(t) } catch (err) { if (cancelled) return setError(err instanceof Error ? err.message : String(err)) - setRun(null) + setTranscript(null) } finally { if (!cancelled) setLoading(false) } @@ -1160,8 +1140,8 @@ function RunTranscriptView({ return () => { cancelled = true } }, [runId]) - const summary = run ? summarizeRun(run) : undefined - const items: ConversationItem[] = run ? runLogToConversation(run.log) : [] + const summary = transcript ?? undefined + const items: ConversationItem[] = transcript?.items ?? [] return (
@@ -1221,10 +1201,10 @@ function RunTranscriptView({ Couldn't load transcript: {error}
)} - {run && !loading && items.length === 0 && ( + {transcript && !loading && items.length === 0 && (

No messages or tool calls recorded.

)} - {run && !loading && items.length > 0 && ( + {transcript && !loading && items.length > 0 && ( )} diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 893f9f1a..e24ac6de 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -378,7 +378,14 @@ export function ChatSidebar({ } try { - const result = await window.ipc.invoke('runs:downloadLog', { runId: activeRunId }) + // Session-first (new runtime); legacy runs fallback covers old + // background tabs until stage 7 removes the runs runtime. + let result: { success: boolean; error?: string } + try { + result = await window.ipc.invoke('sessions:downloadLog', { sessionId: activeRunId }) + } catch { + result = await window.ipc.invoke('runs:downloadLog', { runId: activeRunId }) + } if (result.success) { toast.success('Chat log saved') } else if (result.error) { diff --git a/apps/x/apps/renderer/src/components/live-note-sidebar.tsx b/apps/x/apps/renderer/src/components/live-note-sidebar.tsx index 1510e6c9..18654729 100644 --- a/apps/x/apps/renderer/src/components/live-note-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/live-note-sidebar.tsx @@ -11,11 +11,9 @@ import { ChevronDown, ChevronRight, } from 'lucide-react' import { LiveNoteSchema, type LiveNote, type Triggers } from '@x/shared/dist/live-note.js' -import type { Run } from '@x/shared/dist/runs.js' -import type z from 'zod' import { useLiveNoteAgentStatus } from '@/hooks/use-live-note-agent-status' import { formatRelativeTime } from '@/lib/relative-time' -import { runLogToConversation } from '@/lib/run-to-conversation' +import { fetchAgentRunTranscript, type AgentRunTranscript } from '@/lib/agent-transcript' import { CompactConversation } from '@/components/compact-conversation' export type OpenLiveNotePanelDetail = { @@ -661,7 +659,7 @@ function SectionRegion({ label, children }: { label?: string; children: React.Re } function LastRunTab({ live }: { live: LiveNote }) { - const [run, setRun] = useState | null>(null) + const [transcript, setTranscript] = useState(null) const [loadingRun, setLoadingRun] = useState(false) const [fetchError, setFetchError] = useState(null) @@ -669,7 +667,7 @@ function LastRunTab({ live }: { live: LiveNote }) { useEffect(() => { if (!runId) { - setRun(null) + setTranscript(null) setFetchError(null) setLoadingRun(false) return @@ -679,13 +677,13 @@ function LastRunTab({ live }: { live: LiveNote }) { setFetchError(null) void (async () => { try { - const r = await window.ipc.invoke('runs:fetch', { runId }) + const t = await fetchAgentRunTranscript(runId) if (cancelled) return - setRun(r) + setTranscript(t) } catch (err) { if (cancelled) return setFetchError(err instanceof Error ? err.message : String(err)) - setRun(null) + setTranscript(null) } finally { if (!cancelled) setLoadingRun(false) } @@ -704,7 +702,7 @@ function LastRunTab({ live }: { live: LiveNote }) { } const isError = !!live.lastRunError - const items = run ? runLogToConversation(run.log) : [] + const items = transcript?.items ?? [] return (
@@ -751,10 +749,10 @@ function LastRunTab({ live }: { live: LiveNote }) { Couldn't load transcript: {fetchError}
)} - {run && !loadingRun && items.length === 0 && ( + {transcript && !loadingRun && items.length === 0 && (

No messages or tool calls recorded.

)} - {run && !loadingRun && items.length > 0 && ( + {transcript && !loadingRun && items.length > 0 && ( )} diff --git a/apps/x/apps/renderer/src/lib/agent-transcript.test.ts b/apps/x/apps/renderer/src/lib/agent-transcript.test.ts new file mode 100644 index 00000000..311309f3 --- /dev/null +++ b/apps/x/apps/renderer/src/lib/agent-transcript.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { turnToTranscript } from './agent-transcript' +import { completedTurnLog, created, requested, user } from './session-chat/test-fixtures' + +describe('turnToTranscript', () => { + it('maps a completed turn to id/createdAt/summary/items', () => { + const events = completedTurnLog('turn-1', 'sess-1', 'do the thing', 'all done') + const t = turnToTranscript('turn-1', events) + expect(t.id).toBe('turn-1') + expect(t.createdAt).toBeDefined() + expect(t.summary).toBe('all done') + expect(t.error).toBeUndefined() + expect(t.trigger).toBeUndefined() + expect(t.items.length).toBeGreaterThan(0) + }) + + it('surfaces turn_failed as error with no summary', () => { + const events = [ + created('turn-2', 'sess-1', user('go')), + requested('turn-2', 0), + { type: 'model_call_failed' as const, turnId: 'turn-2', ts: '2026-07-02T10:00:00Z', modelCallIndex: 0, error: 'boom' }, + { type: 'turn_failed' as const, turnId: 'turn-2', ts: '2026-07-02T10:00:00Z', error: 'boom', usage: {} }, + ] + const t = turnToTranscript('turn-2', events) + expect(t.error).toBe('boom') + expect(t.summary).toBeUndefined() + }) +}) diff --git a/apps/x/apps/renderer/src/lib/agent-transcript.ts b/apps/x/apps/renderer/src/lib/agent-transcript.ts new file mode 100644 index 00000000..71c4f75e --- /dev/null +++ b/apps/x/apps/renderer/src/lib/agent-transcript.ts @@ -0,0 +1,89 @@ +import type { z } from 'zod' +import type { Run } from '@x/shared/src/runs.js' +import { reduceTurn, type TurnEvent } from '@x/shared/src/turns.js' +import type { ConversationItem } from '@/lib/chat-conversation' +import { runLogToConversation } from '@/lib/run-to-conversation' +import { buildTurnConversation } from '@/lib/session-chat/turn-view' + +// Unified read model for a headless agent run's transcript, whether the id +// is a turn (new runtime) or a legacy run (pre-turn-runtime files under +// $WorkDir/runs/). Used by the background-tasks and live-note history views. + +export interface AgentRunTranscript { + id: string + createdAt?: string + // Legacy runs only (subUseCase); turns carry the trigger inside the + // message text instead. + trigger?: string + summary?: string + error?: string + items: ConversationItem[] +} + +export function turnToTranscript( + turnId: string, + events: Array>, +): AgentRunTranscript { + const state = reduceTurn(events) + const out: AgentRunTranscript = { + id: turnId, + createdAt: state.definition.ts, + items: buildTurnConversation(state), + } + if (state.terminal?.type === 'turn_failed') { + out.error = state.terminal.error + } + for (let i = state.modelCalls.length - 1; i >= 0; i--) { + const response = state.modelCalls[i].response + if (!response) continue + const content = response.content + const text = + typeof content === 'string' + ? content + : content.map((p) => (p.type === 'text' ? p.text : '')).join('') + if (text) { + out.summary = text + break + } + } + return out +} + +export function runToTranscript(run: z.infer): AgentRunTranscript { + const out: AgentRunTranscript = { + id: run.id, + createdAt: run.createdAt, + trigger: run.subUseCase, + items: runLogToConversation(run.log), + } + for (const event of run.log) { + if (event.type === 'error' && typeof event.error === 'string') { + out.error = event.error + } else if (event.type === 'message' && event.message?.role === 'assistant') { + const content = event.message.content + if (typeof content === 'string') { + out.summary = content + } else if (Array.isArray(content)) { + const text = content + .filter((p) => p.type === 'text') + .map((p) => ('text' in p ? p.text : '')) + .join('') + if (text) out.summary = text + } + } + } + return out +} + +// Turn-first with a legacy-run fallback so histories recorded before the +// turn-runtime migration stay readable. The fallback goes away with the +// runs runtime (stage 7). +export async function fetchAgentRunTranscript(id: string): Promise { + try { + const turn = await window.ipc.invoke('sessions:getTurn', { turnId: id }) + return turnToTranscript(id, turn.events) + } catch { + const run = await window.ipc.invoke('runs:fetch', { runId: id }) + return runToTranscript(run) + } +} diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index a174da9b..565ac47a 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -442,6 +442,14 @@ const ipcSchemas = { req: z.object({ sessionId: z.string(), title: z.string() }), res: z.object({ success: z.literal(true) }), }, + 'sessions:downloadLog': { + // Concatenates the session's turn logs into one JSONL for debugging. + req: z.object({ sessionId: z.string() }), + res: z.object({ + success: z.boolean(), + error: z.string().optional(), + }), + }, 'sessions:delete': { req: z.object({ sessionId: z.string() }), res: z.object({ success: z.literal(true) }),