diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6ac231d81..b0d4261d6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added generated session summaries to the resume picker, so `/resume` and `atomic -r` show what a conversation was about in a dedicated summary column beside the session name or first message. Atomic writes a one-line summary once the agent goes idle, reusing the session's existing model, credentials, stream function, and retry policy with a dedicated one-line prompt and no reasoning request; the stored line has its whitespace collapsed and length clamped so a long response cannot break picker layout. Each summary is persisted as a `session_summary` session entry anchored to the last user/assistant message it describes, and the picker shows it only while that message is still the newest one — a later message, or a later branch summary, retires it and the summary column shows "No summary available." instead, exactly as it does when a summary has not been generated, has failed, or is still in flight; the session name and first message are never displaced. Summaries are matched by picker search, and their token usage is counted toward session usage totals and the footer. Generation is best-effort and invisible: it is skipped for very short sessions, workflow stage sessions, and `--print`/JSON modes, it never blocks or delays a turn, it is cancelled when the next prompt starts or the session shuts down, and a slow request that outlives the conversation discards its own result rather than persisting a stale summary. Set `sessionSummary.enabled` to `false` to disable it ([#1033](https://github.com/bastani-inc/atomic/issues/1033)). + ## [0.9.13-alpha.2] - 2026-08-12 ### Breaking Changes diff --git a/packages/coding-agent/docs/session-format.md b/packages/coding-agent/docs/session-format.md index d31c85a89..a2303abfc 100644 --- a/packages/coding-agent/docs/session-format.md +++ b/packages/coding-agent/docs/session-format.md @@ -307,6 +307,20 @@ Session metadata (e.g., user-defined display name). Set via `/name`, `--name` / The session name is displayed in the session selector (`/resume`) instead of the first message when set. +### SessionSummaryEntry + +A generated one-line description of the session, written automatically once the agent goes idle and shown in the session selector. Never sent to the LLM. + +```json +{"type":"session_summary","id":"l2m3n4o5","parentId":"k1l2m3n4","timestamp":"2024-12-03T14:36:00.000Z","summary":"Refactored auth module token refresh and added retry tests","summarizedThroughId":"j0k1l2m3","usage":{"input":812,"output":21}} +``` + +- `summary`: The stored line. Whitespace is collapsed and length is clamped before writing. +- `summarizedThroughId`: Entry ID of the last user/assistant message the summary covers. The selector shows the summary only while this is still the newest conversation message; anything newer makes it stale and the selector falls back to the session name or first message. Tool results do not count. +- `usage`: Optional token usage from the model call, counted toward session usage totals. + +A `branch_summary` written after a `session_summary` also retires it, because the branch it described was abandoned. + ## Tree Structure Entries form a tree: @@ -420,6 +434,7 @@ for (const stage of stages.filter((session) => session.internal)) { - `appendCompaction(compactedText, firstKeptEntryId, tokensBefore, details)` - Add a durable verbatim-line compaction boundary; pass `null` when no pre-boundary message is retained - `appendCustomEntry(customType, data?)` - Extension state (not in context) - `appendSessionInfo(name)` - Set session display name +- `appendSessionSummary(summary, summarizedThroughId, usage?)` - Store a generated resume-picker summary, anchored to the message it describes - `appendCustomMessageEntry(customType, content, display, details?)` - Extension message (in context) - `appendLabelChange(targetId, label)` - Set/clear label diff --git a/packages/coding-agent/docs/sessions.md b/packages/coding-agent/docs/sessions.md index 6f250a760..b3fb69707 100644 --- a/packages/coding-agent/docs/sessions.md +++ b/packages/coding-agent/docs/sessions.md @@ -57,6 +57,14 @@ In the picker you can: When available, Atomic uses the `trash` CLI for deletion instead of permanently removing files. +### Session summaries + +Each row shows a short generated description of what the session was about in its own column, beside the session name or first message, so you can recognize a conversation without opening it. Atomic writes one after the agent goes idle, using the model the session is already configured with, and stores it in the session file as a `session_summary` entry. + +A summary describes the conversation up to a specific message. Once a newer message arrives it is considered stale and the summary column shows "No summary available." instead — the same placeholder you get when a summary has not been generated yet, could not be generated, or is still in flight. The session name and first message are never displaced by a summary, and the column only appears once at least one listed session has a summary. Summaries are also searchable along with the rest of the session text. + +Generation is best-effort and never blocks a turn: it is skipped for very short sessions, for workflow stage sessions, and in `--print` and JSON modes, it is cancelled when you send the next message or quit, and its failures are silent. Set `sessionSummary.enabled` to `false` to turn it off entirely. + The picker opens instantly: its header, search field, and loading indicator paint on the first frame, then sessions are discovered and parsed off the terminal's UI loop. Large session directories are scanned in cooperative batches and a single very large transcript is parsed in yielding chunks, so search, navigation, and cancel stay responsive and no individual session can freeze the picker while it loads. Closing the picker cancels any in-flight scan and discards stale results, so a slow load that finishes after you leave never updates the list. ### Internal (workflow) sessions diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 09e0ebb31..9dd6a658c 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -196,6 +196,12 @@ The model emits numbered line ranges only; Atomic reconstructs retained text mec | `branchSummary.reserveTokens` | number | `16384` | Tokens reserved for branch summarization | | `branchSummary.skipPrompt` | boolean | `false` | Skip "Summarize branch?" prompt on `/tree` navigation (defaults to no summary) | +### Session Summary + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `sessionSummary.enabled` | boolean | `true` | Generate a one-line summary of each session for the `/resume` picker once the agent goes idle | + ### Retry | Setting | Type | Default | Description | diff --git a/packages/coding-agent/src/core/agent-session-events.ts b/packages/coding-agent/src/core/agent-session-events.ts index 3e25d28e8..b25411afe 100644 --- a/packages/coding-agent/src/core/agent-session-events.ts +++ b/packages/coding-agent/src/core/agent-session-events.ts @@ -291,6 +291,7 @@ export async function _processAgentEvent(this: AgentSession, event: AgentEvent): this._resolveRetry(); this._contextOverflowUnresolved = false; await this._checkCompaction(msg); + // Compaction owns context overflow first. Only once it is disabled, fails, // or reports the overflow unresolved may the chain spend a candidate on a // larger-context model, so a compactable first overflow costs nothing. @@ -306,6 +307,12 @@ export async function _processAgentEvent(this: AgentSession, event: AgentEvent): if (restoreAfterTurn && this._pendingPostCompactionContinuation === undefined) { if (typeof this._restoreFallbackModel === "function") await this._restoreFallbackModel(); } + // Launched last so the fallback lifecycle wins: a switch above returns before this line, and + // the restore has already put `this.model` back to the user's selection, which the launch + // reads synchronously before it parks on waitForIdle(). Guarded like the fallback methods + // above because the fallback suites drive this function on a synthetic session. + if (event.type === "agent_end" && typeof this._maybeGenerateSessionSummary === "function") + void this._maybeGenerateSessionSummary(); } } @@ -502,6 +509,14 @@ export function _disconnectFromAgent(this: AgentSession): void { */ export function dispose(this: AgentSession): void { + // Terminal and idempotent: callers legitimately dispose more than once (an explicit dispose + // followed by a harness teardown), and the steps below are not all safe to repeat. + if (this._disposed) return; + // Summary work queued before its AbortController exists cannot be reached by + // abortSessionSummary(), so disposal is recorded as state that every checkpoint consults. + this._disposed = true; + // A background summary must never keep the process alive past shutdown. + this.abortSessionSummary(); // Fail closed while protected input remains queued, or flush a consumed // reconciliation before invalidation can discard its recovery state. prepareProtectedStreamingCustomMessagesForDisposal(this); diff --git a/packages/coding-agent/src/core/agent-session-export.ts b/packages/coding-agent/src/core/agent-session-export.ts index 20416958c..913da3b61 100644 --- a/packages/coding-agent/src/core/agent-session-export.ts +++ b/packages/coding-agent/src/core/agent-session-export.ts @@ -19,7 +19,9 @@ export function getSessionStats(this: AgentSession): SessionStats { let toolCalls = 0; const totals = createUsageTotals(); for (const entry of this.sessionManager.getEntries()) { - if (entry.type === "branch_summary" && entry.usage) addUsageToTotals(totals, entry.usage); + if ((entry.type === "branch_summary" || entry.type === "session_summary") && entry.usage) { + addUsageToTotals(totals, entry.usage); + } if (entry.type !== "message") continue; totalMessages++; const message = entry.message; diff --git a/packages/coding-agent/src/core/agent-session-methods.ts b/packages/coding-agent/src/core/agent-session-methods.ts index 2a426d06c..55f48e462 100644 --- a/packages/coding-agent/src/core/agent-session-methods.ts +++ b/packages/coding-agent/src/core/agent-session-methods.ts @@ -231,6 +231,8 @@ export interface AgentSessionMethodSurface extends AgentSessionQueuePauseControl compact(options?: Partial): Promise; abortCompaction(): void; abortBranchSummary(): void; + abortSessionSummary(): void; + _maybeGenerateSessionSummary(): Promise; _checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck?: boolean): Promise; _dropTrailingAutoCompactionRetryAssistantIfPresent(): void; _schedulePostAutoCompactionContinuationProbe(reason: "overflow" | "threshold", willRetry: boolean): void; @@ -337,6 +339,7 @@ export interface AgentSessionPublicSurface | "autoCompactionEnabled" | "isRetrying" | "autoRetryEnabled" + | "abortSessionSummary" | "isBashRunning" | "hasPendingBashMessages" | "extensionRunner" @@ -462,6 +465,11 @@ export interface AgentSessionInternalSurface extends AgentSessionMethodSurface, _subagentPolicy?: import("./extensions/index.ts").SubagentChildPolicy; _extensionUIContext?: ExtensionUIContext; _extensionMode: ExtensionMode; + _disposed: boolean; + _sessionSummaryAbortController: AbortController | undefined; + _sessionSummaryToken: number; + _sessionSummaryRun: import("./agent-session-summary.ts").SessionSummaryRun | undefined; + _lastSummarizedMessageId: string | undefined; _extensionCommandContextActions?: ExtensionCommandContextActions; _extensionShutdownHandler?: () => void; _extensionErrorListener?: ExtensionErrorListener; diff --git a/packages/coding-agent/src/core/agent-session-prompt.ts b/packages/coding-agent/src/core/agent-session-prompt.ts index 8c4026497..7697525c8 100644 --- a/packages/coding-agent/src/core/agent-session-prompt.ts +++ b/packages/coding-agent/src/core/agent-session-prompt.ts @@ -61,6 +61,11 @@ export async function prompt(this: AgentSession, text: string, options?: PromptO preflightResult?.(true); return; } + // Real user input is on its way in, so a summary describing the previous turn is about + // to be stale; stop paying for it. Deliberately after the authorization boundary and + // the slash-command path, both of which must observe an untouched session. The + // generation discards itself via its token/anchor checks either way. + this.abortSessionSummary(); // A controlled pause is an admission gate, including the idle gap after // abort settles. Preserve the raw user payload without running input hooks, // compaction, or a provider turn; explicit resume makes it eligible again. diff --git a/packages/coding-agent/src/core/agent-session-summary.ts b/packages/coding-agent/src/core/agent-session-summary.ts new file mode 100644 index 000000000..ffaeca2bc --- /dev/null +++ b/packages/coding-agent/src/core/agent-session-summary.ts @@ -0,0 +1,167 @@ +/** + * Session summary generation for the resume picker. + * + * Runs once the agent goes idle, decides whether a fresh summary is worth generating, and + * persists the result. Generation itself lives in core/compaction/session-summarization.ts. + */ + +import type { AgentSessionInternalSurface as AgentSession } from "./agent-session-methods.ts"; +import { generateSessionSummary } from "./compaction/index.ts"; +import { getLastConversationMessageId, getLatestSessionSummary } from "./session-manager-entries.ts"; + +/** Sessions shorter than this are already legible from their first message. */ +const MIN_ENTRIES_FOR_SUMMARY = 4; + +/** A summary request in flight, published so a launch describing the same state can join it. */ +export type SessionSummaryRun = { + /** The conversation state this request describes. */ + readonly throughId: string; + /** Settles when the request finishes, whatever the outcome. */ + readonly done: Promise; +}; + +export async function _maybeGenerateSessionSummary(this: AgentSession): Promise { + // Held rather than read from the session in `finally`, so an early return can never clear a + // controller or run belonging to a different launch. + let controller: AbortController | undefined; + let run: SessionSummaryRun | undefined; + let finish: (() => void) | undefined; + try { + // --- Bail-outs that cannot change while we wait --------------------------------- + if (this._disposed) return; + if (!this.settingsManager.getSessionSummarySettings().enabled) return; + + // One-shot scripted runs should not pay for a background call; the process may exit before + // it lands. "tui" and "rpc" are the resumable, interactive modes. + if (this._extensionMode === "print" || this._extensionMode === "json") return; + + const model = this.model; + if (!model) return; + + // Workflow-stage sessions are excluded from /resume entirely. + if (this.sessionManager.getHeader()?.internal) return; + + // Claim the launch before waiting, so a later turn supersedes this one while both are + // parked below. + const sessionSummaryToken = ++this._sessionSummaryToken; + + // `agent_end` fires while the agent still reports isStreaming, and that flag survives the + // entire microtask queue — it only clears a macrotask later. Testing it here without + // waiting made generation depend on whether `_checkCompaction` happened to cross a + // macrotask boundary: green under test, silently skipped in the real TUI, and nothing + // retries it. + await this.agent.waitForIdle(); + + if (this._disposed) return; + if (this._sessionSummaryToken !== sessionSummaryToken) return; + if (this.isStreaming || this.isCompacting) return; + + const branch = this.sessionManager.getBranch(); + if (branch.length < MIN_ENTRIES_FOR_SUMMARY) return; + + const throughId = getLastConversationMessageId(branch); + if (!throughId) return; + + // On a resumed session the in-memory anchor starts empty, so fall back to the persisted + // summary. Without this the first idle after every resume regenerates a summary that is + // already current. + const lastSummarized = + this._lastSummarizedMessageId ?? + getLatestSessionSummary(this.sessionManager.getEntries())?.summarizedThroughId; + if (throughId === lastSummarized) return; + + // A request already covering this exact conversation state will store the line this launch + // would ask for, so wait for it instead of aborting it and paying for a second one. + // Overlap is routine rather than exceptional: every turn schedules a launch, and the + // previous turn's can still be in flight when the next one wakes. + const inFlight = this._sessionSummaryRun; + if (inFlight !== undefined && inFlight.throughId === throughId) { + await inFlight.done; + return; + } + + // Anything still running describes an older conversation state, so retire it and take the + // controller. Abort the previous one directly rather than via abortSessionSummary(), which + // bumps the token and would invalidate the claim made above. The controller lives on the + // session so the prompt, tree-navigation, and shutdown paths can reach it. + this._sessionSummaryAbortController?.abort(); + controller = new AbortController(); + this._sessionSummaryAbortController = controller; + const signal = controller.signal; + + // Publish this run before the first await that another launch can overlap. From here on + // ownership of the slot, not the token, is what licenses a write: a joiner bumps the token + // on its way in, and must not invalidate the very run it is waiting for. + // Hand-rolled deferred rather than Promise.withResolvers: coding-agent is the one compiled + // package and its lib target predates ES2024, the same reason `_retryPromise` is built this + // way. The executor runs synchronously, so `finish` is assigned before the constructor returns. + const done = new Promise((resolve) => { + finish = resolve; + }); + run = { throughId, done }; + this._sessionSummaryRun = run; + + const { apiKey, headers, baseUrl } = await this._getRequiredRequestAuth(model); + + // Disposal or a newer turn can land while credentials resolve; nothing past this point + // should reach the provider. + if (this._disposed || signal.aborted) return; + if (this._sessionSummaryRun !== run) return; + + const result = await generateSessionSummary(branch, { + model, + apiKey, + headers, + baseUrl, + signal, + streamFn: this.agent.streamFunction, + retry: this.settingsManager.getRetrySettings(), + }); + // Failures stay silent: nothing awaits this, and the picker falls back on its own. + if (result.aborted || result.error || !result.summary) return; + + // Disposal, cancellation, a newer run, or a newer message all mean this summary no longer + // describes the session. The signal is checked directly as well as the token because a + // provider that ignores the signal still returns an ordinary result. + if (this._disposed) return; + if (signal.aborted) return; + if (this._sessionSummaryRun !== run) return; + if (getLastConversationMessageId(this.sessionManager.getBranch()) !== throughId) return; + + this.sessionManager.appendSessionSummary(result.summary, throughId, result.usage); + this._lastSummarizedMessageId = throughId; + } catch { + // Nothing awaits this call, so an escaping rejection would surface as an unhandled + // rejection and can take the process down. Credential resolution throws outright when no + // key is configured, which is an ordinary state for a session that never prompts. + } finally { + if (controller !== undefined && this._sessionSummaryAbortController === controller) { + this._sessionSummaryAbortController = undefined; + } + if (run !== undefined && this._sessionSummaryRun === run) { + this._sessionSummaryRun = undefined; + } + // Settled on every path, including a throw and every early return above: a joiner parked + // on `done` has no other way out. + finish?.(); + } +} + +export function abortSessionSummary(this: AgentSession): void { + // Bump the token as well as aborting: a launch parked on waitForIdle() has no controller yet, + // and would otherwise survive a prompt, a tree navigation, or disposal. + this._sessionSummaryToken++; + this._sessionSummaryAbortController?.abort(); + this._sessionSummaryAbortController = undefined; + // Retire the published run too. It is what licenses a write once a request is in flight, so a + // provider that ignores its signal must still fail the ownership check, and a later launch + // must not join a run that has just been cancelled. + this._sessionSummaryRun = undefined; + // Drop the in-memory anchor as well. It is only a cache of the persisted state, and the + // persisted lookup is retirement-aware where the cache is not: after branchWithSummary() + // retires the stored summary, a cached anchor that still matches the last message id would + // skip regeneration and leave the picker on fallback text until the next real turn. + this._lastSummarizedMessageId = undefined; +} + +export const agentSessionSummaryMethods = { _maybeGenerateSessionSummary, abortSessionSummary }; diff --git a/packages/coding-agent/src/core/agent-session-tree.ts b/packages/coding-agent/src/core/agent-session-tree.ts index 7f328ecfc..645ecda4f 100644 --- a/packages/coding-agent/src/core/agent-session-tree.ts +++ b/packages/coding-agent/src/core/agent-session-tree.ts @@ -78,6 +78,13 @@ export async function navigateTree( label, }; + // Moving the leaf invalidates any session summary still in flight: it was generated from the + // branch we are leaving. Its anchor can survive the move — a `branch_summary` is not a + // conversation message, and navigating to an existing assistant message leaves the last + // message id unchanged — so the anchor check alone would let it persist against the new + // branch. Cancelling here is what the signal check before persistence keys off. + this.abortSessionSummary(); + // Set up abort controller for summarization this._branchSummaryAbortController = new AbortController(); if (this._compactionReason === undefined) this._compactionReason = "branchSummary"; diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 0290cb7dd..108051802 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -25,6 +25,7 @@ import { agentSessionPostToolCompactionMethods } from "./agent-session-post-tool import { agentSessionPromptMethods } from "./agent-session-prompt.ts"; import { agentSessionRetryMethods } from "./agent-session-retry.ts"; import { agentSessionStateMethods } from "./agent-session-state.ts"; +import { agentSessionSummaryMethods, type SessionSummaryRun } from "./agent-session-summary.ts"; import { agentSessionToolHooksMethods } from "./agent-session-tool-hooks.ts"; import { agentSessionToolRegistryMethods } from "./agent-session-tool-registry.ts"; import { agentSessionTreeMethods } from "./agent-session-tree.ts"; @@ -122,7 +123,13 @@ class AgentSessionBase { protected _postToolCompactionPreflightError: string | undefined = undefined; protected _pendingPostToolCompactionGuard: PendingPostToolCompactionGuard | undefined = undefined; protected _terminatingToolCallIds = new Set(); + protected _disposed = false; protected _branchSummaryAbortController: AbortController | undefined = undefined; + protected _sessionSummaryAbortController: AbortController | undefined = undefined; + protected _sessionSummaryToken = 0; + /** The summary request currently in flight, published so a later launch can join it. */ + protected _sessionSummaryRun: SessionSummaryRun | undefined = undefined; + protected _lastSummarizedMessageId: string | undefined = undefined; protected _retryAbortController: AbortController | undefined = undefined; protected _retryAttempt = 0; protected _retryPromise: Promise | undefined = undefined; @@ -265,4 +272,5 @@ Object.assign( agentSessionBashMethods, agentSessionTreeMethods, agentSessionExportMethods, + agentSessionSummaryMethods, ); diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index 358da01b2..d36e09157 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -189,6 +189,7 @@ function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined { case "custom": case "label": case "session_info": + case "session_summary": case "context_compaction": return undefined; } diff --git a/packages/coding-agent/src/core/compaction/index.ts b/packages/coding-agent/src/core/compaction/index.ts index ee2e09aa9..e1ef3a979 100644 --- a/packages/coding-agent/src/core/compaction/index.ts +++ b/packages/coding-agent/src/core/compaction/index.ts @@ -14,5 +14,6 @@ export * from "./planner-outcome.js"; export * from "./range-planner.js"; export * from "./range-planner-diagnostics.js"; export * from "./region-trimming.js"; +export * from "./session-summarization.ts"; export * from "./transcript-serialization.js"; export * from "./utils.ts"; diff --git a/packages/coding-agent/src/core/compaction/session-summarization.ts b/packages/coding-agent/src/core/compaction/session-summarization.ts new file mode 100644 index 000000000..967d6f985 --- /dev/null +++ b/packages/coding-agent/src/core/compaction/session-summarization.ts @@ -0,0 +1,149 @@ +/** + * Session summarization for the resume picker. + * + * Produces one short line describing what a session was about, so `/resume` can show + * something recognizable instead of a truncated first message. Unlike a branch summary this + * never re-enters model context: it exists only as picker metadata. + */ + +import type { StreamFn } from "@earendil-works/pi-agent-core"; +import { type ProviderHeaders, type RetryPolicy, retryAssistantCall, uuidv7 } from "@earendil-works/pi-ai"; +import type { Api, Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai/compat"; +import { completeSimple } from "@earendil-works/pi-ai/compat"; +import { convertToLlm } from "../messages.ts"; +import type { SessionEntry } from "../session-manager.ts"; +import { prepareBranchEntries } from "./branch-summarization.ts"; +import { SUMMARIZATION_SYSTEM_PROMPT, serializeConversation } from "./utils.ts"; + +// ============================================================================ +// Types +// ============================================================================ + +export interface SessionSummaryResult { + summary?: string; + usage?: Usage; + aborted?: boolean; + error?: string; +} + +export interface GenerateSessionSummaryOptions { + /** Model to use for summarization */ + model: Model; + /** API key for the model; omitted for header-only bearer authentication. */ + apiKey?: string; + /** Request headers for the model */ + headers?: ProviderHeaders; + /** Credential-specific request endpoint for the model */ + baseUrl?: string; + /** Abort signal for cancellation */ + signal: AbortSignal; + /** Optional session stream function. Used to preserve SDK request behavior without mutating agent state. */ + streamFn?: StreamFn; + /** Retry policy for transient failures. Retries stay silent: this is background work. */ + retry?: RetryPolicy; +} + +// ============================================================================ +// Generation +// ============================================================================ + +/** + * Recent conversation fed to the summarizer. A one-line summary does not improve with more + * history, and this runs after every idle turn, so the input stays deliberately small. + */ +const SESSION_SUMMARY_INPUT_TOKENS = 4000; + +/** Hard ceiling on the stored line. The picker truncates to width; this bounds what we persist. */ +const SESSION_SUMMARY_MAX_CHARS = 160; + +const SESSION_SUMMARY_PROMPT = `Describe this coding session in one short sentence, at most 120 characters, so someone scanning a list of sessions can tell what it was about. + +Name the concrete task and the main file, feature, or component involved. Write plain text: no markdown, no quotes, no line breaks, and do not refer to the session or the summary itself.`; + +/** Collapse a model response into the single line the picker can render. */ +function toSingleLine(text: string): string { + const collapsed = text.replace(/\s+/g, " ").trim(); + return collapsed.length > SESSION_SUMMARY_MAX_CHARS + ? `${collapsed.slice(0, SESSION_SUMMARY_MAX_CHARS - 1).trimEnd()}…` + : collapsed; +} + +/** + * Generate a one-line summary of a session. + * + * @param entries - Session entries to summarize (chronological order) + * @param options - Generation options + */ +export async function generateSessionSummary( + entries: SessionEntry[], + options: GenerateSessionSummaryOptions, +): Promise { + const { model, apiKey, headers, baseUrl, signal, streamFn, retry } = options; + + const { messages } = prepareBranchEntries(entries, SESSION_SUMMARY_INPUT_TOKENS); + if (messages.length === 0) { + return {}; + } + + // Transform to LLM-compatible messages, then serialize to text. + // Serialization prevents the model from treating it as a conversation to continue. + const llmMessages = convertToLlm(messages); + const conversationText = serializeConversation(llmMessages); + const promptText = `\n${conversationText}\n\n\n${SESSION_SUMMARY_PROMPT}`; + + const summarizationMessages = [ + { + role: "user" as const, + content: [{ type: "text" as const, text: promptText }], + timestamp: Date.now(), + }, + ]; + + // Call LLM for summarization. Prefer the session stream function so SDK + // request behavior stays consistent without mutating agent state. + const context = { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }; + const requestModel = baseUrl === undefined || baseUrl === model.baseUrl ? model : { ...model, baseUrl }; + // No reasoning is requested. One sentence gains nothing from thinking tokens, and this + // request runs after every idle turn. + const requestOptions: SimpleStreamOptions = { + apiKey, + headers, + signal, + cacheRetention: "none", + sessionId: uuidv7(), + }; + const response = await (async () => { + try { + return await retryAssistantCall( + async () => + streamFn + ? (await streamFn(requestModel, context, requestOptions)).result() + : completeSimple(requestModel, context, requestOptions), + retry, + signal, + ); + } catch (error) { + if (signal.aborted) return undefined; + return { + stopReason: "error" as const, + errorMessage: error instanceof Error ? error.message : String(error), + }; + } + })(); + + if (!response || response.stopReason === "aborted") { + return { aborted: true }; + } + if (response.stopReason === "error") { + return { error: response.errorMessage || "Session summarization failed" }; + } + + const summary = toSingleLine( + response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(" "), + ); + + return summary ? { summary, usage: response.usage } : { usage: response.usage }; +} diff --git a/packages/coding-agent/src/core/extensions/ui-types.ts b/packages/coding-agent/src/core/extensions/ui-types.ts index ea098ee58..47c162eaf 100644 --- a/packages/coding-agent/src/core/extensions/ui-types.ts +++ b/packages/coding-agent/src/core/extensions/ui-types.ts @@ -114,6 +114,8 @@ export interface HostSessionPickerRow { modifiedAt: number; messageCount: number; firstMessage: string; + /** Generated resume summary. Absent when never generated, or stale against the latest message. */ + summary?: string; allMessagesText?: string; name?: string; /** Optional semantic color for synthetic selector rows. */ diff --git a/packages/coding-agent/src/core/session-manager-core.ts b/packages/coding-agent/src/core/session-manager-core.ts index c836b02a6..0249da830 100644 --- a/packages/coding-agent/src/core/session-manager-core.ts +++ b/packages/coding-agent/src/core/session-manager-core.ts @@ -1,4 +1,4 @@ -import type { ImageContent, Message, TextContent } from "@earendil-works/pi-ai/compat"; +import type { ImageContent, Message, TextContent, Usage } from "@earendil-works/pi-ai/compat"; import { existsSync, statSync } from "fs"; import { resolve } from "path"; import { normalizePath, resolvePath } from "../utils/paths.ts"; @@ -17,6 +17,7 @@ import { createSessionFilePath, createSessionHeader, createSessionInfoEntry, + createSessionSummaryEntry, createThinkingLevelChangeEntry, getEntriesWithoutHeader, getLatestSessionName, @@ -284,6 +285,14 @@ export class SessionManager { return entry.id; } + /** Append a generated resume summary anchored to the message it describes. Returns entry id. */ + appendSessionSummary(summary: string, summarizedThroughId: string, usage?: Usage): string { + if (!this.byId.has(summarizedThroughId)) throw new Error(`Entry ${summarizedThroughId} not found`); + const entry = createSessionSummaryEntry(summary, summarizedThroughId, usage, this.byId, this.leafId); + this._appendEntry(entry); + return entry.id; + } + /** Get the current session name from the latest session_info entry, if any. */ getSessionName(): string | undefined { return getLatestSessionName(this.getEntries()); diff --git a/packages/coding-agent/src/core/session-manager-entries.ts b/packages/coding-agent/src/core/session-manager-entries.ts index 09db91b5a..ce476df6c 100644 --- a/packages/coding-agent/src/core/session-manager-entries.ts +++ b/packages/coding-agent/src/core/session-manager-entries.ts @@ -17,6 +17,7 @@ import { type SessionHeader, type SessionInfoEntry, type SessionMessageEntry, + type SessionSummaryEntry, type SessionWorkflowMetadata, type ThinkingLevelChangeEntry, } from "./session-manager-types.ts"; @@ -145,6 +146,59 @@ export function createSessionInfoEntry( }; } +export function createSessionSummaryEntry( + summary: string, + summarizedThroughId: string, + usage: Usage | undefined, + byId: { has(id: string): boolean }, + parentId: string | null, +): SessionSummaryEntry { + return { + type: "session_summary", + ...entryBase(byId, parentId), + summary: summary.trim(), + summarizedThroughId, + usage, + }; +} + +/** + * Anchor for a session summary: the newest user/assistant message entry. + * + * Both sides of the freshness check call this so they cannot drift apart. Tool results and + * non-message entries are ignored, and an assistant turn made only of tool calls still counts — + * it carries no text, but it is a real conversation step. + */ +export function getLastConversationMessageId(entries: FileEntry[]): string | undefined { + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + if (entry.type !== "message") continue; + const role = entry.message.role; + if (role === "user" || role === "assistant") return entry.id; + } + return undefined; +} + +/** + * Latest generated resume summary that has not been retired, entry and all. + * + * Returns the entry rather than its text: callers need `summarizedThroughId` alongside the + * summary to decide whether it still describes the conversation. + * + * A `branch_summary` written afterwards retires it, because the branch it described was + * abandoned. Both the picker and the generation guard call this, so a retired summary is hidden + * from the picker *and* lets a replacement be generated; splitting the two rules leaves such a + * session showing fallback text forever. + */ +export function getLatestSessionSummary(entries: FileEntry[]): SessionSummaryEntry | undefined { + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + if (entry.type === "branch_summary") return undefined; + if (entry.type === "session_summary") return entry; + } + return undefined; +} + export function getLatestSessionName(entries: SessionEntry[]): string | undefined { // Walk entries in reverse to find the latest session_info entry. // Empty names explicitly clear the session title. diff --git a/packages/coding-agent/src/core/session-manager-list.ts b/packages/coding-agent/src/core/session-manager-list.ts index 7f1e4dd4e..733cce7ba 100644 --- a/packages/coding-agent/src/core/session-manager-list.ts +++ b/packages/coding-agent/src/core/session-manager-list.ts @@ -7,6 +7,7 @@ import { getSessionsDir } from "../config.ts"; import { yieldToEventLoopIfSlow } from "../utils/event-loop.ts"; import { normalizePath, resolvePath } from "../utils/paths.ts"; import { classifiedWorkflowMetadata } from "./session-manager-classification.ts"; +import { getLastConversationMessageId, getLatestSessionSummary } from "./session-manager-entries.ts"; import { parseSessionEntries } from "./session-manager-migrations.ts"; import { getDefaultSessionDir, getDefaultSessionDirPath } from "./session-manager-paths.ts"; import { isInternalHeader, readSessionHeader, sessionCwdMatches } from "./session-manager-storage.ts"; @@ -186,6 +187,16 @@ async function buildSessionInfo(filePath: string): Promise { const modified = getSessionModifiedDate(entries, header as SessionHeader, stats.mtime); + // A summary describes the conversation up to one specific message. Anything newer makes it + // stale, and the picker falls back to the session name or the first message. Retirement by + // a later branch summary is handled inside getLatestSessionSummary, so the generation guard + // applies exactly the same rule. + const summaryEntry = getLatestSessionSummary(entries); + const summary = + summaryEntry?.summarizedThroughId === getLastConversationMessageId(entries) + ? summaryEntry?.summary + : undefined; + return { path: filePath, id: (header as SessionHeader).id, @@ -198,6 +209,7 @@ async function buildSessionInfo(filePath: string): Promise { modified, messageCount, firstMessage: firstMessage || "(no messages)", + summary, allMessagesText: allMessages.join(" "), }; } catch { diff --git a/packages/coding-agent/src/core/session-manager-types.ts b/packages/coding-agent/src/core/session-manager-types.ts index 58d50561c..5e6df5ea0 100644 --- a/packages/coding-agent/src/core/session-manager-types.ts +++ b/packages/coding-agent/src/core/session-manager-types.ts @@ -135,6 +135,14 @@ export interface SessionInfoEntry extends SessionEntryBase { name?: string; } +/** Session summary metadata entry. */ +export interface SessionSummaryEntry extends SessionEntryBase { + type: "session_summary"; + summary: string; + summarizedThroughId: string; + usage?: Usage; +} + /** * Custom message entry for extensions to inject messages into LLM context. * Use customType to identify your extension's entries. @@ -176,7 +184,8 @@ export type SessionEntry = | CustomEntry | CustomMessageEntry | LabelEntry - | SessionInfoEntry; + | SessionInfoEntry + | SessionSummaryEntry; /** Raw file entry (includes header) */ export type FileEntry = SessionHeader | SessionEntry; @@ -214,6 +223,8 @@ export interface SessionInfo { modified: Date; messageCount: number; firstMessage: string; + /** Generated resume summary. Absent when never generated, or stale against the latest message. */ + summary?: string; allMessagesText: string; /** Optional semantic color for synthetic selector rows. */ messageColor?: "success" | "warning" | "accent" | "error"; diff --git a/packages/coding-agent/src/core/settings-manager-basic-accessors.ts b/packages/coding-agent/src/core/settings-manager-basic-accessors.ts index e4a6ed406..bb26db41f 100644 --- a/packages/coding-agent/src/core/settings-manager-basic-accessors.ts +++ b/packages/coding-agent/src/core/settings-manager-basic-accessors.ts @@ -50,6 +50,7 @@ interface SettingsManagerBasicAccessors { }; getBranchSummarySettings(): { reserveTokens: number; skipPrompt: boolean }; getBranchSummarySkipPrompt(): boolean; + getSessionSummarySettings(): { enabled: boolean }; getRetryEnabled(): boolean; setRetryEnabled(enabled: boolean): void; getRetrySettings(): { enabled: boolean; maxRetries: number; baseDelayMs: number }; @@ -289,6 +290,12 @@ const basicAccessors: SettingsManagerBasicAccessors = { return settingsInternals(this).settings.branchSummary?.skipPrompt ?? false; }, + getSessionSummarySettings() { + return { + enabled: settingsInternals(this).settings.sessionSummary?.enabled ?? true, + }; + }, + getRetryEnabled() { return settingsInternals(this).settings.retry?.enabled ?? true; }, diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index ed1c2f6d4..cd234d6ed 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -17,6 +17,7 @@ export type { PackageSource, ProviderRetrySettings, RetrySettings, + SessionSummarySettings, Settings, SettingsError, SettingsManagerCreateOptions, diff --git a/packages/coding-agent/src/core/settings-types.ts b/packages/coding-agent/src/core/settings-types.ts index 9a28faf98..756887887 100644 --- a/packages/coding-agent/src/core/settings-types.ts +++ b/packages/coding-agent/src/core/settings-types.ts @@ -14,6 +14,10 @@ export interface BranchSummarySettings { skipPrompt?: boolean; // default: false - when true, skips "Summarize branch?" prompt and defaults to no summary } +export interface SessionSummarySettings { + enabled?: boolean; // default: true - generate a one-line resume-picker summary once the agent goes idle +} + export interface ProviderRetrySettings { timeoutMs?: number; // SDK/provider request timeout in milliseconds maxRetries?: number; // SDK/provider retry attempts @@ -110,6 +114,7 @@ export interface Settings { showCacheMissNotices?: boolean; // default: false compaction?: CompactionSettings; branchSummary?: BranchSummarySettings; + sessionSummary?: SessionSummarySettings; retry?: RetrySettings; hideThinkingBlock?: boolean; externalEditor?: string; // Command for Ctrl+G external editor; takes precedence over VISUAL/EDITOR diff --git a/packages/coding-agent/src/core/usage-totals.ts b/packages/coding-agent/src/core/usage-totals.ts index d1764f60e..0f6b36072 100644 --- a/packages/coding-agent/src/core/usage-totals.ts +++ b/packages/coding-agent/src/core/usage-totals.ts @@ -40,7 +40,7 @@ export function getUsageCostBreakdown(entries: SessionEntry[]): UsageCostBreakdo } else if (entry.type === "message" && entry.message.role === "toolResult") { usage = (entry.message as MessageWithUsage).usage; key = usage ? "Tools/summaries" : undefined; - } else if (entry.type === "branch_summary" && entry.usage) { + } else if ((entry.type === "branch_summary" || entry.type === "session_summary") && entry.usage) { key = "Tools/summaries"; usage = entry.usage; } diff --git a/packages/coding-agent/src/modes/interactive-engine/protocol.ts b/packages/coding-agent/src/modes/interactive-engine/protocol.ts index 5f3d23185..895df3f1b 100644 --- a/packages/coding-agent/src/modes/interactive-engine/protocol.ts +++ b/packages/coding-agent/src/modes/interactive-engine/protocol.ts @@ -181,8 +181,19 @@ const SESSION_PICKER_MESSAGE_COLORS = ["success", "warning", "accent", "error"] function parseSessionPickerRow(value: JsonValue): HostSessionPickerRow | undefined { if (!isJsonObject(value)) return undefined; - const { path, id, cwd, createdAt, modifiedAt, messageCount, firstMessage, allMessagesText, name, messageColor } = - value; + const { + path, + id, + cwd, + createdAt, + modifiedAt, + messageCount, + firstMessage, + summary, + allMessagesText, + name, + messageColor, + } = value; if ( typeof path !== "string" || typeof id !== "string" || @@ -193,6 +204,7 @@ function parseSessionPickerRow(value: JsonValue): HostSessionPickerRow | undefin typeof firstMessage !== "string" ) return undefined; + if (summary !== undefined && typeof summary !== "string") return undefined; if (allMessagesText !== undefined && typeof allMessagesText !== "string") return undefined; if (name !== undefined && typeof name !== "string") return undefined; if ( @@ -208,6 +220,7 @@ function parseSessionPickerRow(value: JsonValue): HostSessionPickerRow | undefin modifiedAt, messageCount, firstMessage, + ...(summary !== undefined ? { summary } : {}), ...(allMessagesText !== undefined ? { allMessagesText } : {}), ...(name !== undefined ? { name } : {}), ...(messageColor !== undefined diff --git a/packages/coding-agent/src/modes/interactive/components/footer.ts b/packages/coding-agent/src/modes/interactive/components/footer.ts index 67babdfe9..c4d9dedd7 100644 --- a/packages/coding-agent/src/modes/interactive/components/footer.ts +++ b/packages/coding-agent/src/modes/interactive/components/footer.ts @@ -68,7 +68,7 @@ function getUsageLine(session: AgentSession, autoCompactEnabled: boolean, width: entry.message.usage.input + entry.message.usage.cacheRead + entry.message.usage.cacheWrite; latestCacheHitRate = latestPromptTokens > 0 ? (entry.message.usage.cacheRead / latestPromptTokens) * 100 : undefined; - } else if (entry.type === "branch_summary" && entry.usage) { + } else if ((entry.type === "branch_summary" || entry.type === "session_summary") && entry.usage) { addUsageToTotals(totals, entry.usage); } else if ( entry.type === "message" && diff --git a/packages/coding-agent/src/modes/interactive/components/host-session-picker.ts b/packages/coding-agent/src/modes/interactive/components/host-session-picker.ts index 6ca583913..085fac775 100644 --- a/packages/coding-agent/src/modes/interactive/components/host-session-picker.ts +++ b/packages/coding-agent/src/modes/interactive/components/host-session-picker.ts @@ -20,6 +20,7 @@ export function sessionInfoFromPickerRow(row: HostSessionPickerRow): SessionInfo modified: new Date(row.modifiedAt), messageCount: row.messageCount, firstMessage: row.firstMessage, + ...(row.summary !== undefined ? { summary: row.summary } : {}), allMessagesText: row.allMessagesText ?? "", ...(row.name !== undefined ? { name: row.name } : {}), ...(row.messageColor !== undefined ? { messageColor: row.messageColor } : {}), diff --git a/packages/coding-agent/src/modes/interactive/components/session-selector-list.ts b/packages/coding-agent/src/modes/interactive/components/session-selector-list.ts index acc4e7f35..0b938faa0 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector-list.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector-list.ts @@ -182,6 +182,11 @@ export class SessionList implements Component, Focusable { ); const endIndex = Math.min(startIndex + this.maxVisible, this.filteredSessions.length); + // The summary column only exists when at least one listed row has a summary. Lists that + // reuse this component for rows that never carry summaries (durable workflow runs, fresh + // installs) keep the single-column layout instead of a screenful of placeholders. + const showSummaryColumn = this.filteredSessions.some((n) => !!n.session.summary?.trim()); + // Render visible sessions (one line each with tree structure) for (let i = startIndex; i < endIndex; i++) { const node = this.filteredSessions[i]!; @@ -193,10 +198,15 @@ export class SessionList implements Component, Focusable { // Build tree prefix const prefix = this.buildTreePrefix(node); - // Session display text (name or first message) + // Session display text (name or first message). A generated summary renders in its + // own column beside it, so a summary never displaces the identity of the row; rows + // missing a usable summary show an explicit placeholder there instead. const hasName = !!session.name; const displayText = session.name ?? session.firstMessage; const normalizedMessage = displayText.replace(/[\x00-\x1f\x7f]/g, " ").trim(); + const rawSummary = session.summary?.replace(/[\x00-\x1f\x7f]/g, " ").trim(); + const hasSummary = !!rawSummary; + const summaryText = rawSummary || "No summary available."; // Right side: message count and age const age = formatSessionDate(session.modified); @@ -212,12 +222,17 @@ export class SessionList implements Component, Focusable { // Cursor const cursor = isSelected ? theme.fg("accent", "› ") : " "; - // Calculate available width for message + // Calculate available width, split between the identity column and the summary column const prefixWidth = visibleWidth(prefix); const rightWidth = visibleWidth(rightPart) + 2; // +2 for spacing const availableForMsg = width - 2 - prefixWidth - rightWidth; // -2 for cursor + const summaryGutter = 2; + const msgColWidth = showSummaryColumn + ? Math.max(10, Math.floor(availableForMsg * 0.45)) + : Math.max(10, availableForMsg); + const summaryColWidth = showSummaryColumn ? availableForMsg - msgColWidth - summaryGutter : 0; - const truncatedMsg = truncateToWidth(normalizedMessage, Math.max(10, availableForMsg), "…"); + const truncatedMsg = truncateToWidth(normalizedMessage, msgColWidth, "…"); // Style message let messageColor: "error" | "warning" | "accent" | "success" | null = null; @@ -235,8 +250,20 @@ export class SessionList implements Component, Focusable { styledMsg = theme.bold(styledMsg); } + // Summary column: dim for a real summary, muted italic for the placeholder. Skipped + // entirely when the terminal is too narrow to show anything useful. + let summaryCell = ""; + if (summaryColWidth >= 10) { + const truncatedSummary = truncateToWidth(summaryText, summaryColWidth, "…"); + const styledSummary = hasSummary + ? theme.fg("dim", truncatedSummary) + : theme.italic(theme.fg("muted", truncatedSummary)); + const pad = " ".repeat(Math.max(0, msgColWidth - visibleWidth(truncatedMsg)) + summaryGutter); + summaryCell = pad + styledSummary; + } + // Build line - const leftPart = cursor + theme.fg("dim", prefix) + styledMsg; + const leftPart = cursor + theme.fg("dim", prefix) + styledMsg + summaryCell; const leftWidth = visibleWidth(leftPart); const spacing = Math.max(1, width - leftWidth - visibleWidth(rightPart)); const styledRight = theme.fg(isConfirmingDelete ? "error" : "dim", rightPart); diff --git a/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts b/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts index 9b5bf2327..6139b18ac 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts @@ -24,7 +24,7 @@ function normalizeWhitespaceLower(text: string): string { } function getSessionSearchText(session: SessionInfo): string { - return `${session.id} ${session.name ?? ""} ${session.allMessagesText} ${session.cwd}`; + return `${session.id} ${session.name ?? ""} ${session.summary ?? ""} ${session.allMessagesText} ${session.cwd}`; } export function hasSessionName(session: SessionInfo): boolean { diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector-content.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector-content.ts index a4a74741a..1a1eb6522 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector-content.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector-content.ts @@ -42,6 +42,9 @@ export function getSearchableText(node: SessionTreeNode): string { parts.push("title"); if (entry.name) parts.push(entry.name); break; + case "session_summary": + parts.push("session summary", entry.summary); + break; case "model_change": parts.push("model", entry.modelId); break; @@ -140,6 +143,9 @@ export function getEntryDisplayText( ? [theme.fg("dim", "[title: "), theme.fg("dim", entry.name), theme.fg("dim", "]")].join("") : [theme.fg("dim", "[title: "), theme.italic(theme.fg("dim", "empty")), theme.fg("dim", "]")].join(""); break; + case "session_summary": + result = theme.fg("dim", `[session summary]: `) + normalizeText(entry.summary); + break; default: result = ""; } diff --git a/packages/coding-agent/test/session-manager/session-summary-listing.test.ts b/packages/coding-agent/test/session-manager/session-summary-listing.test.ts new file mode 100644 index 000000000..6eca8751d --- /dev/null +++ b/packages/coding-agent/test/session-manager/session-summary-listing.test.ts @@ -0,0 +1,158 @@ +import { mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { type SessionHeader, SessionManager } from "../../src/core/session-manager.ts"; + +/** + * A `session_summary` entry is only shown by the resume picker while it still describes the + * session. `summarizedThroughId` anchors it to the last user/assistant message it covered, and a + * later `branch_summary` retires it because the branch it described was abandoned. + */ + +function header(id: string, cwd: string): SessionHeader { + return { type: "session", version: 3, id, timestamp: new Date().toISOString(), cwd }; +} + +function writeSessionFile(dir: string, id: string, cwd: string, lines: string[]): void { + const h = header(id, cwd); + writeFileSync( + join(dir, `${h.timestamp.replace(/[:.]/g, "-")}_${id}.jsonl`), + `${JSON.stringify(h)}\n${lines.join("\n")}\n`, + ); +} + +function userMessage(id: string, parentId: string | null, text: string): string { + return JSON.stringify({ + type: "message", + id, + parentId, + timestamp: "2025-01-01T00:00:01Z", + message: { role: "user", content: text, timestamp: 1 }, + }); +} + +function assistantMessage(id: string, parentId: string | null, text: string): string { + return JSON.stringify({ + type: "message", + id, + parentId, + timestamp: "2025-01-01T00:00:02Z", + message: { role: "assistant", content: [{ type: "text", text }], timestamp: 2, stopReason: "stop" }, + }); +} + +function toolResult(id: string, parentId: string): string { + return JSON.stringify({ + type: "message", + id, + parentId, + timestamp: "2025-01-01T00:00:03Z", + message: { role: "toolResult", toolCallId: "t1", toolName: "read", content: "ok", isError: false, timestamp: 3 }, + }); +} + +function sessionSummary(id: string, parentId: string, summary: string, summarizedThroughId: string): string { + return JSON.stringify({ + type: "session_summary", + id, + parentId, + timestamp: "2025-01-01T00:00:04Z", + summary, + summarizedThroughId, + }); +} + +function branchSummary(id: string, parentId: string): string { + return JSON.stringify({ + type: "branch_summary", + id, + parentId, + timestamp: "2025-01-01T00:00:05Z", + fromId: parentId, + summary: "abandoned branch", + }); +} + +describe("resume listing surfaces session summaries", () => { + let dir: string; + const cwd = "/project"; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "session-summary-list-")); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("exposes the summary while it still anchors to the newest conversation message", async () => { + writeSessionFile(dir, "fresh", cwd, [ + userMessage("m1", null, "add a resume summary"), + assistantMessage("m2", "m1", "done"), + sessionSummary("s1", "m2", "Added resume summaries to the session picker", "m2"), + ]); + + const sessions = await SessionManager.list(cwd, dir); + expect(sessions[0]?.summary).toBe("Added resume summaries to the session picker"); + }); + + it("drops the summary once a newer message lands", async () => { + writeSessionFile(dir, "stale", cwd, [ + userMessage("m1", null, "add a resume summary"), + assistantMessage("m2", "m1", "done"), + sessionSummary("s1", "m2", "Added resume summaries to the session picker", "m2"), + userMessage("m3", "s1", "now also handle workflows"), + ]); + + const sessions = await SessionManager.list(cwd, dir); + expect(sessions[0]?.summary).toBeUndefined(); + }); + + it("drops the summary once a branch summary retires it", async () => { + writeSessionFile(dir, "branched", cwd, [ + userMessage("m1", null, "add a resume summary"), + assistantMessage("m2", "m1", "done"), + sessionSummary("s1", "m2", "Added resume summaries to the session picker", "m2"), + branchSummary("b1", "s1"), + ]); + + const sessions = await SessionManager.list(cwd, dir); + expect(sessions[0]?.summary).toBeUndefined(); + }); + + it("ignores tool results when anchoring, so a tool turn does not make the summary stale", async () => { + writeSessionFile(dir, "tools", cwd, [ + userMessage("m1", null, "add a resume summary"), + assistantMessage("m2", "m1", "done"), + sessionSummary("s1", "m2", "Added resume summaries to the session picker", "m2"), + toolResult("t1", "s1"), + ]); + + const sessions = await SessionManager.list(cwd, dir); + expect(sessions[0]?.summary).toBe("Added resume summaries to the session picker"); + }); + + it("keeps a summary written after a branch summary", async () => { + // Retirement is positional, not permanent: a summary generated after the branch summary + // describes the current branch and must survive. + writeSessionFile(dir, "rebranched", cwd, [ + userMessage("m1", null, "add a resume summary"), + assistantMessage("m2", "m1", "done"), + branchSummary("b1", "m2"), + sessionSummary("s1", "b1", "Reworked the resume picker after a rewind", "m2"), + ]); + + const sessions = await SessionManager.list(cwd, dir); + expect(sessions[0]?.summary).toBe("Reworked the resume picker after a rewind"); + }); + + it("leaves the summary absent when the session never generated one", async () => { + writeSessionFile(dir, "none", cwd, [ + userMessage("m1", null, "add a resume summary"), + assistantMessage("m2", "m1", "done"), + ]); + + const sessions = await SessionManager.list(cwd, dir); + expect(sessions[0]?.summary).toBeUndefined(); + }); +}); diff --git a/packages/coding-agent/test/session-selector-summary-column.test.ts b/packages/coding-agent/test/session-selector-summary-column.test.ts new file mode 100644 index 000000000..da81aac94 --- /dev/null +++ b/packages/coding-agent/test/session-selector-summary-column.test.ts @@ -0,0 +1,98 @@ +import { setKeybindings } from "@earendil-works/pi-tui"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { KeybindingsManager } from "../src/core/keybindings.ts"; +import type { SessionInfo } from "../src/core/session-manager.ts"; +import { SessionSelectorComponent } from "../src/modes/interactive/components/session-selector.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; + +function makeSession(overrides: Partial & { id: string }): SessionInfo { + return { + path: overrides.path ?? `/tmp/${overrides.id}.jsonl`, + id: overrides.id, + cwd: overrides.cwd ?? "", + name: overrides.name, + created: overrides.created ?? new Date(0), + modified: overrides.modified ?? new Date(0), + messageCount: overrides.messageCount ?? 1, + firstMessage: overrides.firstMessage ?? `first-${overrides.id}`, + allMessagesText: overrides.allMessagesText ?? `text-${overrides.id}`, + ...(overrides.summary !== undefined ? { summary: overrides.summary } : {}), + }; +} + +function renderRows(sessions: SessionInfo[], width = 160): string { + const keybindings = new KeybindingsManager(); + const selector = new SessionSelectorComponent( + async () => sessions, + async () => [], + () => {}, + () => {}, + () => {}, + () => {}, + { showRenameHint: false, keybindings, initialSessions: sessions }, + ); + return selector.render(width).join("\n"); +} + +describe("session selector summary column", () => { + beforeAll(() => { + initTheme("dark"); + }); + + beforeEach(() => { + setKeybindings(new KeybindingsManager()); + }); + + it("shows the summary in its own column without displacing the first message", () => { + const frame = renderRows([ + makeSession({ id: "a", firstMessage: "fix the login bug", summary: "Debugged an OAuth redirect loop" }), + ]); + expect(frame).toContain("fix the login bug"); + expect(frame).toContain("Debugged an OAuth redirect loop"); + }); + + it("keeps a user-set session name visible alongside the summary", () => { + const frame = renderRows([ + makeSession({ id: "b", name: "auth-work", summary: "Debugged an OAuth redirect loop" }), + ]); + expect(frame).toContain("auth-work"); + expect(frame).toContain("Debugged an OAuth redirect loop"); + }); + + it("falls back to a placeholder when a summary is missing and others exist", () => { + const frame = renderRows([ + makeSession({ id: "c", firstMessage: "fix the login bug" }), + makeSession({ id: "c2", firstMessage: "other work", summary: "Refactored the session picker" }), + ]); + expect(frame).toContain("fix the login bug"); + expect(frame).toContain("No summary available."); + }); + + it("treats a whitespace-only summary as absent", () => { + const frame = renderRows([ + makeSession({ id: "d", summary: " " }), + makeSession({ id: "d2", summary: "Real summary" }), + ]); + expect(frame).toContain("No summary available."); + }); + + it("renders no summary column when no listed session has one", () => { + const frame = renderRows([ + makeSession({ id: "f", firstMessage: "fix the login bug" }), + makeSession({ id: "g", firstMessage: "other work" }), + ]); + expect(frame).toContain("fix the login bug"); + expect(frame).not.toContain("No summary available."); + }); + + it("omits the summary column entirely when the terminal is too narrow", () => { + const frame = renderRows( + [ + makeSession({ id: "e", firstMessage: "fix the login bug", summary: "x".repeat(80) }), + makeSession({ id: "e2" }), + ], + 40, + ); + expect(frame).not.toContain("No summary available."); + }); +}); diff --git a/packages/coding-agent/test/suite/agent-session-summary.test.ts b/packages/coding-agent/test/suite/agent-session-summary.test.ts new file mode 100644 index 000000000..4c5153c4d --- /dev/null +++ b/packages/coding-agent/test/suite/agent-session-summary.test.ts @@ -0,0 +1,458 @@ +import type { AgentEvent } from "@earendil-works/pi-agent-core"; +import { fauxAssistantMessage } from "@earendil-works/pi-ai/compat"; +import { afterEach, describe, expect, it } from "vitest"; +import type { SessionSummaryEntry } from "../../src/core/session-manager.ts"; +import { createHarness, type Harness } from "./harness.ts"; + +/** + * `_maybeGenerateSessionSummary` runs fire-and-forget after `agent_end`. These tests drive real + * turns through the faux provider and assert on what reaches the session file. + */ + +/** Bounded wait for the background summary; the faux provider answers in-process. */ +const SUMMARY_DEADLINE_MS = 2_000; +/** Long enough for a summary that was going to happen to have happened. */ +const SUMMARY_SETTLE_MS = 250; + +function summaryEntries(harness: Harness): SessionSummaryEntry[] { + return harness.sessionManager.getEntries().filter((e): e is SessionSummaryEntry => e.type === "session_summary"); +} + +async function waitForSummary(harness: Harness): Promise { + const deadline = Date.now() + SUMMARY_DEADLINE_MS; + while (Date.now() < deadline) { + const found = summaryEntries(harness); + if (found.length > 0) return found[found.length - 1]!; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error("timed out waiting for a session_summary entry"); +} + +async function settle(): Promise { + await new Promise((resolve) => setTimeout(resolve, SUMMARY_SETTLE_MS)); +} + +/** Two turns, so the branch clears the minimum-entry guard. */ +async function runTwoTurns(harness: Harness): Promise { + await harness.session.prompt("add resume summaries"); + await harness.session.prompt("now wire up the picker"); +} + +describe("session summary generation", () => { + const harnesses: Harness[] = []; + + afterEach(() => { + while (harnesses.length > 0) { + harnesses.pop()?.cleanup(); + } + }); + + it("appends a summary anchored to the last conversation message", async () => { + const harness = await createHarness(); + harnesses.push(harness); + await harness.session.bindExtensions({ mode: "tui" }); + harness.setResponses([ + fauxAssistantMessage("first turn"), + fauxAssistantMessage("second turn"), + fauxAssistantMessage("Wiring resume summaries into the session picker"), + ]); + + await runTwoTurns(harness); + const summary = await waitForSummary(harness); + + expect(summary.summary).toBe("Wiring resume summaries into the session picker"); + + // The anchor must be the newest user/assistant message entry, never the leaf. + const conversation = harness.sessionManager + .getEntries() + .filter((e) => e.type === "message" && (e.message.role === "user" || e.message.role === "assistant")); + expect(summary.summarizedThroughId).toBe(conversation[conversation.length - 1]?.id); + }); + + it("does not regenerate while the conversation has not moved on", async () => { + const harness = await createHarness(); + harnesses.push(harness); + await harness.session.bindExtensions({ mode: "tui" }); + harness.setResponses([ + fauxAssistantMessage("first turn"), + fauxAssistantMessage("second turn"), + fauxAssistantMessage("a summary"), + ]); + + await runTwoTurns(harness); + await waitForSummary(harness); + expect(harness.getPendingResponseCount()).toBe(0); + + // A second idle with no new messages must not spend another request. + await harness.session._maybeGenerateSessionSummary(); + await settle(); + + expect(summaryEntries(harness)).toHaveLength(1); + }); + + it("does not persist a summary once tree navigation has left the branch", async () => { + // A branch_summary is not a conversation message, and navigating to an existing assistant + // message leaves the last message id unchanged, so the anchor check alone cannot catch this. + const harness = await createHarness(); + harnesses.push(harness); + await harness.session.bindExtensions({ mode: "tui" }); + harness.setResponses([ + fauxAssistantMessage("first turn"), + fauxAssistantMessage("second turn"), + fauxAssistantMessage("a summary of the abandoned branch"), + ]); + + const requestStarted = Promise.withResolvers(); + const releaseRequest = Promise.withResolvers(); + harness.setResponses([ + fauxAssistantMessage("first turn"), + fauxAssistantMessage("second turn"), + // Hold the summary request open so the navigation lands while it is in flight. + async () => { + requestStarted.resolve(); + await releaseRequest.promise; + return fauxAssistantMessage("a summary of the abandoned branch"); + }, + ]); + + await runTwoTurns(harness); + await requestStarted.promise; + + // Push the leaf past the last assistant with a non-message entry, so navigating back to + // that message moves the branch while leaving the anchor untouched. + harness.session.setSessionName("pinned"); + const assistants = harness.sessionManager + .getEntries() + .filter((entry) => entry.type === "message" && entry.message.role === "assistant"); + await harness.session.navigateTree(assistants[assistants.length - 1]!.id); + + releaseRequest.resolve(); + await settle(); + + expect(summaryEntries(harness)).toHaveLength(0); + }); + + it("regenerates after a branch summary retires the persisted summary", async () => { + // A branch_summary retires the stored summary without moving the last conversation message + // id, so the in-memory anchor cache alone would skip regeneration forever. Cancellation + // (which every branch/navigation path performs) must drop the cache so the next idle + // consults the retirement-aware persisted lookup and spends a fresh request. + const harness = await createHarness(); + harnesses.push(harness); + await harness.session.bindExtensions({ mode: "tui" }); + harness.setResponses([ + fauxAssistantMessage("first turn"), + fauxAssistantMessage("second turn"), + fauxAssistantMessage("summary before retirement"), + fauxAssistantMessage("summary after retirement"), + ]); + + await runTwoTurns(harness); + const first = await waitForSummary(harness); + expect(first.summary).toBe("summary before retirement"); + + // Retire it the way branchWithSummary() does: a branch_summary lands on the file and the + // session cancels any summary work, exactly as the branch and navigation paths do. + const assistants = harness.sessionManager + .getEntries() + .filter((entry) => entry.type === "message" && entry.message.role === "assistant"); + harness.sessionManager.branchWithSummary(assistants[assistants.length - 1]!.id, "the branch was abandoned"); + harness.session.abortSessionSummary(); + + await harness.session._maybeGenerateSessionSummary(); + await settle(); + + const summaries = summaryEntries(harness); + expect(summaries).toHaveLength(2); + expect(summaries[summaries.length - 1]!.summary).toBe("summary after retirement"); + expect(harness.getPendingResponseCount()).toBe(0); + }); + + it("generates nothing when the setting is disabled", async () => { + const harness = await createHarness({ settings: { sessionSummary: { enabled: false } } }); + harnesses.push(harness); + await harness.session.bindExtensions({ mode: "tui" }); + harness.setResponses([fauxAssistantMessage("first turn"), fauxAssistantMessage("second turn")]); + + await runTwoTurns(harness); + await settle(); + + expect(summaryEntries(harness)).toHaveLength(0); + expect(harness.getPendingResponseCount()).toBe(0); + }); + + it("swallows a credential failure instead of rejecting", async () => { + // Regression: _getRequiredRequestAuth throws outright when no key is configured, and the + // caller is `void this._maybeGenerateSessionSummary()`. An escaping rejection became an + // unhandled rejection that took down a CLI child mid-run. + const harness = await createHarness({ withConfiguredAuth: false }); + harnesses.push(harness); + await harness.session.bindExtensions({ mode: "tui" }); + + // Build a branch directly: without credentials the session cannot run real turns. + harness.sessionManager.appendMessage({ role: "user", content: "add resume summaries", timestamp: 1 }); + harness.sessionManager.appendMessage({ + role: "assistant", + content: [{ type: "text", text: "done" }], + timestamp: 2, + stopReason: "stop", + provider: "faux", + model: "faux", + api: "anthropic-messages", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }, + }); + harness.sessionManager.appendMessage({ role: "user", content: "and the picker", timestamp: 3 }); + harness.sessionManager.appendMessage({ + role: "assistant", + content: [{ type: "text", text: "done again" }], + timestamp: 4, + stopReason: "stop", + provider: "faux", + model: "faux", + api: "anthropic-messages", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }, + }); + + await expect(harness.session._maybeGenerateSessionSummary()).resolves.toBeUndefined(); + expect(summaryEntries(harness)).toHaveLength(0); + }); + + it("generates nothing in non-interactive modes", async () => { + const harness = await createHarness(); + harnesses.push(harness); + // No bindExtensions call: the session stays in its default "print" mode. + harness.setResponses([fauxAssistantMessage("first turn"), fauxAssistantMessage("second turn")]); + + await runTwoTurns(harness); + await settle(); + + expect(summaryEntries(harness)).toHaveLength(0); + expect(harness.getPendingResponseCount()).toBe(0); + }); + + it("does not persist a summary once the conversation has outrun it", async () => { + const harness = await createHarness(); + harnesses.push(harness); + await harness.session.bindExtensions({ mode: "tui" }); + harness.setResponses([ + fauxAssistantMessage("first turn"), + fauxAssistantMessage("second turn"), + fauxAssistantMessage("third turn"), + ]); + + await runTwoTurns(harness); + // Start a summary, then land another turn before it can be persisted. + const pending = harness.session._maybeGenerateSessionSummary(); + await harness.session.prompt("and one more thing"); + await pending; + await settle(); + + for (const entry of summaryEntries(harness)) { + const conversation = harness.sessionManager + .getEntries() + .filter((e) => e.type === "message" && (e.message.role === "user" || e.message.role === "assistant")); + expect(entry.summarizedThroughId).toBe(conversation[conversation.length - 1]?.id); + } + }); + + it("still generates when the launch happens while the agent reports streaming", async () => { + // `agent_end` fires with isStreaming still true, and the flag survives the whole microtask + // queue — it only clears a macrotask later. Generation used to depend on _checkCompaction + // happening to cross that boundary before the guard was read: true in this harness, false + // in the real TUI, where the feature silently produced nothing and nothing retried it. + const harness = await createHarness(); + harnesses.push(harness); + await harness.session.bindExtensions({ mode: "tui" }); + harness.setResponses([ + fauxAssistantMessage("first turn"), + fauxAssistantMessage("second turn"), + fauxAssistantMessage("a summary generated after idle"), + ]); + + const launches: Promise[] = []; + let streamingAtLaunch = false; + harness.session.agent.subscribe((event: AgentEvent) => { + if (event.type !== "agent_end") return; + if (harness.session.isStreaming) streamingAtLaunch = true; + launches.push(harness.session._maybeGenerateSessionSummary()); + }); + + await runTwoTurns(harness); + await Promise.all(launches); + + // Guards the regression itself: if this ever goes false the test has stopped reproducing + // the condition and would pass for the wrong reason. + expect(streamingAtLaunch).toBe(true); + + const summary = await waitForSummary(harness); + expect(summary.summary).toBe("a summary generated after idle"); + }); + + it("runs no summary work once the session has been disposed", async () => { + // Work queued before the AbortController exists cannot be reached by abortSessionSummary(), + // so disposal has to be recorded as state and re-checked at every async boundary. + const harness = await createHarness(); + harnesses.push(harness); + await harness.session.bindExtensions({ mode: "tui" }); + const requestStarted = Promise.withResolvers(); + const releaseRequest = Promise.withResolvers(); + harness.setResponses([ + fauxAssistantMessage("first turn"), + fauxAssistantMessage("second turn"), + // Turn 2's own launch reaches the provider before dispose() lands, and is cancelled + // mid-request. Budgeting it keeps the response below as the one thing the disposal + // guard has to protect. The gate makes "reaches the provider first" a fact rather + // than a race: without it, a fast dispose() cancels the launch before it spends a + // response and the pending count depends on scheduler timing. + async () => { + requestStarted.resolve(); + await releaseRequest.promise; + return fauxAssistantMessage("cancelled by disposal"); + }, + fauxAssistantMessage("must never be requested"), + ]); + + await runTwoTurns(harness); + // Turn 2's fire-and-forget launch is now provably in flight. + await requestStarted.promise; + + harness.session.dispose(); + releaseRequest.resolve(); + // Release the launch that was queued before disposal. + await harness.session._maybeGenerateSessionSummary(); + await settle(); + + expect(summaryEntries(harness)).toHaveLength(0); + // The third response is still unconsumed, so the provider was never contacted. + expect(harness.getPendingResponseCount()).toBe(1); + }); + + it("does not persist a summary when disposal lands mid-request", async () => { + const harness = await createHarness(); + harnesses.push(harness); + await harness.session.bindExtensions({ mode: "tui" }); + + const requestStarted = Promise.withResolvers(); + const releaseRequest = Promise.withResolvers(); + harness.setResponses([ + fauxAssistantMessage("first turn"), + fauxAssistantMessage("second turn"), + async () => { + requestStarted.resolve(); + await releaseRequest.promise; + return fauxAssistantMessage("summary for a disposed session"); + }, + ]); + + await runTwoTurns(harness); + const pending = harness.session._maybeGenerateSessionSummary(); + await requestStarted.promise; + + harness.session.dispose(); + releaseRequest.resolve(); + await pending; + await settle(); + + expect(summaryEntries(harness)).toHaveLength(0); + }); + + it("collapses concurrent launches into a single request", async () => { + // The token is claimed before waitForIdle(), so two launches can now be parked at once — + // a state that could not exist when the claim happened after the guards. + const harness = await createHarness(); + harnesses.push(harness); + await harness.session.bindExtensions({ mode: "tui" }); + harness.setResponses([ + fauxAssistantMessage("first turn"), + fauxAssistantMessage("second turn"), + fauxAssistantMessage("the only summary"), + ]); + + await runTwoTurns(harness); + await Promise.all([ + harness.session._maybeGenerateSessionSummary(), + harness.session._maybeGenerateSessionSummary(), + ]); + await settle(); + + expect(summaryEntries(harness)).toHaveLength(1); + // One summary response consumed, not two. + expect(harness.getPendingResponseCount()).toBe(0); + }); + + it("lets a new prompt supersede a launch that is still parked", async () => { + // A parked launch holds no AbortController, so abortSessionSummary() has to bump the token + // to reach it. Without that, prompt() cancels nothing and the stale launch runs anyway. + const harness = await createHarness(); + harnesses.push(harness); + await harness.session.bindExtensions({ mode: "tui" }); + harness.setResponses([ + fauxAssistantMessage("first turn"), + fauxAssistantMessage("second turn"), + // Turn 2's own launch is already in flight when the next prompt arrives, so it spends + // a request that prompt() then cancels. The parked launch under test spends none. + fauxAssistantMessage("summary the next prompt cancels"), + fauxAssistantMessage("third turn"), + fauxAssistantMessage("the surviving summary"), + ]); + + await runTwoTurns(harness); + const parked = harness.session._maybeGenerateSessionSummary(); + await harness.session.prompt("and one more thing"); + await parked; + await settle(); + + // Exactly one summary, and every response accounted for: the parked launch never spent a + // request of its own. + expect(summaryEntries(harness)).toHaveLength(1); + expect(harness.getPendingResponseCount()).toBe(0); + }); + + it("never escapes as an unhandled rejection", async () => { + // Production calls this as `void this._maybeGenerateSessionSummary()`, so anything that + // throws — including the guards ahead of the first await — surfaces as an unhandled + // rejection rather than a caught error. One of those took down a CLI child mid-run. + const rejections: unknown[] = []; + const onRejection = (reason: unknown): void => { + rejections.push(reason); + }; + process.on("unhandledRejection", onRejection); + try { + const harness = await createHarness({ withConfiguredAuth: false }); + harnesses.push(harness); + await harness.session.bindExtensions({ mode: "tui" }); + + harness.sessionManager.appendMessage({ role: "user", content: "add resume summaries", timestamp: 1 }); + harness.sessionManager.appendMessage({ + role: "assistant", + content: [{ type: "text", text: "done" }], + timestamp: 2, + stopReason: "stop", + provider: "faux", + model: "faux", + api: "anthropic-messages", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }, + }); + harness.sessionManager.appendMessage({ role: "user", content: "and the picker", timestamp: 3 }); + harness.sessionManager.appendMessage({ + role: "assistant", + content: [{ type: "text", text: "done again" }], + timestamp: 4, + stopReason: "stop", + provider: "faux", + model: "faux", + api: "anthropic-messages", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }, + }); + + // Deliberately not awaited: this is the production call shape. + void harness.session._maybeGenerateSessionSummary(); + await settle(); + + expect(rejections).toEqual([]); + expect(summaryEntries(harness)).toHaveLength(0); + } finally { + process.off("unhandledRejection", onRejection); + } + }); +}); diff --git a/packages/workflows/src/extension/ui-surface.ts b/packages/workflows/src/extension/ui-surface.ts index f19d2d3e8..8a7bf84f3 100644 --- a/packages/workflows/src/extension/ui-surface.ts +++ b/packages/workflows/src/extension/ui-surface.ts @@ -209,6 +209,8 @@ export interface PiHostSessionPickerRow { modifiedAt: number; messageCount: number; firstMessage: string; + /** Generated resume summary. Absent when never generated, or stale against the latest message. */ + summary?: string; allMessagesText?: string; name?: string; /** Optional semantic color for synthetic selector rows. */ diff --git a/packages/workflows/src/tui/workflow-resume-selector.ts b/packages/workflows/src/tui/workflow-resume-selector.ts index a12c4cd0f..b1153091c 100644 --- a/packages/workflows/src/tui/workflow-resume-selector.ts +++ b/packages/workflows/src/tui/workflow-resume-selector.ts @@ -191,6 +191,7 @@ function toPickerRow(session: SessionInfo): PiHostSessionPickerRow { modifiedAt: session.modified.getTime(), messageCount: session.messageCount, firstMessage: session.firstMessage, + ...(session.summary !== undefined ? { summary: session.summary } : {}), allMessagesText: session.allMessagesText, ...(session.name !== undefined ? { name: session.name } : {}), ...(session.messageColor !== undefined ? { messageColor: session.messageColor } : {}), diff --git a/test/unit/agent-session-prompt-start.test.ts b/test/unit/agent-session-prompt-start.test.ts index 3c0d8766d..e83dc6a9f 100644 --- a/test/unit/agent-session-prompt-start.test.ts +++ b/test/unit/agent-session-prompt-start.test.ts @@ -56,6 +56,7 @@ describe("AgentSession prompt-start handshake", () => { }, }, isStreaming: false, + abortSessionSummary: () => {}, async waitForRetry() {}, async _continueQueuedAgentMessages() {}, async _awaitPendingPostCompactionContinuation() {}, @@ -77,6 +78,7 @@ describe("AgentSession prompt-start handshake", () => { const session = { agent: { prompt: () => Promise.reject(new Error("startup rejected")) }, isStreaming: false, + abortSessionSummary: () => {}, async waitForRetry() {}, async _continueQueuedAgentMessages() {}, async _awaitPendingPostCompactionContinuation() {}, @@ -103,6 +105,7 @@ describe("AgentSession workflow delivery authorization", () => { const delivered: string[] = []; const session = { isStreaming: false, + abortSessionSummary: () => {}, promptTemplates: [], _extensionRunner: { hasHandlers: (event: string) => event === "input", diff --git a/test/unit/interactive-engine-cycle-fallback.test.ts b/test/unit/interactive-engine-cycle-fallback.test.ts index 7179c7c94..c320e53b6 100644 --- a/test/unit/interactive-engine-cycle-fallback.test.ts +++ b/test/unit/interactive-engine-cycle-fallback.test.ts @@ -230,6 +230,10 @@ serialTest( lastChangelogVersion: "0.0.0", firstRunOnboardingStartedVersion: "0.0.0", onboardedVersion: "0.0.0", + // The request count below is the point of this test: one provider turn for the + // cycled prompt, none from the fallback path. The idle session-summary launch + // would add an unrelated second request to the same fake provider. + sessionSummary: { enabled: false }, }), ); writeFileSync( diff --git a/test/unit/workflow-idle-prompt-start-race.test.ts b/test/unit/workflow-idle-prompt-start-race.test.ts index d4486dcbb..65ffd52fb 100644 --- a/test/unit/workflow-idle-prompt-start-race.test.ts +++ b/test/unit/workflow-idle-prompt-start-race.test.ts @@ -46,6 +46,7 @@ test("production prompt wiring holds idle admission until the first agent turn s get isStreaming() { return streaming; }, + abortSessionSummary() {}, prompt(text: string, options?: Parameters[1]) { return prompt.call(surface as never, text, options); },