From f540ff9227ce65517bd50b569a1c6562d6c289cf Mon Sep 17 00:00:00 2001 From: Nathan Plesnicher Date: Thu, 8 Oct 2026 20:45:47 -0700 Subject: [PATCH 1/3] fix(session-summary): fold Pi and Copilot sessions, and report a fold's real state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel could not summarize the sessions this reported from, and when it could not it said it was up to date. Both are fixed here, because either alone still leaves a reader with an empty pane and a false assurance. Transcripts the fold could never resolve. `readSessionSummaryTranscriptEvents` resolved a file only through the native-chat resolver, which names Claude/OpenClaude, Codex, Grok, OMP and OpenCode — so a Pi or Copilot session got `null`, the service settled on `unavailable`, and the pane kept the empty state for the life of the install. On the machine this was reported from that is 462 Pi sessions and 16 Copilot ones against 11 OpenCode. Both now resolve: - Pi and Prime Agent post `session_file` on every hook, and `agent-session-resume.ts` only returns their provider session at all when that path exists, so the hook's path *is* the transcript and there is nothing to search for. - Copilot reports no path, but its `session_id` is the name of its session directory, so the file is `$COPILOT_HOME/session-state//events.jsonl`. The id is shape checked, because joining a hook string onto a root path unchecked is a traversal; a hook path that exists is preferred, and a directory is never mistaken for a transcript. - `COPILOT_SESSIONS_DIR` is exported rather than re-derived, so the AI Vault scanner and this reader cannot drift apart on where Copilot keeps sessions. Everything downstream already worked: `parseAgentSessionFile` dispatches on `candidate.agent`, and both parsers publish into the consumer channel, so the fold needed only a path and a parser name. Agents with neither still return null, and the pane says so. A fold reported "Up to date" while it was running, and after it failed. Two causes: - `fold.running` was set only once a chunk came back from the model, so the entire first fold — the whole window a reader actually watches — rendered "Up to date" over a loading skeleton, and a fold that could not start never set it at all. It is now set when the fold starts. - The header hint ignored `status`, so `unavailable` and `failed` both fell through to "Up to date". They now name what happened. A failed fold over a session that already has a good ledger keeps counting its backlog instead, because that ledger is still on screen and the events behind it are genuinely unfolded. Verified: `pnpm tc` clean; 2150 tests across session-summary, activity, right-sidebar, settings and text-generation; `check:code-quality:changed`, oxlint and the localization verifiers pass. End to end on this machine with the reporter's own settings (`sessionSummaryAi` = pi/deepseek-flash) and a real Pi transcript, the service reaches `ready` in 7.4s with a populated ledger, which it could not do before this change. --- docs/reference/session-summary.md | 21 ++- .../ai-vault/session-scanner-agent-sources.ts | 2 +- .../session-summary-service.test.ts | 13 ++ .../session-summary-service.ts | 6 + .../session-summary-transcript-source.test.ts | 177 ++++++++++++++++++ .../session-summary-transcript-source.ts | 88 +++++++-- .../session-summary-presentation.test.ts | 42 +++++ .../activity/session-summary-presentation.ts | 17 ++ src/renderer/src/i18n/locales/en.json | 2 + 9 files changed, 355 insertions(+), 13 deletions(-) create mode 100644 src/main/session-summary/session-summary-transcript-source.test.ts diff --git a/docs/reference/session-summary.md b/docs/reference/session-summary.md index e4bd30d59644..c2e0691cc755 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 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/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f4645e3d761d..db68316a14d9 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19970,7 +19970,9 @@ "upToDate": "Up to date", "label": "Session summary", "failed": "Could not summarize this session.", + "failedHint": "Could not summarize this session", "unavailable": "No readable transcript for this session yet.", + "noTranscriptHint": "No readable transcript", "unavailableInWindow": "Session summaries are not available in this window.", "notFocused": "Focus an agent session to see its summary." } From b81860520b8da25943530f5a00bc4ced9df69988 Mon Sep 17 00:00:00 2001 From: Nathan Plesnicher Date: Thu, 8 Oct 2026 20:47:55 -0700 Subject: [PATCH 2/3] fix(copilot): discover the CLI's model catalog instead of pinning a list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot's entries in the agent model picker were a hand-written array frozen at the CLI build the list was read from. It ended at `gpt-5.5` and `claude-opus-4.7` while the installed CLI offers 31 ids — `claude-sonnet-5.5`, `claude-opus-5.5`, `gpt-6.1-sol`, `gpt-6-luna`, `grok-4.7`, `kimi-k3` among them — so the picker could offer retired models and could never offer current ones. Every model released after that array was written was unreachable. The CLI publishes no `models` subcommand (`copilot help models` is an unknown topic, and the interactive picker is policy-filtered per account), but `copilot help config` documents the `model` setting as the list of ids it accepts. That list is the CLI's own catalog, so probing it is what keeps the picker current. The parser reads only that setting's bullet list, stops at the next documented setting, and adds `auto` back: `--model` documents it as "let Copilot pick automatically" while the setting's value list omits it. Two things that would otherwise regress: - Model discovery replaces the spec's list wholesale rather than merging metadata into it, so the parsed models carry the same low/medium/high/xhigh levels and `low` default the static entries did. Without that, every Copilot model would lose its effort control. - The array stays as `COPILOT_FALLBACK_MODELS` for a machine with no `copilot` on PATH, refreshed to the catalog the CLI ships now. A help format change parses to nothing, which falls back to it and is not cached, so the next probe tries again instead of retiring the probe for the life of the install. Also spells the effort flag the documented way. The earlier Copilot effort commit relied on `--effort` being an alias of `--reasoning-effort`; that still works, but it is absent from both `--help` and the CLI's own shell completion, so a fold would break silently if the alias were dropped. Verified with a live one-shot prompt, not only with `--help`. Copilot's spec is the Source Control AI catalog as well, so commit-message model selection gets the same live list. The probe lives in its own module because `commit-message-model-parsers.ts` was already at its `max-lines` cap — extracted, not suppressed. Verified: the real main-process discovery path (`discoverCommitMessageModelsLocal`) spawns `copilot help config` and returns 31 models with `catalogOrigin: 'probe'`, carrying labels and effort levels; `pnpm tc` clean; the agent-spec, session-summary-ai, ai-generation-settings and text-generation suites pass. --- docs/reference/session-summary.md | 7 +- .../src/lib/ai-generation-settings.test.ts | 5 +- src/shared/commit-message-agent-spec.test.ts | 74 ++++++++--- src/shared/commit-message-agent-spec.ts | 8 +- .../commit-message-agent-specs-secondary.ts | 115 +++-------------- src/shared/copilot-model-list-probe.ts | 119 ++++++++++++++++++ src/shared/session-summary-ai.test.ts | 19 ++- 7 files changed, 223 insertions(+), 124 deletions(-) create mode 100644 src/shared/copilot-model-list-probe.ts diff --git a/docs/reference/session-summary.md b/docs/reference/session-summary.md index c2e0691cc755..22f8c912e1fe 100644 --- a/docs/reference/session-summary.md +++ b/docs/reference/session-summary.md @@ -93,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/renderer/src/lib/ai-generation-settings.test.ts b/src/renderer/src/lib/ai-generation-settings.test.ts index 78bb4d3013f5..fe6a79ce7edb 100644 --- a/src/renderer/src/lib/ai-generation-settings.test.ts +++ b/src/renderer/src/lib/ai-generation-settings.test.ts @@ -110,8 +110,9 @@ describe('resolveAiGenerationSelection', () => { }) describe('getCommitMessageAgentCapability', () => { - it('reports Pi as dynamic so the pane knows to probe', () => { + it('reports the agents whose model list is probed so the pane knows to probe', () => { expect(getCommitMessageAgentCapability('pi')?.modelSource).toBe('dynamic') - expect(getCommitMessageAgentCapability('copilot')?.modelSource).toBe('static') + expect(getCommitMessageAgentCapability('copilot')?.modelSource).toBe('dynamic') + expect(getCommitMessageAgentCapability('amp')?.modelSource).toBe('static') }) }) diff --git a/src/shared/commit-message-agent-spec.test.ts b/src/shared/commit-message-agent-spec.test.ts index 18635420b4cc..54aa4b9539a2 100644 --- a/src/shared/commit-message-agent-spec.test.ts +++ b/src/shared/commit-message-agent-spec.test.ts @@ -12,6 +12,7 @@ import { } from './commit-message-agent-spec' import { COMMIT_MESSAGE_MODEL_JSON_STRUCTURE_LIMITS, + OPENAI_THINKING_LEVELS, parseAntigravityModels, parseClaudeModels, parseCodexModels, @@ -19,6 +20,7 @@ import { parseLineModels, parsePiModels } from './commit-message-model-parsers' +import { COPILOT_MODEL_DISCOVERY, parseCopilotModels } from './copilot-model-list-probe' afterEach(() => { vi.restoreAllMocks() @@ -119,28 +121,64 @@ describe('COMMIT_MESSAGE_AGENT_SPECS', () => { } }) - it('lists Copilot hosted CLI models even when account policy filters the picker', () => { - expect(COMMIT_MESSAGE_AGENT_SPECS.copilot?.defaultModelId).toBe('gpt-5.4') - expect(COMMIT_MESSAGE_AGENT_SPECS.copilot?.models.map((m) => m.id)).toEqual([ + it('probes Copilot for its hosted CLI catalog instead of pinning a snapshot', () => { + const copilot = COMMIT_MESSAGE_AGENT_SPECS.copilot! + + // The CLI ships no `models` subcommand; `help config` is the catalog it + // publishes, and it is what keeps this list current. + expect(copilot.modelSource).toBe('dynamic') + expect(copilot.modelDiscovery?.binary).toBe('copilot') + expect(copilot.modelDiscovery?.args).toEqual(['help', 'config']) + expect(copilot.defaultModelId).toBe('gpt-5.4') + // Models released after this fallback was frozen still resolve, because the + // probe reports them rather than the spec's own list. + expect(copilot.modelDiscovery).toEqual(COPILOT_MODEL_DISCOVERY) + expect(copilot.modelDiscovery?.parse('`model`: AI model to use.\n - "gpt-9"\n')).toEqual([ + { id: 'auto', label: 'Auto' }, + { + id: 'gpt-9', + label: 'GPT 9', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + } + ]) + }) + + it('lists Copilot models from its static fallback when the CLI cannot be probed', () => { + const ids = COMMIT_MESSAGE_AGENT_SPECS.copilot!.models.map((model) => model.id) + + expect(ids[0]).toBe('auto') + expect(ids).toContain('gpt-5.4') + expect(ids.length).toBeGreaterThan(20) + // Never re-offer a model the CLI has dropped. + expect(ids).not.toContain('gpt-4.1') + }) + + it('reads Copilot model ids only from the `model` setting block', () => { + const stdout = [ + ' `defaultMode`: mode for new sessions.', + ' - "interactive"', + ' `model`: AI model to use for Copilot CLI.', + ' - "claude-opus-5.5"', + ' - "gpt-6-luna"', + ' `contextTier`: context window tier.', + ' - "default"', + '' + ].join('\n') + + expect(parseCopilotModels(stdout).map((model) => model.id)).toEqual([ 'auto', - 'claude-haiku-4.5', - 'claude-sonnet-4.5', - 'claude-sonnet-4.6', - 'claude-opus-4.5', - 'claude-opus-4.6', - 'claude-opus-4.6-fast', - 'claude-opus-4.7', - 'gpt-4.1', - 'gpt-5-mini', - 'gpt-5.2', - 'gpt-5.2-codex', - 'gpt-5.3-codex', - 'gpt-5.4', - 'gpt-5.4-mini', - 'gpt-5.5' + 'claude-opus-5.5', + 'gpt-6-luna' ]) }) + it('parses nothing when the Copilot help format changes', () => { + // An empty parse is the safe failure: the spec's fallback list is used and + // the result is never cached, so the next probe tries again. + expect(parseCopilotModels('Usage: copilot config [OPTIONS]')).toEqual([]) + }) + it('treats disabled default agents as unavailable for implicit Source Control AI choices', () => { expect(resolveCommitMessageAgentChoice(null, 'codex', ['codex'])).toBe('claude') expect(resolveCommitMessageAgentChoice(null, null, ['claude'])).toBeNull() diff --git a/src/shared/commit-message-agent-spec.ts b/src/shared/commit-message-agent-spec.ts index bd47acc5bac9..684771decf78 100644 --- a/src/shared/commit-message-agent-spec.ts +++ b/src/shared/commit-message-agent-spec.ts @@ -16,6 +16,7 @@ import { parsePiModels, withOpenAiThinking } from './commit-message-model-parsers' +import { COPILOT_FALLBACK_MODELS, COPILOT_MODEL_DISCOVERY } from './copilot-model-list-probe' // Why: this file is the source of truth for non-interactive agent invocation // (commit-message generation). It is intentionally separate from @@ -127,10 +128,13 @@ export const COMMIT_MESSAGE_AGENT_SPECS: Partial CommitMessageModel[] parseAntigravityModels: (stdout: string) => CommitMessageModel[] parseLineModels: (stdout: string) => CommitMessageModel[] + /** Copilot's probe and the catalog a machine without the binary falls back to. */ + copilotCatalog: { + modelDiscovery: NonNullable + fallbackModels: CommitMessageModel[] + } } export function buildSecondaryCommitMessageAgentSpecs({ BASIC_THINKING_LEVELS, - OPENAI_THINKING_LEVELS, parseCursorModels, parseAntigravityModels, - parseLineModels + parseLineModels, + copilotCatalog }: SecondaryAgentSpecDeps): Partial> { return { amp: { @@ -174,102 +178,17 @@ export function buildSecondaryCommitMessageAgentSpecs({ '--no-custom-instructions', '--model', model, - ...(thinkingLevel ? ['--effort', thinkingLevel] : []) - ], - modelSource: 'static', - // Why: Copilot CLI's picker is policy-filtered per account/org. Keep the - // full hosted CLI catalog here so users can select models enabled for them. - models: [ - { id: 'auto', label: 'Auto' }, - { - id: 'claude-haiku-4.5', - label: 'Claude Haiku 4.5', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'claude-sonnet-4.5', - label: 'Claude Sonnet 4.5', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'claude-sonnet-4.6', - label: 'Claude Sonnet 4.6', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'claude-opus-4.5', - label: 'Claude Opus 4.5', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'claude-opus-4.6', - label: 'Claude Opus 4.6', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'claude-opus-4.6-fast', - label: 'Claude Opus 4.6 Fast', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'claude-opus-4.7', - label: 'Claude Opus 4.7', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'gpt-4.1', - label: 'GPT-4.1' - }, - { - id: 'gpt-5-mini', - label: 'GPT-5 Mini', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'gpt-5.2', - label: 'GPT-5.2', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'gpt-5.2-codex', - label: 'GPT-5.2 Codex', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'gpt-5.3-codex', - label: 'GPT-5.3 Codex', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'gpt-5.4', - label: 'GPT-5.4', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'gpt-5.4-mini', - label: 'GPT-5.4 Mini', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'gpt-5.5', - label: 'GPT-5.5', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - } + ...(thinkingLevel ? ['--reasoning-effort', thinkingLevel] : []) ], + modelSource: 'dynamic', + // Why: the CLI has no `models` subcommand, but `help config` documents the + // `model` setting's accepted ids — the CLI's own catalog, so it keeps up + // with models released after this file was written. (The interactive picker + // is policy-filtered per account/org; this is the catalog behind it.) + modelDiscovery: copilotCatalog.modelDiscovery, + // Used verbatim only when the probe cannot run — no `copilot` on PATH — and + // kept out of the cache, so a later probe replaces it. + models: copilotCatalog.fallbackModels, defaultModelId: 'gpt-5.4' }, antigravity: { diff --git a/src/shared/copilot-model-list-probe.ts b/src/shared/copilot-model-list-probe.ts new file mode 100644 index 000000000000..cdb6e06f631e --- /dev/null +++ b/src/shared/copilot-model-list-probe.ts @@ -0,0 +1,119 @@ +import type { CommitMessageAgentSpec, CommitMessageModel } from './commit-message-agent-spec' +import { OPENAI_THINKING_LEVELS } from './commit-message-model-parsers' +import { labelFromModelId } from './model-id-label' + +// Why: the Copilot CLI has no `models` subcommand, but `help config` documents the +// `model` setting's accepted ids — the CLI's own catalog, so the list keeps up with +// models released after this file was written. Its interactive picker is +// policy-filtered per account/org; this is the catalog behind it. +const COPILOT_MODEL_LIST_ARGS = ['help', 'config'] + +const COPILOT_AUTO_MODEL_ID = 'auto' + +/** + * Parses `copilot help config`: the `model` setting's heading, then its accepted + * ids as quoted bullets, ended by the next documented setting. + * + * A format change parses to nothing, which falls back to `COPILOT_FALLBACK_MODELS` + * and is never cached, so the next probe tries again. + */ +export function parseCopilotModels(stdout: string): CommitMessageModel[] { + const ids: string[] = [] + let inModelSetting = false + for (const rawLine of stdout.split(/\r\n|\r|\n/)) { + if (/^\s*`model`\s*:/.test(rawLine)) { + inModelSetting = true + continue + } + if (!inModelSetting) { + continue + } + // The next documented setting ends this one's value list. + if (/^\s*`[^`]+`\s*:/.test(rawLine)) { + break + } + const quoted = /^\s+-\s+"([^"]+)"\s*$/.exec(rawLine) + const id = quoted?.[1]?.trim() + if (id) { + ids.push(id) + } + } + return copilotModelsFromIds(ids) +} + +/** + * Copilot publishes no display names, so a label is derived from the id like every + * other probed model. Every model but `auto` takes a reasoning effort: + * `--reasoning-effort` is a CLI-level flag, so the picker offers the documented + * subset for each one rather than guessing per model. + */ +function copilotModelsFromIds(ids: readonly string[]): CommitMessageModel[] { + // Why: `--model` documents `auto` as "let Copilot pick", but the setting's value + // list omits it, so the CLI's own routing choice is added back ahead of the ids. + const withAuto = + ids.length > 0 && !ids.includes(COPILOT_AUTO_MODEL_ID) ? [COPILOT_AUTO_MODEL_ID, ...ids] : ids + const seen = new Set() + return withAuto + .filter((id) => { + if (!id || seen.has(id)) { + return false + } + seen.add(id) + return true + }) + .map((id) => + id === COPILOT_AUTO_MODEL_ID + ? { id, label: 'Auto' } + : { + id, + label: labelFromModelId(id), + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + } + ) +} + +/** Probe descriptor for the copilot spec; owns the argv it spawns. */ +export const COPILOT_MODEL_DISCOVERY: NonNullable = { + binary: 'copilot', + args: COPILOT_MODEL_LIST_ARGS, + parse: parseCopilotModels +} + +/** + * What a machine with no `copilot` binary falls back to, frozen at the catalog the + * CLI shipped when this list was last read. The live catalog comes from the probe, + * so this only has to be a usable starting point — never authoritative. + */ +export const COPILOT_FALLBACK_MODELS: CommitMessageModel[] = copilotModelsFromIds([ + 'claude-sonnet-5.5', + 'claude-sonnet-5', + 'claude-fable-5.1', + 'claude-fable-5', + 'claude-opus-5.5', + 'claude-opus-5', + 'claude-opus-4.8', + 'claude-opus-4.8-fast', + 'claude-sonnet-4.6', + 'claude-haiku-5.5', + 'claude-haiku-4.5', + 'gpt-6.1-sol', + 'gpt-6-sol', + 'gpt-6-luna', + 'gpt-6-astra', + 'gpt-5.6-sol', + 'gpt-5.6-terra', + 'gpt-5.6-luna', + 'gpt-5.5', + 'gpt-5.4', + 'gpt-5.4-mini', + 'gpt-5.3-codex', + 'gpt-5-mini', + 'mai-code-1.1-flash', + 'gemini-3.8-flash', + 'gemini-3.7-flash', + 'grok-4.7', + 'grok-4.6', + 'grok-4.5', + 'kimi-k3' +]) diff --git a/src/shared/session-summary-ai.test.ts b/src/shared/session-summary-ai.test.ts index e3ae756f152a..845459275fee 100644 --- a/src/shared/session-summary-ai.test.ts +++ b/src/shared/session-summary-ai.test.ts @@ -63,17 +63,30 @@ describe('resolveSessionSummaryAiParams', () => { }) it('falls back to the default model when a static agent no longer offers the stored one', () => { - const copilot = getCommitMessageAgentSpec('copilot') + const amp = getCommitMessageAgentSpec('amp') + const resolved = resolveSessionSummaryAiParams( + input({ + sessionSummaryAi: config({ + agentId: 'amp', + selectedModelByAgent: { amp: 'amp-9000' } + }) + }) + ) + + expect(resolved?.model).toBe(amp?.defaultModelId) + }) + + it('passes an unknown model through for an agent whose list is probed', () => { const resolved = resolveSessionSummaryAiParams( input({ sessionSummaryAi: config({ agentId: 'copilot', - selectedModelByAgent: { copilot: 'copilot-9000' } + selectedModelByAgent: { copilot: 'gpt-9' } }) }) ) - expect(resolved?.model).toBe(copilot?.defaultModelId) + expect(resolved?.model).toBe('gpt-9') }) it('falls back to the model default when the stored effort is not offered', () => { From f6adc4ca029dc36a49eebeb1e11fd4dca33e1a34 Mon Sep 17 00:00:00 2001 From: Nathan Plesnicher Date: Thu, 8 Oct 2026 20:48:38 -0700 Subject: [PATCH 3/3] fix(session-summary): use a sticky-note icon for the panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Summary tab and the pane header both used a generic "info" circle, which is the icon the app already uses for informational notices — it says nothing about a written brief, and it made the tab read like a warning rather than a place to catch up on a session. Both now use a sticky note, the same icon the sidebar already uses for a worktree's notes. SSR test updated from `lucide-info` to `lucide-sticky-note`. --- .../src/components/right-sidebar/SessionSummaryPane.test.tsx | 2 +- .../src/components/right-sidebar/SessionSummaryPane.tsx | 4 ++-- .../right-sidebar/use-right-sidebar-activity-items.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) 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 ( <>
-