Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions docs/reference/session-summary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>/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
Expand All @@ -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 (`<binary> --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
Expand Down
2 changes: 1 addition & 1 deletion src/main/ai-vault/session-scanner-agent-sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
)
Expand Down
13 changes: 13 additions & 0 deletions src/main/session-summary/session-summary-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(() => {}))

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.
Expand Down
6 changes: 6 additions & 0 deletions src/main/session-summary/session-summary-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
177 changes: 177 additions & 0 deletions src/main/session-summary/session-summary-transcript-source.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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()
})
})
88 changes: 77 additions & 11 deletions src/main/session-summary/session-summary-transcript-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'

Expand All @@ -27,6 +29,22 @@ const CANDIDATE_AGENTS: Record<NativeChatTranscriptAgent, AiVaultAgent> = {
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<Record<AgentType, AiVaultAgent>> = {
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
Expand All @@ -43,28 +61,23 @@ 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,
modifiedAt: stats.mtime.toISOString(),
sizeBytes: stats.size
}
const candidate: SessionFileCandidate = {
agent: CANDIDATE_AGENTS[transcriptAgent],
agent: source.parser,
file,
codexHome: null
}
Expand Down Expand Up @@ -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<boolean> {
try {
return (await stat(path)).isFile()
} catch {
return false
}
}
Loading