fix(x/renderer): load headless transcripts via the turn runtime

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 <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-07-02 13:53:47 +05:30
parent c73531ec38
commit 49e1e99d16
7 changed files with 198 additions and 57 deletions

View file

@ -907,6 +907,37 @@ export function setupIpcHandlers() {
await container.resolve<ISessions>('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<ISessions>('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) {

View file

@ -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/<slug>/runs.log` — a plain-text file with one
// runId per line (newest first). The actual transcripts live at the global
// `$WorkDir/runs/<runId>.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<typeof Run>): 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<RunRowSummary[]>([])
@ -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<z.infer<typeof Run> | null>(null)
const [transcript, setTranscript] = useState<AgentRunTranscript | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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 (
<div className="flex flex-1 flex-col overflow-hidden">
@ -1221,10 +1201,10 @@ function RunTranscriptView({
Couldn&apos;t load transcript: {error}
</div>
)}
{run && !loading && items.length === 0 && (
{transcript && !loading && items.length === 0 && (
<p className="text-xs italic text-muted-foreground">No messages or tool calls recorded.</p>
)}
{run && !loading && items.length > 0 && (
{transcript && !loading && items.length > 0 && (
<CompactConversation items={items} />
)}
</div>

View file

@ -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) {

View file

@ -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<z.infer<typeof Run> | null>(null)
const [transcript, setTranscript] = useState<AgentRunTranscript | null>(null)
const [loadingRun, setLoadingRun] = useState(false)
const [fetchError, setFetchError] = useState<string | null>(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 (
<div className="flex-1 overflow-auto px-4 py-4 space-y-4">
@ -751,10 +749,10 @@ function LastRunTab({ live }: { live: LiveNote }) {
Couldn't load transcript: {fetchError}
</div>
)}
{run && !loadingRun && items.length === 0 && (
{transcript && !loadingRun && items.length === 0 && (
<p className="text-xs italic text-muted-foreground">No messages or tool calls recorded.</p>
)}
{run && !loadingRun && items.length > 0 && (
{transcript && !loadingRun && items.length > 0 && (
<CompactConversation items={items} />
)}
</div>

View file

@ -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()
})
})

View file

@ -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<z.infer<typeof TurnEvent>>,
): 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<typeof Run>): 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<AgentRunTranscript> {
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)
}
}

View file

@ -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) }),