RFD 126: Turn Summaries
ImplementedDesign2026-10-30Jean Mertz
ExtendsRFD 097
Summary
Every turn in a conversation carries an optional single-line summary on its TurnStart event, alongside the turn's stable event_id (RFD 097). Summaries are generated from the completed turn (user request, tool call outcomes, and assistant response) by a background task (RFD 107) at the start of the next jp query invocation, using a configurable fast or local model. This gives turn-level interfaces such as interactive conversation editing a git rebase -i-style <event_id> <summary> header for every turn without a dedicated LLM round-trip at the end of a query: the summary request overlaps the next turn instead.
Terminology
- Turn summary: a single-line (~60 character) factual description of what a single turn asked and accomplished, stored on
TurnStartfor human navigation and stream editing.
Not the same as a compaction Summary (RFD 064), which is multi-paragraph prose attached to a Compaction overlay (SummaryPolicy) that replaces a range of turns in the provider-facing Compacted View. A turn summary is display metadata on TurnStart and never enters a Thread.
Motivation
As conversations grow across dozens of turns, navigating or editing them at the turn level becomes difficult. In an interactive stream editor (jp conversation edit --interactive), the user is presented with a plan file (CONVERSATION) grouping events by turn. Without a summary of each turn, the plan file can only display bare turn indices (# Turn 1, # Turn 2) or a mechanical truncation of the user's ChatRequest.
Neither is sufficient for a git-style interactive rebase workflow:
- Bare turn numbers carry no context. Finding "the turn where we switched from
MutextoRwLock" requires opening individual event files until the right exchange is found. - Truncating
ChatRequestfails on conversational follow-ups. In pair programming, many turns start with short directives such as"continue","yes, do option 2","fix that warning", or a pasted compiler error. The meaning of the turn lives in the combination of the request, the tools invoked, and the assistant's response. - Synchronous post-turn summarization hurts inner-loop speed. Summarizing the full turn after the assistant finishes streaming requires an additional LLM round-trip. Waiting for that call before returning the shell prompt adds 0.5 to 3 seconds of dead time at the end of every
jp query.
We already generate conversation titles in the background using a cheap or local model (conversation.title.generate). We need an analogous mechanism for per-turn summaries that captures full-turn outcomes without delaying CLI exit.
Design
Consumer Contract
This RFD does not ship a consumer. It provides the storage, accessors, and generation pipeline that turn-level views build on. An interactive stream editor, for example, could label the turns in its plan file like this:
# Turn 1 (k3m9x2a): Set up project structure with error handling
000-request.md
001-message.md
002-tool-call-fs_create_file.md
003-tool-result-fs_create_file.md
# Turn 2 (p8q2r4b): Add retry backoff to stream handler
004-config-delta.toml
005-request.md
006-reasoning.md
007-tool-call-fs_read_file.md
008-tool-result-fs_read_file.md
009-message.mdA consumer can rely on:
Turn::id()andTurn::summary()to read a turn's identity and its stored summary.Turn::fallback_summary()(also re-exported asmechanical_turn_summary(&Turn)) as the display fallback when no summary is stored: the first non-empty line of the turn's firstChatRequest, collapsed to a single line and truncated to 72 characters.ConversationStream::set_turn_summaryto normalize and persist a summary it generated itself.
A consumer that rewrites the stream, such as an editor that reconstructs events from files, must:
- Keep the original
TurnStartentry,event_idandsummaryincluded, for every turn whose content it did not change. - Clear
summaryon every turn whose content it changed, so catch-up regenerates it on the next query (see Summary Freshness). - Own any on-demand generation it offers, such as summarizing the most recent turn before opening
$EDITOR, including the mechanical fallback when generation is disabled or fails, and persist the result while holding the conversation lock.
Configuration
Turn summary generation is configured under conversation.turn_summary:
[conversation.turn_summary.generate]
auto = true
batch_size = 5
# Optional. Defaults to conversation.title.generate.model, then assistant.model.
[conversation.turn_summary.generate.model]
id = "ollama/llama3.2:3b"auto(defaulttrue): whetherjp queryspawns background tasks to summarize completed turns that lack a summary.batch_size(default5): maximum number of unsummarized completed turns to process in a singlejp queryinvocation, bounding token and compute cost when resuming a long legacy conversation.model(optionalModelConfig): the model and parameters used for turn summarization.
Model Resolution Chain
Most users who configure a fast or local model for conversation.title.generate want the same model for turn summaries. Model resolution therefore follows a three-step fallback chain:
conversation.turn_summary.generate.model(if configured)conversation.title.generate.model(if configured)assistant.model.idwith freshParametersConfig::default()
As in jp_llm::title::resolve_model, if the resolved model config leaves parameters.reasoning unset, it defaults to ReasoningEffort::Low with exclude = true.
Data Model: TurnStart
TurnStart in jp_conversation::event::turn changes from a unit struct to a struct carrying an optional summary:
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct TurnStart {
/// A single-line summary of the turn.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
}In events.json, TurnStart serializes with its wrapper event_id (RFD 097) and summary on the same JSON object:
{
"event_id": "k3m9x2a",
"type": "turn_start",
"timestamp": "2026-10-30 10:00:00.0",
"summary": "Add retry backoff to stream handler"
}When summary is None, the key is omitted and the serialized form is byte-for-byte identical to today's TurnStart. Legacy files without summary deserialize with summary: None.
Why TurnStart In-Place Update Preserves Stream Invariants
ConversationStream treats provider-visible events as append-only (RFD 064) because mutating them invalidates cryptographic thinking signatures, alters provider prompt caches, and destroys raw history.
None of those constraints apply to TurnStart:
TurnStartis not provider-visible (EventKind::is_provider_visible()isfalse). It never enters aThreadand is never sent to an LLM provider.- Storing
summaryonTurnStartco-locates the turn's summary with the turn'sevent_id. - Every turn-slicing operation (
retain_turns,retain_last_turns,fork,sanitize,normalize_turn_starts) already keepsTurnStartattached to its turn, so summaries are retained or dropped with their turn automatically. PersistBackend::writealready writesevents.jsonatomically via a temp-file rename when flushing aConversationMut.
Stream and Turn API
Turn<'a> (in jp_conversation::stream::turn_iter) gains three accessors:
impl<'a> Turn<'a> {
/// The `EventId` of this turn's leading `TurnStart` event, if present.
#[must_use]
pub fn id(&self) -> Option<&'a EventId>;
/// The single-line summary on this turn's leading `TurnStart`, if present.
#[must_use]
pub fn summary(&self) -> Option<&'a str>;
/// Fallback summary derived from the first non-empty line of the turn's
/// first `ChatRequest`.
#[must_use]
pub fn fallback_summary(&self) -> Option<String>;
}ConversationStream gains targeted setters that address the turn by its TurnStart EventId rather than by positional turn index, and clears the trailing turn's summary when pop removes events from it:
impl ConversationStream {
/// Normalize and store `summary` on the `TurnStart` entry with
/// `event_id == *turn_id`.
///
/// Returns `true` if a non-blank summary was stored on a matching `TurnStart`.
pub fn set_turn_summary(&mut self, turn_id: &EventId, summary: impl AsRef<str>) -> bool;
/// Clear the summary on the `TurnStart` entry with `event_id == *turn_id`.
pub fn clear_turn_summary(&mut self, turn_id: &EventId) -> bool;
}Addressing by EventId decides which TurnStart a summary is written to. If the turn was trimmed or replayed away while the task ran, the setter finds no match and the summary is dropped.
Summary Freshness
A matching event_id does not prove the summary still describes the turn. RFD 097 keeps an entry's event_id when its content is edited, so a turn's content can change underneath its TurnStart. This happens in two ways:
- While a summary is in flight. The task captures its turns while the query holds the conversation lock, but
syncreacquires the lock only after the background-task drain, which can wait up to RFD 107's shutdown budget. Another process can edit the conversation in between.synctherefore compares each turn's fingerprint (a hash of the stripped transcript the model received for that turn, event IDs included) against the same fingerprint recomputed from the freshly locked stream, and drops any result whose turn no longer matches. That turn staysNoneand is retried on the next query. - After a summary is stored. Catch-up only nominates turns whose
summaryisNone, so a stored summary is never revisited. Managed edits clear it (Consumer Contract). Raw edits throughjp conversation edit --eventsare the user's responsibility: removing thesummarykey from a turn'sturn_startentry after changing that turn's content makes catch-up regenerate it.
What is Summarized: Bounded Turn Transcripts
To produce an accurate summary, the summarizer needs the complete turn: the user's ChatRequest, the tools that were called and whether they succeeded, and the assistant's ChatResponse::Message.
However, raw coding turns routinely contain tens of kilobytes of tool output (file reads, grep matches, diffs), long ChatResponse::Reasoning blocks, or large pasted logs in ChatRequest. Sending raw turns to a small local model overflows its context window or causes multi-second prompt-evaluation latency.
Candidate turns are transcribed directly from raw events in ConversationStream (ignoring compaction overlays so turns inside a compaction summary are still read from their own events) into compact XML <turn id="..."> blocks:
ChatResponse::Reasoning,TurnStart, and inquiry events are omitted.ToolCallRequestis rendered as<tool_call name="..."/>(arguments omitted), andToolCallResponseis rendered as<tool_result/>or<tool_error/>.ChatRequestandChatResponsemessages longer thanMAX_MESSAGE_CHARS(4,000 characters) are truncated to their first and last halves separated by[…].
This shrinks a 50k-token coding turn down to a few hundred tokens while keeping the user prompt, every tool name and outcome, and the assistant's final answer.
When Summarization Runs: Lagged Background Catch-Up
TaskHandler::sync (RFD 107) runs at the end of jp_cli::lib::run and blocks process exit while waiting for spawned tasks to finish their run phase (up to the 10-second shutdown budget, displaying ⏱ Finishing background tasks… after 1 second).
TitleGeneratorTask hides its latency because it is spawned beforerun_turn starts and runs concurrently with the main assistant stream. A task spawned after run_turn finishes has nothing to hide behind: it blocks the user's terminal prompt at the end of every turn.
To get full-turn accuracy (request + tool calls + response) without a post-turn round-trip, turn summarization uses lagged background catch-up: Turn $N$ is summarized concurrently with Turn $N+1$'s assistant stream.
Turn 1 (jp query "...")
├─ Main thread: streams Turn 1 from assistant model
└─ Exit: Turn 1 persisted with TurnStart { summary: None } (no task to wait for)
Turn 2 (jp query "...")
├─ Spawn TurnSummaryTask for completed unsummarized turns (Turn 1)
├─ Background: summarizes Turn 1 (request + stripped tools + response)
├─ Main thread: streams Turn 2 from assistant model
└─ Exit (TaskHandler::sync): writes Turn 1's summary to TurnStart
(waits only if the summary request outlasts Turn 2)The overlap hides the summary request behind the assistant's turn; it does not remove it. When the request outlasts the turn (a cold local model, a short answer, or a catch-up batch on a legacy conversation), TaskHandler::sync waits for it under RFD 107's budget: up to 10 seconds, then cancellation, with the finishing-tasks line shown after 1 second.
The task is not cancelled when the turn ends. With a summary model slower than the user's turns, cancelling at turn end would discard every result, and each query would request the same batch again.
Spawn (Main Thread)
In crates/jp_cli/src/cmd/query.rs, alongside the existing TitleGeneratorTask spawn (before setup.flush() and inputs.run()):
- Check
cfg.conversation.turn_summary.generate.auto && ctx.term.args.persist. - Scan
stream(which contains all turns prior to the new turn about to start) for completed turns whoseTurnStarthassummary: None. - If any exist, construct
TurnSummaryTaskwith thestreamsnapshot and the candidates'event_ids, and spawn it onctx.task_handler. Choosing which candidates fit happens inrun, once the summary model's context window is known.
Run (Background)
TurnSummaryTask::run executes concurrently with the active turn:
Resolve the summary model (see Model Resolution Chain) and its context window.
Build each candidate's stripped transcript (see What is Summarized).
Exclude candidates whose transcript alone exceeds the request budget.
Pack the remaining candidates whole, newest first, until either
batch_sizeturns or the budget is reached. A turn is either in the request entirely or not at all.Request structured JSON output with a
summariesobject keyed by each packed turn'sTurnStartevent_id:jsonjson{ "summaries": { "k3m9x2a": "Add retry backoff to stream handler" } }Keep only entries whose
turn_idis in the packed set, discarding anything else the model returns, and keep each packed turn's fingerprint forsync.Observe the
CancellationTokenviajp_macro::select!, returning early if cancelled.
jp_llm::window::truncate_to_fit is not used. It drops the oldest events and empties a stream whose ChatRequests did not survive, which suits a title request appended after fitting, but not a batch in which every returned label must describe a turn the model actually received.
Sync (Main Thread)
In TurnSummaryTask::sync, after Query::run has released the conversation lock:
- Acquire a non-blocking lock on the conversation via
Workspace::acquire_conversationandWorkspace::lock_conversation(same asTitleGeneratorTask::sync). IfLockResult::AlreadyLocked, log and skip; the turns will be retried on the next run. - Call
conv.update_events. For each returned summary, recompute the turn's fingerprint from the locked stream and skip the summary if the turn is gone or the fingerprint differs (see Summary Freshness). Invokestream.set_turn_summary(&turn_id, summary)for the rest. - Flush
conv.
Drawbacks
- One-turn lag on persisted summaries. Immediately after Turn $N$ finishes, Turn $N$'s
TurnStart.summaryon disk is stillNone; it is populated when Turn $N+1$ runs. - A slow summary model can delay exit. When the summary request outlasts the turn it overlaps, the CLI waits for it under RFD 107's shutdown budget. This is most likely while a legacy conversation catches up. If routine queries show the finishing-tasks line, the trade-off flips: cancel the task when the turn ends and accept the repeated requests.
- Oversized turns stay unsummarized. A turn whose stripped transcript alone exceeds the summary model's window keeps the mechanical fallback. It is re-estimated, never sent, on every query.
- Additional background LLM request per turn. From Turn 2 onward, each
jp querymakes a background call to the summary model. Although mechanical stripping keeps the input small (~200-500 tokens per turn), this still spends tokens on remote providers or compute on local models. TurnStartis no longer a unit struct. Pattern matches and constructors using bareTurnStartmust be updated toTurnStart::default()orTurnStart { .. }.
Alternatives
1. Introduce a TurnEnd Event
Add an EventKind::TurnEnd(TurnEnd) variant appended at the end of each turn to hold summary and future turn-scoped metadata (such as token usage).
Rejected for four reasons:
- Split identity.
TurnStartalready carries the turn'sevent_id(RFD 097). Putting the summary onTurnEndsplits a turn's ID and its summary across two separate stream entries. - Broken invariants on incomplete and edited turns. As RFD 023 noted when rejecting
TurnEnd, turns can be interrupted, crash mid-stream, or be edited by hand. A turn without aTurnEndstill needs to appear injp conversation edit -i, and every stream-mutating method (pop,retain,sanitize) would have to maintain matchingTurnStart/TurnEndpairs. - Does not avoid in-place mutation. When a summary is generated in a background task on the next turn or regenerated on demand,
TurnEndhas already been persisted, so updating the summary still rewrites a historical entry. - Token usage is per-response, not per-turn. A single turn can span multiple tool-call cycles, retries, continuations, and inquiry models. Usage counters belong on provider-response records rather than a single
TurnEndevent.
2. Summarize Only ChatRequest Concurrently During Turn $N$
Spawn the summary task at the start of Turn $N$ with only Turn $N$'s ChatRequest, eliminating the one-turn lag.
Rejected because user prompts in multi-turn coding sessions are frequently brief continuation directives ("continue", "do option 2", "fix it"). Without the tool calls and assistant response, the generated summary cannot describe what the turn actually did.
3. Summarize Full Turn $N$ Post-Turn Before Process Exit
Spawn the summary task after run_turn completes and wait for it in drain_background_tasks.
Rejected because TaskHandler::sync blocks CLI exit. Waiting for an LLM call after the assistant's final response has already been printed delays the return of the shell prompt on every query, by the full duration of the summary request. Lagged catch-up waits only for the part of the request that outlasts the next turn.
4. Two-Stage Progressive Summarization
Generate a provisional request-only summary for Turn $N$ concurrently during Turn $N$, then overwrite it with a full-turn summary during Turn $N+1$.
Not adopted for the initial design. It eliminates the one-turn lag on disk without a post-turn round-trip, but requires tracking Provisional vs Final summary state on TurnStart and summarizes every turn twice. Can be added later if consumers find the most recent turn's missing summary noticeable.
5. Store Turn Summaries in metadata.json or a Sidecar File
Store a HashMap<EventId, String> in metadata.json or in a separate annotations file alongside events.json.
Rejected because it separates a turn's summary from its events across files and requires every stream-pruning operation (fork --last, retain_turns, manual edits) to garbage-collect orphaned EventId keys.
Non-Goals
- Replacing compaction summaries (RFD 064). Turn summaries are single-line labels for human navigation and never replace events in a
Thread. - Persisting token usage or cost metrics. Durable usage tracking requires per-response identity and is out of scope.
- Building a consumer. Interactive conversation editing, including any on-demand generation before opening
$EDITOR, is a separate feature. This RFD provides the storage and generation pipeline and defines the Consumer Contract such an editor must follow.
Risks and Open Questions
- Prompt cache isolation. Unlike
jp_llm::title::generate, which clones and rebases the conversation stream,TurnSummaryTaskconstructs a standalone request containing only the mechanically stripped target turns. This avoids polluting or invalidating the main assistant's prompt cache, which should be verified across providers during implementation. - Summary quality on stripped tool calls.
ToolCallPolicy::Strippreserves tool names, call IDs, and success/error status while dropping response bodies. If summaries lack specificity when the assistant's finalChatResponseis terse (e.g."Done."),ToolCallPolicy::Strip { request: false, response: true }(keeping tool request arguments such as file paths while stripping large response bodies) is a cheap adjustment. - Stale summaries after raw edits. Detection of raw
--eventsedits relies on the user clearingsummary. Persisting a fingerprint onTurnStartwould detect them automatically, at the cost of an on-disk field that exists only for an escape hatch. Not adopted unless stale labels show up in practice. - Naming in the ubiquitous language. "Summary" is already used by RFD 064 for compaction summaries. During discussion we should confirm whether
turn_summary/TurnStart.summaryis clear enough or ifTurnStart.title(conversation.title.turn) is preferable.
Implementation Plan
Phase 1: TurnStart Data Model and Stream Accessors
- Change
TurnStartinjp_conversation::event::turnfrom a unit struct toTurnStart { pub summary: Option<String> }with#[derive(Default)]andskip_serializing_if = "Option::is_none". - Update
TurnStartconstruction and pattern-match sites across the workspace. - Add
Turn::id(&self) -> Option<&EventId>andTurn::summary(&self) -> Option<&str>incrates/jp_conversation/src/stream/turn_iter.rs. - Add
ConversationStream::set_turn_summary(&mut self, turn_id: &EventId, summary: String) -> bool. - Unit tests for serde round-trip (with and without
summary), backward compatibility with legacyturn_startJSON, andset_turn_summarybyEventId.
Independent. Can be reviewed and merged on its own.
Phase 2: Configuration
- Add
TurnSummaryConfigandTurnSummaryGenerateConfig(auto: bool,batch_size: usize,model: Option<ModelConfig>) underConversationConfiginjp_config. - Implement
AssignKeyValue,PartialConfigDelta,FillDefaults, andToPartial. - Unit tests for config loading,
--cfgassignment, and delta generation.
Independent. Can be reviewed and merged in parallel with Phase 1.
Phase 3: Turn Summarizer in jp_llm
- Add
jp_llm::turn_summary(mirroringjp_llm::title):resolve_modelimplementing the fallback chain (turn_summary.generate.model->title.generate.model->assistant.modelwith low/excluded reasoning).- Standalone transcript builder: copies candidate turns into a fresh stream without the conversation's overlays, applies
ReasoningPolicy::StripandToolCallPolicy::Strip, and projects. - Turn fingerprint over a turn's stripped transcript.
- Turn-granular packing: size estimate, exclusion of oversized turns, and newest-first packing bounded by
batch_sizeand the request budget. - Structured output schema (
summaries: [{ turn_id, summary }]) and prompt instructions (concise, factual, single line, <= 60 chars), with returned IDs filtered to the packed set. - Mechanical fallback helper (
mechanical_turn_summary(turn: &Turn) -> Option<String>) extracting and truncating the first line of the turn's firstChatRequest.
- Unit tests for model resolution, mechanical stripping, schema extraction, and mechanical fallback. Packing tests cover a candidate inside an existing summary compaction (it is summarized from its raw events), an oversized turn (excluded without a request), and a response naming an unrequested ID (discarded).
Depends on Phase 1 and Phase 2.
Phase 4: TurnSummaryTask and jp query Integration
- Implement
TurnSummaryTaskinjp_task::task(orjp_cli):run: callsjp_llm::turn_summary::generateinside aselect!againsttoken.cancelled().sync: locks the conversation viaWorkspace::lock_conversation, drops results whose turn fingerprint changed, writes the rest viaConversationStream::set_turn_summary, and flushes.
- Spawn
TurnSummaryTaskincrates/jp_cli/src/cmd/query.rswith the completed turns lacking a summary whenauto && persist. - Extend the
jp conversation edit --eventslong help: clear a turn'ssummaryafter changing its content. - Integration tests verifying that Turn 1 is summarized during Turn 2 and persisted on
TurnStart, and that an edit to Turn 1 between spawn and sync leaves its summaryNone.
Depends on Phase 3.
References
- RFD 020 - Parallel Conversations (conversation locking in
Task::sync) - RFD 023 - Resumable Conversation Turns (prior rejection of
TurnEnd) - RFD 053 - Auto-Refresh Conversation Titles (bounded background batching pattern)
- RFD 064 - Non-Destructive Conversation Compaction (mechanical
ReasoningPolicy::StripandToolCallPolicy::Strippolicies) - RFD 097 - Stable Event Identifiers (
event_idonTurnStart) - RFD 107 - Background Task Infrastructure (
jp_task::TaskandTaskHandler)