diff --git a/docs/reference/session-summary.md b/docs/reference/session-summary.md index e4bd30d59644..22f8c912e1fe 100644 --- a/docs/reference/session-summary.md +++ b/docs/reference/session-summary.md @@ -49,10 +49,29 @@ The summary is a **materialized view** over the session's own provider transcrip the agent CLI writes — not a re-reading of the terminal. `session-summary-transcript-source.ts` resolves that file per agent type; the fold turns it into a structured ledger. +The fold reads a transcript only for agents it can name a file for, and each agent's file is +found a different way: + +- **Native-chat agents** (Claude/OpenClaude, Codex, Grok, OMP) go through + `native-chat/session-file-resolver.ts`, which takes the hook's `transcript_path` when it is + host-readable and otherwise searches the agent's own roots by session id. +- **Pi and Prime Agent** post `session_file` on every hook, so the reported path *is* the + transcript; `agent-session-resume.ts` only returns their provider session at all when that + path exists. +- **Copilot** reports no path, but its `session_id` is the name of its session directory: + `$COPILOT_HOME/session-state//events.jsonl`. + +Every other agent type returns null and the pane says so. Adding one means naming its parser in +`HOOK_RESOLVED_TRANSCRIPT_AGENTS` and reaching a readable file — the AI Vault parsers are +agent-generic (`parseAgentSessionFile` dispatches on `candidate.agent`), so nothing else changes. + - **Lazy by construction.** Opening the pane starts the fold; closing it cancels in-flight work. Nothing runs for sessions nobody opens, so there is no background LLM spend. - **Cheap facts are always available.** State, timestamps and event counts come from the - status entry, so the header hint ("Up to date" / "N events to catch up on") needs no fold. + status entry, so the header hint needs no fold. The hint still respects the fold's own state: + a fold in flight reads "Updating…" (set when the fold starts, not when its first chunk + returns), and a session with no readable transcript or a failed fold says that instead of + claiming to be up to date. - **Bounded, resumable chunks.** The transcript is split deterministically into slices of at most 24k chars or 40 events (`session-summary-fold.ts`), extracted in parallel and merged deterministically. Completed extracts are cached, so a fold that is cancelled or fails @@ -74,7 +93,12 @@ configured it follows the default agent and that agent's default model and effor A **dynamic** agent ships only a stub model list — Pi advertises `Config default` — so the pane asks its CLI once (` --list-models`, the same probe Native Chat uses), keeps the -answer and replaces the stub with it. Two rules matter there: +answer and replaces the stub with it. **Copilot** is dynamic too, but its catalog is only +documented, not listed: the CLI has no `models` subcommand, so the probe reads +`copilot help config`, whose `model` setting enumerates the accepted ids. That list is the CLI's +own and therefore tracks models released after the build, which a pinned array cannot; a machine +without the binary keeps a pinned snapshot of it (`COPILOT_FALLBACK_MODELS`) that the probe +replaces the moment it can run. Two rules matter there: - **The pane keys on the session, not just the pane.** A new agent session can start in the pane the user is already focused on, and the key includes the provider session id so the diff --git a/src/main/ai-vault/session-scanner-agent-sources.ts b/src/main/ai-vault/session-scanner-agent-sources.ts index 4a12303982cb..90848d3b2f50 100644 --- a/src/main/ai-vault/session-scanner-agent-sources.ts +++ b/src/main/ai-vault/session-scanner-agent-sources.ts @@ -30,7 +30,7 @@ const CODEX_SESSIONS_DIR = join( 'sessions' ) const GEMINI_SESSIONS_DIR = join(homedir(), '.gemini', 'tmp') -const COPILOT_SESSIONS_DIR = join( +export const COPILOT_SESSIONS_DIR = join( resolveAbsoluteDirOverride(process.env.COPILOT_HOME, join(homedir(), '.copilot')), 'session-state' ) diff --git a/src/main/session-summary/session-summary-service.test.ts b/src/main/session-summary/session-summary-service.test.ts index 89cabac6e067..bb7466f1f0b5 100644 --- a/src/main/session-summary/session-summary-service.test.ts +++ b/src/main/session-summary/session-summary-service.test.ts @@ -112,6 +112,19 @@ describe('SessionSummaryService', () => { expect(updates.some((u) => u.status === 'ready')).toBe(false) }) + // The renderer's header reads `fold.running`, and open()'s return value is the + // first snapshot it sees. A fold that only reported progress after its first + // chunk read as "Up to date" for the whole first fold. + it('reports the fold as in flight before the first chunk comes back', async () => { + const { store } = testStore() + const { service, setBrainReply } = harness(store, events(5)) + setBrainReply(() => new Promise(() => {})) + + const opened = service.open({ paneKey: 'tab:leaf' }) + + expect(opened.fold.running).toBe(true) + }) + // A pane whose status row was dropped keeps a provider-session remnant // (`server-cleanup.ts`): no state, prompt, or timestamps, but a resumable // session — which is all the fold needs to summarize a finished agent. diff --git a/src/main/session-summary/session-summary-service.ts b/src/main/session-summary/session-summary-service.ts index 9253a1216986..50a945105a93 100644 --- a/src/main/session-summary/session-summary-service.ts +++ b/src/main/session-summary/session-summary-service.ts @@ -180,6 +180,12 @@ export class SessionSummaryService { const controller = new AbortController() this.folds.set(paneKey, controller) const runtime = this.runtimes.get(paneKey) + // Why: the fold is in flight from here, before the transcript read resolves. + // Without this the header read "Up to date" for the whole first fold, because + // onProgress only fires once a chunk comes back. + if (runtime) { + runtime.fold = { running: true, completedChunks: 0, totalChunks: 0 } + } try { const entry = this.deps.getAgentStatusEntry(paneKey) const identity = identityOf(entry) diff --git a/src/main/session-summary/session-summary-transcript-source.test.ts b/src/main/session-summary/session-summary-transcript-source.test.ts new file mode 100644 index 000000000000..593993f01167 --- /dev/null +++ b/src/main/session-summary/session-summary-transcript-source.test.ts @@ -0,0 +1,177 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +/** + * Which provider transcripts the fold can read. + * + * Pi and Copilot are the agents this file exists for: neither is a native-chat + * transcript agent, so before this the fold resolved no path and the pane read + * "no readable transcript" for every one of their sessions. + */ + +const tempDirs: string[] = [] + +async function tempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'session-summary-source-')) + tempDirs.push(dir) + return dir +} + +afterEach(async () => { + vi.unstubAllEnvs() + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +/** Pi writes a `session` record then `message` records whose content is blocks. */ +function piTranscript(prompt: string, reply: string): string { + return [ + JSON.stringify({ + type: 'session', + id: 'pi-session-1', + cwd: '/tmp/project', + timestamp: '2026-01-01T00:00:00.000Z' + }), + JSON.stringify({ + type: 'message', + id: 'm1', + timestamp: '2026-01-01T00:00:01.000Z', + message: { role: 'user', content: [{ type: 'text', text: prompt }] } + }), + JSON.stringify({ + type: 'message', + id: 'm2', + timestamp: '2026-01-01T00:00:02.000Z', + message: { role: 'assistant', content: [{ type: 'text', text: reply }] } + }) + ].join('\n') +} + +/** Copilot writes one event object per line, payload under `data`. */ +function copilotTranscript(prompt: string, reply: string): string { + return [ + JSON.stringify({ + type: 'session.start', + data: { sessionId: 'copilot-session-1', context: { cwd: '/tmp/project' } }, + timestamp: '2026-01-01T00:00:00.000Z' + }), + JSON.stringify({ + type: 'user.message', + data: { content: prompt }, + timestamp: '2026-01-01T00:00:01.000Z' + }), + JSON.stringify({ + type: 'assistant.message', + data: { content: reply }, + timestamp: '2026-01-01T00:00:02.000Z' + }) + ].join('\n') +} + +/** + * Copilot's sessions root is a module-level constant read from the environment, + * so the stub has to land before the module graph is evaluated. + */ +async function loadSource() { + vi.resetModules() + return await import('./session-summary-transcript-source.js') +} + +const signal = (): AbortSignal => new AbortController().signal + +describe('readSessionSummaryTranscriptEvents', () => { + it('reads a Pi session from the file its hook reports', async () => { + const dir = await tempDir() + const transcriptPath = join(dir, '2026-01-01T00-00-00-000Z_pi-session-1.jsonl') + await writeFile(transcriptPath, piTranscript('Add a sticky-note icon', 'Icon replaced.')) + const { readSessionSummaryTranscriptEvents } = await loadSource() + + const events = await readSessionSummaryTranscriptEvents({ + identity: { + agentType: 'pi', + providerSession: { key: 'session_id', id: 'pi-session-1', transcriptPath } + }, + signal: signal() + }) + + expect(events?.map((event) => event.text)).toEqual(['Add a sticky-note icon', 'Icon replaced.']) + }) + + it('reads a Copilot session from its session-state directory', async () => { + const home = await tempDir() + vi.stubEnv('COPILOT_HOME', home) + const sessionDir = join(home, 'session-state', 'copilot-session-1') + await mkdir(sessionDir, { recursive: true }) + await writeFile( + join(sessionDir, 'events.jsonl'), + copilotTranscript('Why is the pane empty?', 'The transcript never resolved.') + ) + const { readSessionSummaryTranscriptEvents } = await loadSource() + + const events = await readSessionSummaryTranscriptEvents({ + identity: { + agentType: 'copilot', + providerSession: { key: 'session_id', id: 'copilot-session-1' } + }, + signal: signal() + }) + + expect(events?.map((event) => event.text)).toEqual([ + 'Why is the pane empty?', + 'The transcript never resolved.' + ]) + }) + + it('never joins a Copilot session id onto the sessions root', async () => { + const home = await tempDir() + vi.stubEnv('COPILOT_HOME', home) + // A transcript the traversal would reach if the id were joined unchecked. + await mkdir(join(home, 'session-state'), { recursive: true }) + await writeFile(join(home, 'session-state', 'events.jsonl'), copilotTranscript('x', 'y')) + const { readSessionSummaryTranscriptEvents } = await loadSource() + + const events = await readSessionSummaryTranscriptEvents({ + identity: { + agentType: 'copilot', + providerSession: { key: 'session_id', id: '../' } + }, + signal: signal() + }) + + expect(events).toBeNull() + }) + + it('returns null when the hook path is not a readable file', async () => { + const dir = await tempDir() + const { readSessionSummaryTranscriptEvents } = await loadSource() + + const events = await readSessionSummaryTranscriptEvents({ + identity: { + agentType: 'pi', + // A directory: resolvable, but not a transcript. + providerSession: { key: 'session_id', id: 'pi-session-1', transcriptPath: dir } + }, + signal: signal() + }) + + expect(events).toBeNull() + }) + + it('returns null for an agent whose transcript the fold cannot parse', async () => { + const dir = await tempDir() + const transcriptPath = join(dir, 'gemini.json') + await writeFile(transcriptPath, '{}') + const { readSessionSummaryTranscriptEvents } = await loadSource() + + const events = await readSessionSummaryTranscriptEvents({ + identity: { + agentType: 'gemini', + providerSession: { key: 'session_id', id: 'gemini-1', transcriptPath } + }, + signal: signal() + }) + + expect(events).toBeNull() + }) +}) diff --git a/src/main/session-summary/session-summary-transcript-source.ts b/src/main/session-summary/session-summary-transcript-source.ts index e4ee5bd562e3..5727f82cb02e 100644 --- a/src/main/session-summary/session-summary-transcript-source.ts +++ b/src/main/session-summary/session-summary-transcript-source.ts @@ -2,6 +2,7 @@ // ordered fold events. Preferred source per docs/reference/session-summary.md; // structured journal and PTY fallbacks are later adapters over the same events. import { stat } from 'node:fs/promises' +import { join } from 'node:path' import type { AgentType } from '../../shared/agent-status-types' import type { AiVaultAgent } from '../../shared/ai-vault-types' import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume' @@ -15,6 +16,7 @@ import { type TranscriptMessage } from '../ai-vault/session-transcript-consumers' import { resolveSessionFilePath } from '../native-chat/session-file-resolver' +import { COPILOT_SESSIONS_DIR } from '../ai-vault/session-scanner-agent-sources' import type { SessionFileCandidate, FileWithMtime } from '../ai-vault/session-scanner-types' import type { SessionSummarySourceEvent } from './session-summary-fold' @@ -27,6 +29,22 @@ const CANDIDATE_AGENTS: Record = { opencode: 'opencode' } +/** + * Transcript agents outside the native-chat set, which resolves its own paths. + * + * Pi and Prime Agent post `session_file` on every hook, so the reported path is + * the CLI's own artifact. Copilot reports no path at all, but names its session + * directory after the session id the hook carries. + */ +const HOOK_RESOLVED_TRANSCRIPT_AGENTS: Partial> = { + pi: 'pi', + 'prime-agent': 'prime-agent', + copilot: 'copilot' +} + +/** Copilot's own id shape; anything else must not be joined onto a root path. */ +const COPILOT_SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/ + export type SessionSummarySessionIdentity = { agentType?: AgentType providerSession?: AgentProviderSessionMetadata @@ -43,20 +61,15 @@ export async function readSessionSummaryTranscriptEvents(args: { if (!providerSession?.id || !agentType) { return null } - const transcriptAgent = resolveNativeChatTranscriptAgent(agentType) - if (!transcriptAgent) { + const source = await resolveTranscriptSource(agentType, providerSession, signal) + if (!source) { return null } - const filePath = await resolveSessionFilePath( - agentType, - providerSession.id, - providerSession.transcriptPath ? { transcriptPath: providerSession.transcriptPath } : {}, - signal - ) - if (!filePath) { + const filePath = source.path + const stats = await stat(filePath) + if (!stats.isFile()) { return null } - const stats = await stat(filePath) const file: FileWithMtime = { path: filePath, mtimeMs: stats.mtimeMs, @@ -64,7 +77,7 @@ export async function readSessionSummaryTranscriptEvents(args: { sizeBytes: stats.size } const candidate: SessionFileCandidate = { - agent: CANDIDATE_AGENTS[transcriptAgent], + agent: source.parser, file, codexHome: null } @@ -96,3 +109,56 @@ export async function readSessionSummaryTranscriptEvents(args: { unregister() } } + +/** + * The transcript file to fold and the AI Vault parser that decodes it. + * + * Native-chat agents keep the native-chat resolver, which owns the hook-path + * handoff, Claude/Codex profile roots and WSL host translation. Agents outside + * that set are only locatable by the path their own hook reports, or — Copilot — + * by the session id its hook names a directory with. + */ +async function resolveTranscriptSource( + agentType: AgentType, + providerSession: AgentProviderSessionMetadata, + signal: AbortSignal +): Promise<{ path: string; parser: AiVaultAgent } | null> { + const transcriptAgent = resolveNativeChatTranscriptAgent(agentType) + if (transcriptAgent) { + const path = await resolveSessionFilePath( + agentType, + providerSession.id, + providerSession.transcriptPath ? { transcriptPath: providerSession.transcriptPath } : {}, + signal + ) + return path ? { path, parser: CANDIDATE_AGENTS[transcriptAgent] } : null + } + + const parser = HOOK_RESOLVED_TRANSCRIPT_AGENTS[agentType] + if (!parser) { + return null + } + // A hook-named file is the exact artifact the CLI is writing. Copilot's hook + // carries no path at all, so its session id names the directory instead. + const hookPath = providerSession.transcriptPath?.trim() + if (hookPath && (await isReadableFile(hookPath))) { + return { path: hookPath, parser } + } + return agentType === 'copilot' ? copilotTranscriptSource(providerSession.id) : null +} + +function copilotTranscriptSource(sessionId: string): { path: string; parser: AiVaultAgent } | null { + if (!COPILOT_SESSION_ID_PATTERN.test(sessionId)) { + return null + } + return { path: join(COPILOT_SESSIONS_DIR, sessionId, 'events.jsonl'), parser: 'copilot' } +} + +/** A path the hook named is only usable when this host can actually open it. */ +async function isReadableFile(path: string): Promise { + try { + return (await stat(path)).isFile() + } catch { + return false + } +} diff --git a/src/renderer/src/components/activity/session-summary-presentation.test.ts b/src/renderer/src/components/activity/session-summary-presentation.test.ts index b37e59975446..21e8863fe2fc 100644 --- a/src/renderer/src/components/activity/session-summary-presentation.test.ts +++ b/src/renderer/src/components/activity/session-summary-presentation.test.ts @@ -43,6 +43,48 @@ describe('resolveSessionSummaryHeaderState', () => { expect(resolveSessionSummaryHeaderState(null)).toEqual({ hint: 'Up to date', folding: false }) }) + it('names the missing transcript instead of claiming to be up to date', () => { + const state = resolveSessionSummaryHeaderState(snapshot({ status: 'unavailable' })) + + expect(state).toEqual({ hint: 'No readable transcript', folding: false }) + }) + + it('names the failure instead of claiming to be up to date', () => { + const state = resolveSessionSummaryHeaderState(snapshot({ status: 'failed' })) + + expect(state).toEqual({ hint: 'Could not summarize this session', folding: false }) + }) + + // A failed fold over a session already folded once leaves the last good ledger + // on screen, so the header keeps counting what never got folded. + it('counts the backlog when a fold failed over an existing ledger', () => { + const state = resolveSessionSummaryHeaderState( + snapshot({ + status: 'failed', + ledger: { + goal: null, + plan: [], + inProgress: null, + done: [], + blockers: [], + decisions: [], + timeline: [], + foldedThrough: 4, + foldedAt: 1 + }, + facts: { + paneKey: 'pane-1', + prompt: 'Refactor the parser', + messageCount: 9, + foldedThrough: 4, + backlogCount: 5 + } + }) + ) + + expect(state).toEqual({ hint: '5 events to catch up on', folding: false }) + }) + it('reads as up to date when facts have no backlog', () => { const state = resolveSessionSummaryHeaderState( snapshot({ diff --git a/src/renderer/src/components/activity/session-summary-presentation.ts b/src/renderer/src/components/activity/session-summary-presentation.ts index 593f6a5e4db9..595ac404c674 100644 --- a/src/renderer/src/components/activity/session-summary-presentation.ts +++ b/src/renderer/src/components/activity/session-summary-presentation.ts @@ -15,6 +15,23 @@ export function resolveSessionSummaryHeaderState( if (snapshot?.fold.running) { return { hint: translate('activity.sessionSummary.updating', 'Updating…'), folding: true } } + // Why: without a ledger there is nothing on screen, so the hint has to name + // why. Falling through to "Up to date" told the reader a session with no + // readable transcript was fully summarized. + if (!snapshot?.ledger) { + if (snapshot?.status === 'failed') { + return { + hint: translate('activity.sessionSummary.failedHint', 'Could not summarize this session'), + folding: false + } + } + if (snapshot?.status === 'unavailable') { + return { + hint: translate('activity.sessionSummary.noTranscriptHint', 'No readable transcript'), + folding: false + } + } + } const backlogCount = snapshot?.facts?.backlogCount ?? 0 if (backlogCount > 0) { return { diff --git a/src/renderer/src/components/right-sidebar/SessionSummaryPane.test.tsx b/src/renderer/src/components/right-sidebar/SessionSummaryPane.test.tsx index 2c46c87bd9ec..abd4f0d66d36 100644 --- a/src/renderer/src/components/right-sidebar/SessionSummaryPane.test.tsx +++ b/src/renderer/src/components/right-sidebar/SessionSummaryPane.test.tsx @@ -89,7 +89,7 @@ describe('SessionSummaryPane', () => { expect(markup).toContain('Session summary') expect(markup).toContain('Up to date') - expect(markup).toContain('lucide-info') + expect(markup).toContain('lucide-sticky-note') expect(markup).toContain('Wiring the right-sidebar tab') expect(markup).toContain('Moved the ledger view out of the activity strip') }) diff --git a/src/renderer/src/components/right-sidebar/SessionSummaryPane.tsx b/src/renderer/src/components/right-sidebar/SessionSummaryPane.tsx index 9ba0705a767a..638beaf4ce8a 100644 --- a/src/renderer/src/components/right-sidebar/SessionSummaryPane.tsx +++ b/src/renderer/src/components/right-sidebar/SessionSummaryPane.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Info } from 'lucide-react' +import { Info, StickyNote } from 'lucide-react' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import { SessionSummaryLedgerView } from '@/components/activity/session-summary-ledger-view' @@ -21,7 +21,7 @@ function SessionSummarySubjectBody({ return ( <>
-