Skip to content

feat(cli): add session recap with /recap and auto-show on return#3434

Merged
wenshao merged 10 commits into
QwenLM:mainfrom
wenshao:feat/session-recap
Apr 19, 2026
Merged

feat(cli): add session recap with /recap and auto-show on return#3434
wenshao merged 10 commits into
QwenLM:mainfrom
wenshao:feat/session-recap

Conversation

@wenshao

@wenshao wenshao commented Apr 19, 2026

Copy link
Copy Markdown
Collaborator

Motivation

Opening an old conversation a few days later, you have to scroll through pages
to remember what you were doing and what came next. /resume only reloads
the messages — there is no built-in "where did I leave off" summary.

This PR adds a short recap (1-3 sentences) that appears either on demand or
when you return to the terminal after stepping away.

What this adds

1. /recap slash command

Generates and displays the recap immediately:

> /recap

❯ Refactoring packages/core/src/services/loopDetectionService.ts to address
  long-session OOM caused by unbounded streamContentHistory and contentStats.
  The next step is to implement option B (LRU sliding window with FNV-1a)
  pending user confirmation.

The recap is rendered in theme.text.secondary (dim grey) with a
prefix — visually distinct from real assistant replies, so it doesn't get
confused for new model output.

2. Auto-trigger on focus return

Uses the existing useFocus hook (DECSET 1004 focus protocol). When the
terminal has been blurred for 5+ minutes and gets focused again, a
recap is generated and added to the history. Conditions:

  • streamingState === Idle — never interrupts an active turn.
  • One recap per blur cycle (re-blur/re-focus inside the threshold does
    nothing).
  • Re-checked on completion as well, so a turn that starts while the LLM
    call is in flight will suppress the late-arriving recap.

Controlled by general.showSessionRecap (default: true). The manual
/recap command works regardless of the setting.

Implementation

Layer File Purpose
Service packages/core/src/services/sessionRecap.ts One-shot LLM call against fastModel (falls back to main model) with tools: [], custom systemInstruction, and a 30-message window
Hook packages/cli/src/ui/hooks/useAwaySummary.ts Tracks blur duration via useFocus, fires recap on qualifying focus return, aborts on unmount
Command packages/cli/src/ui/commands/recapCommand.ts Manual /recap, refuses when a turn is pending
UI StatusMessages.tsx + HistoryItemDisplay.tsx + types.ts New away_recap history item with dim renderer
Setting settingsSchema.ts general.showSessionRecap boolean (default true)
Wire-up AppContainer.tsx, BuiltinCommandLoader.ts, core/src/index.ts Hook activation + command registration + service export

Key design choices

Filter tool calls/responses out of the recap context. A single
functionResponse can hold a 10K+ token file dump that drowns the recap
LLM in irrelevant detail and inflates cost. filterToDialog keeps only
text parts of user/model messages.

Structured output with extraction. The model is instructed to wrap its
answer in <recap>...</recap>; the extractor returns empty (and the
service returns null) when the tag is missing, instead of guessing. This
prevents reasoning preamble from leaking into the UI — we observed GLM
models prepending a thinking paragraph before the answer otherwise.

Best-effort, never breaks main flow. All failures (network error,
malformed response, unavailable client) are silently caught and logged
through a scoped debugLogger('SESSION_RECAP'). The user never sees an
error from the recap path.

Token-tight one-shot call. tools: [], maxOutputTokens: 300,
temperature: 0.3, custom systemInstruction overriding the main agent
prompt. Uses fastModel when configured (e.g., qwen3-coder-flash),
falling back to the session model.

What's intentionally not in this PR

  • Progress indicator UI for /recap — 3-5s wait is tolerable; can be
    added later with a pending item pattern like /summary.
  • Automated tests — service is small (~150 lines) and end-to-end
    tested manually; tests can be added separately.
  • QWEN_CODE_ENABLE_AWAY_SUMMARY env var — covers the "telemetry
    disabled" case in Claude Code's equivalent. Not relevant to Qwen Code's
    current telemetry model.
  • Auto-recap on /resume — would be a natural follow-up; useResumeCommand
    has a clean hook point.

Test plan

  • npm run typecheck — 0 errors
  • npm run lint — clean on all touched files
  • Existing useFocus and BuiltinCommandLoader tests still pass
  • Manual end-to-end: /help lists /recap; /recap after a
    multi-turn conversation produces clean dim-color output without
    preamble; tool calls in history are correctly filtered
  • Auto-trigger via real terminal blur ≥ 5 min (requires manual run
    outside CI — tmux focus events are not a reliable proxy)

Users often open an old session days later and need to scroll through
pages to remember where they left off. This change adds a short
"where did I leave off" recap — a 1-3 sentence summary generated by
the fast model — so they can resume without re-reading the history.

Two triggers:
- /recap: manual slash command.
- Auto: when the terminal has been blurred for 5+ minutes and gets
  focused again (uses the existing DECSET 1004 focus protocol via
  useFocus). Gated on streamingState === Idle so it never interrupts
  an active turn. Only fires once per blur cycle.

The recap is rendered in dim color with a chevron prefix, visually
distinct from assistant replies. A new `general.showSessionRecap`
setting controls the auto-trigger (default on). /recap works
independent of the setting.

Implementation notes:
- generateSessionRecap uses fastModel (falls back to main model),
  tools: [], maxOutputTokens: 300, and a tight system prompt. It
  strips tool calls / responses from history before sending — tool
  responses can hold 10K+ tokens of file content that drown the recap
  in irrelevant detail. The 30-message window respects turn boundaries
  (slice never starts on a dangling model/tool response).
- Output is wrapped in <recap>...</recap> tags; the extractor returns
  empty (skips render) if the tag is missing, preventing model
  reasoning from leaking into the UI.
- All failures are silent (return null) and logged via a scoped
  debugLogger; recap is best-effort and must never break main flow.
- /recap refuses to run while a turn is pending.
Comment thread packages/cli/src/ui/hooks/useAwaySummary.ts
If the user disables showSessionRecap while an auto-recap LLM call is
already in flight, the previous code returned early without aborting.
The pending .then would still pass its idle/abort guards and append the
recap, producing an unwanted message after the user has opted out.

Abort the controller and clear it eagerly so the resolved promise no
longer adds to history.
Comment thread packages/cli/src/ui/commands/recapCommand.ts Outdated
Comment thread packages/cli/src/ui/hooks/useAwaySummary.ts
Two related issues from review:

1. /recap was only refusing when ui.pendingItem was set, but a normal
   model reply runs with streamingState === Responding and a null
   pendingItem. Invoking /recap mid-stream would generate a recap from
   a partial conversation and insert it between the user prompt and
   the assistant reply.

2. useAwaySummary cleared blurredAtRef before checking isIdle, so if
   focus returned during a still-streaming turn (after a >5min blur)
   the recap was permanently dropped — there was no later retry when
   the turn became idle, because isIdle was not in the effect deps.

Fixes:
- Expose isIdleRef on CommandContext.ui (mirrors btwAbortControllerRef
  pattern). Plumb it from AppContainer through useSlashCommandProcessor.
- recapCommand now refuses when isIdleRef.current is false OR
  pendingItem is non-null.
- useAwaySummary preserves blurredAtRef on the !isIdle bail and adds
  isIdle to the effect deps, so the trigger re-evaluates when the
  current turn finishes.
- Brief blurs (< AWAY_THRESHOLD_MS) still reset blurredAtRef.

Also seeds isIdleRef in nonInteractiveUi and mockCommandContext so the
new field has a sensible default outside the interactive UI.
Comment thread packages/core/src/services/sessionRecap.ts
wenshao added 3 commits April 19, 2026 16:02
- User docs: add /recap to the Session and Project Management table in
  features/commands.md and a dedicated subsection covering manual use,
  the auto-trigger, the dim-color rendering, and the fast-model tip.
- User docs: add general.showSessionRecap row to the configuration
  settings reference.
- Design doc: docs/design/session-recap/session-recap-design.md covers
  motivation, the two trigger paths, the per-file architecture, prompt
  design with the <recap> tag and three-tier extractor, history
  filtering rationale (functionResponse can be 10K+ tokens), the
  useAwaySummary state machine, the isIdleRef gating for /recap, model
  selection, observability, and out-of-scope items.
filterToDialog kept any non-empty text part, but @google/genai's Part
type also marks model reasoning with part.thought / part.thoughtSignature.
That hidden chain-of-thought was being fed to the recap LLM and could
get summarized as if it were user-visible dialogue.

Drop parts where either flag is set. Update the design doc's
History 过滤 section to call this out alongside the existing
tool-call/response rationale.
…chine, sharpen UX wording

Audit of the session recap docs against the implementation found three
issues worth fixing:

- Design doc claimed debug logs were enabled via a QWEN_CODE_DEBUG_LOGGING
  env var. That var does not exist; debug logs are written to
  ~/.qwen/debug/<sessionId>.txt by default, gated by QWEN_DEBUG_LOG_FILE.
  Replace with the accurate path + opt-out behavior, and tell the reader
  to grep for the [SESSION_RECAP] tag.
- Design doc's useAwaySummary state machine table was missing the
  isFocused && blurredAtRef === null path (taken on first render and
  right after a brief-blur reset). Add the row.
- User doc's "Refuses to run ... failures are silent" line conflated the
  inline-error refusal with silent generation failures, and "(when the
  conversation is idle)" used internal jargon. Split the two cases and
  spell out what "idle" means, including the wait-then-fire behavior
  when focus returns mid-turn.
Comment thread docs/users/features/commands.md Outdated
wenshao added 3 commits April 19, 2026 16:15
…e modes

The previous wording said "Generation/network failures are silent — the
recap simply does not appear", but recapCommand returns a user-facing
info message ("Not enough conversation context for a recap yet.") in
exactly that path, and also returns inline messages for the
config-not-loaded and busy-turn guards.

Only the auto-trigger path is truly silent (it just skips addItem when
generateSessionRecap returns null). Split the two paths in the doc so
the manual command's "always responds with something" behavior is
distinguished from the auto-trigger's no-op-on-failure behavior.
Two doc-vs-code mismatches in the design doc's "System Prompt" section,
caught with the same lens as yiliang114's failure-mode review:

- The bullet list claimed RECAP_SYSTEM_PROMPT forbids "推测用户意图"
  and "用 'you' 称呼用户". Those rules existed in an early draft but
  were dropped when the <recap> tag rules were added; the current
  prompt has no such restrictions. Replace with the actual rules and
  add a "与 RECAP_SYSTEM_PROMPT 一一对应" marker so future edits stay
  in sync.
- The doc said systemInstruction "覆盖" the main agent prompt. True
  for the agent prompt portion, but GeminiClient.generateContent
  internally calls getCustomSystemPrompt which appends user memory
  (QWEN.md / 自动 memory) as a suffix. Spell that out — the final
  system prompt is recap prompt + user memory, which is actually
  useful project context for the recap.
The repo convention for docs/design is English (7 of 8 existing files;
auto-memory/memory-system.md is the only Chinese one). The first version
of this design doc followed the auto-memory example, which turned out
to be the wrong sample.

Translate to English while preserving the existing structure, the
state-machine table, the prompt-vs-doc 1:1 alignment, the
QWEN_DEBUG_LOG_FILE description, and the failure-mode notes added in
prior commits.
Comment thread packages/cli/src/ui/commands/recapCommand.ts
The interactive success path inserts the away_recap history item
directly via ui.addItem and then returned `{type: 'message',
messageType: 'info', content: ''}`. The slash-command processor's
'message' case unconditionally calls addMessage, which adds another
HistoryItemInfo with empty text. The empty info renders as nothing
(StatusMessage early-returns null), but it still bloats the in-memory
history list and shows up in /export and saved sessions.

Return void on the interactive success path and on the abort path so
the processor's `if (result)` check skips the message-handler branch
entirely. Widen the action's return type to `void | SlashCommandActionReturn`
to match (same shape as btwCommand).
@wenshao wenshao requested a review from yiliang114 April 19, 2026 08:53

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found. LGTM! ✅ — gpt-5.4 via Qwen Code /review

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. The follow-up fixes addressed the review feedback, and the current head passes the full CI matrix.

@wenshao wenshao merged commit 60a6dfc into QwenLM:main Apr 19, 2026
13 checks passed
mabry1985 added a commit to protoLabsAI/protoCLI that referenced this pull request May 3, 2026
When the terminal regains focus after >= awayThresholdMinutes (default 5)
of sustained blur, generate a `/recap`-style "where you left off"
summary and append it to history. Off by default; enable via
`general.showSessionRecap`.

Adapted from the auto-recap-on-return feature in QwenLM/qwen-code's
`useAwaySummary` hook (introduced in QwenLM#3434, finalized in QwenLM#3482) — but
takes a focused slice rather than a full cherry-pick, since upstream's
version churned through a sticky-banner placement that QwenLM#3482 then tore
back out, and most of the conflict surface was unrelated layout work
we don't have.

What this lands:

- `packages/core/src/services/sessionRecap.ts` — thin wrapper around
  `generateRecap` that pulls history off the GeminiClient and returns
  `{ text } | null`, matching upstream's API so the hook is portable.
- `packages/cli/src/ui/hooks/useAwaySummary.ts` — port of the upstream
  hook. Uses our existing `HistoryItemRecap` (`type: 'recap'`) instead
  of the upstream-only `away_recap` type so manual `/recap` and the
  auto-fire path share one render path.
- Settings: `general.showSessionRecap` (boolean, default false) and
  `general.sessionRecapAwayThresholdMinutes` (number, default 5).
- Wired in `AppContainer.tsx` next to `useFocus()`; `isIdle` is
  derived from `streamingState === StreamingState.Idle`.
- Mirrors Claude Code's dedup gating: needs at least 3 user messages
  total before firing, and at least 2 new user messages since the
  previous recap before another can fire — prevents back-to-back
  near-duplicate recaps after a brief alt-tab cycle with no work
  between.

Cleanup: stripped vestigial `awayRecapItem` / `setAwayRecapItem`
fields from `UIStateContext` that referenced a never-imported
`HistoryItemAwayRecap` type. No consumers; the auto-fire path
addItem's directly into history instead of holding a sticky banner.

Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
xaelistic pushed a commit to xaelistic/qwen-code that referenced this pull request Jun 7, 2026
…nLM#3434)

* feat(cli): add session recap with /recap and auto-show on return

Users often open an old session days later and need to scroll through
pages to remember where they left off. This change adds a short
"where did I leave off" recap — a 1-3 sentence summary generated by
the fast model — so they can resume without re-reading the history.

Two triggers:
- /recap: manual slash command.
- Auto: when the terminal has been blurred for 5+ minutes and gets
  focused again (uses the existing DECSET 1004 focus protocol via
  useFocus). Gated on streamingState === Idle so it never interrupts
  an active turn. Only fires once per blur cycle.

The recap is rendered in dim color with a chevron prefix, visually
distinct from assistant replies. A new `general.showSessionRecap`
setting controls the auto-trigger (default on). /recap works
independent of the setting.

Implementation notes:
- generateSessionRecap uses fastModel (falls back to main model),
  tools: [], maxOutputTokens: 300, and a tight system prompt. It
  strips tool calls / responses from history before sending — tool
  responses can hold 10K+ tokens of file content that drown the recap
  in irrelevant detail. The 30-message window respects turn boundaries
  (slice never starts on a dangling model/tool response).
- Output is wrapped in <recap>...</recap> tags; the extractor returns
  empty (skips render) if the tag is missing, preventing model
  reasoning from leaking into the UI.
- All failures are silent (return null) and logged via a scoped
  debugLogger; recap is best-effort and must never break main flow.
- /recap refuses to run while a turn is pending.

* fix(cli): abort in-flight recap when showSessionRecap is disabled

If the user disables showSessionRecap while an auto-recap LLM call is
already in flight, the previous code returned early without aborting.
The pending .then would still pass its idle/abort guards and append the
recap, producing an unwanted message after the user has opted out.

Abort the controller and clear it eagerly so the resolved promise no
longer adds to history.

* fix(cli): gate /recap and auto-recap on streaming idle state

Two related issues from review:

1. /recap was only refusing when ui.pendingItem was set, but a normal
   model reply runs with streamingState === Responding and a null
   pendingItem. Invoking /recap mid-stream would generate a recap from
   a partial conversation and insert it between the user prompt and
   the assistant reply.

2. useAwaySummary cleared blurredAtRef before checking isIdle, so if
   focus returned during a still-streaming turn (after a >5min blur)
   the recap was permanently dropped — there was no later retry when
   the turn became idle, because isIdle was not in the effect deps.

Fixes:
- Expose isIdleRef on CommandContext.ui (mirrors btwAbortControllerRef
  pattern). Plumb it from AppContainer through useSlashCommandProcessor.
- recapCommand now refuses when isIdleRef.current is false OR
  pendingItem is non-null.
- useAwaySummary preserves blurredAtRef on the !isIdle bail and adds
  isIdle to the effect deps, so the trigger re-evaluates when the
  current turn finishes.
- Brief blurs (< AWAY_THRESHOLD_MS) still reset blurredAtRef.

Also seeds isIdleRef in nonInteractiveUi and mockCommandContext so the
new field has a sensible default outside the interactive UI.

* docs: document /recap command, showSessionRecap setting, and design

- User docs: add /recap to the Session and Project Management table in
  features/commands.md and a dedicated subsection covering manual use,
  the auto-trigger, the dim-color rendering, and the fast-model tip.
- User docs: add general.showSessionRecap row to the configuration
  settings reference.
- Design doc: docs/design/session-recap/session-recap-design.md covers
  motivation, the two trigger paths, the per-file architecture, prompt
  design with the <recap> tag and three-tier extractor, history
  filtering rationale (functionResponse can be 10K+ tokens), the
  useAwaySummary state machine, the isIdleRef gating for /recap, model
  selection, observability, and out-of-scope items.

* fix(core): exclude thought parts from session recap context

filterToDialog kept any non-empty text part, but @google/genai's Part
type also marks model reasoning with part.thought / part.thoughtSignature.
That hidden chain-of-thought was being fed to the recap LLM and could
get summarized as if it were user-visible dialogue.

Drop parts where either flag is set. Update the design doc's
History 过滤 section to call this out alongside the existing
tool-call/response rationale.

* docs(session-recap): correct debug-logging guidance, fill in state machine, sharpen UX wording

Audit of the session recap docs against the implementation found three
issues worth fixing:

- Design doc claimed debug logs were enabled via a QWEN_CODE_DEBUG_LOGGING
  env var. That var does not exist; debug logs are written to
  ~/.qwen/debug/<sessionId>.txt by default, gated by QWEN_DEBUG_LOG_FILE.
  Replace with the accurate path + opt-out behavior, and tell the reader
  to grep for the [SESSION_RECAP] tag.
- Design doc's useAwaySummary state machine table was missing the
  isFocused && blurredAtRef === null path (taken on first render and
  right after a brief-blur reset). Add the row.
- User doc's "Refuses to run ... failures are silent" line conflated the
  inline-error refusal with silent generation failures, and "(when the
  conversation is idle)" used internal jargon. Split the two cases and
  spell out what "idle" means, including the wait-then-fire behavior
  when focus returns mid-turn.

* docs(session-recap): correctly describe /recap vs auto-trigger failure modes

The previous wording said "Generation/network failures are silent — the
recap simply does not appear", but recapCommand returns a user-facing
info message ("Not enough conversation context for a recap yet.") in
exactly that path, and also returns inline messages for the
config-not-loaded and busy-turn guards.

Only the auto-trigger path is truly silent (it just skips addItem when
generateSessionRecap returns null). Split the two paths in the doc so
the manual command's "always responds with something" behavior is
distinguished from the auto-trigger's no-op-on-failure behavior.

* docs(session-recap): align prompt-rules section with the actual prompt

Two doc-vs-code mismatches in the design doc's "System Prompt" section,
caught with the same lens as yiliang114's failure-mode review:

- The bullet list claimed RECAP_SYSTEM_PROMPT forbids "推测用户意图"
  and "用 'you' 称呼用户". Those rules existed in an early draft but
  were dropped when the <recap> tag rules were added; the current
  prompt has no such restrictions. Replace with the actual rules and
  add a "与 RECAP_SYSTEM_PROMPT 一一对应" marker so future edits stay
  in sync.
- The doc said systemInstruction "覆盖" the main agent prompt. True
  for the agent prompt portion, but GeminiClient.generateContent
  internally calls getCustomSystemPrompt which appends user memory
  (QWEN.md / 自动 memory) as a suffix. Spell that out — the final
  system prompt is recap prompt + user memory, which is actually
  useful project context for the recap.

* docs(session-recap): translate design doc to English

The repo convention for docs/design is English (7 of 8 existing files;
auto-memory/memory-system.md is the only Chinese one). The first version
of this design doc followed the auto-memory example, which turned out
to be the wrong sample.

Translate to English while preserving the existing structure, the
state-machine table, the prompt-vs-doc 1:1 alignment, the
QWEN_DEBUG_LOG_FILE description, and the failure-mode notes added in
prior commits.

* fix(cli): drop empty info return from /recap interactive success path

The interactive success path inserts the away_recap history item
directly via ui.addItem and then returned `{type: 'message',
messageType: 'info', content: ''}`. The slash-command processor's
'message' case unconditionally calls addMessage, which adds another
HistoryItemInfo with empty text. The empty info renders as nothing
(StatusMessage early-returns null), but it still bloats the in-memory
history list and shows up in /export and saved sessions.

Return void on the interactive success path and on the abort path so
the processor's `if (result)` check skips the message-handler branch
entirely. Widen the action's return type to `void | SlashCommandActionReturn`
to match (same shape as btwCommand).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants