mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 15:31:27 +00:00
* feat(cli): make recap away-threshold configurable The 5-minute blur threshold was hard-coded. Confirmed from Claude Code's own binary (v2.1.113) that 5 minutes is their default as well (and that they shift to 60 minutes when 1h prompt-cache is active) — so the default stays, but expose it as `general.sessionRecapAway ThresholdMinutes` for users who briefly alt-tab often and don't want recaps piling up, or who want to lower it for testing. Non-positive / unset values fall back to the 5-minute default, so dropping the key has the same behavior as before. * fix(core): align recap prompt with Claude Code (1-2 sentences, ≤40 words) The earlier "exactly one sentence, 80-char cap" was an over-correction to a single in-the-moment ask. Going back to it: the natural shape of "current task + next action" is two clauses, and forcing them into a single sentence either crams them with a semicolon or drops the next action entirely on complex sessions. Adopt Claude Code's prompt verbatim (extracted from the v2.1.113 binary): "under 40 words, 1-2 plain sentences, no markdown. Lead with the overall goal and current task, then the one next action. Skip root-cause narrative, fix internals, secondary to-dos, and em-dash tangents." Add a Chinese-budget note (~80 chars) and keep the <recap>...</recap> wrapping that protects against reasoning-model preambles leaking into the UI. The sticky banner already re-measures controls height when the recap toggles, so a 2-line render lays out cleanly. Sweep "one-line" out of user-facing copy (settings description, slash-command description, feature docs, design doc) so the documentation matches the new shape. * fix(cli): restore "one-line" in user-facing recap copy Verified from the Claude Code v2.1.113 binary that the slash-command description IS literally "Generate a one-line session recap now" even though the underlying prompt allows 1-2 sentences. Claude Code is deliberately setting a tighter user expectation than the prompt guarantees, which keeps the surface feel "glanceable". Mirror that asymmetry: keep the prompt at 1-2 sentences (the previous commit) for behavioral parity, but put "one-line" back in the user- visible copy (slash-command description, settings description, user docs). Internal design doc keeps the accurate "1-2 sentence" wording. * fix(cli): render recap inline in history to match Claude Code Earlier I read the user's complaint that the recap "scrolled away" as "the recap should be sticky above the input box," and built a sticky banner accordingly. Disassembly of the Claude Code v2.1.113 binary shows the actual behavior is the opposite: their away_summary is a plain `type:"system", subtype:"away_summary"` message dispatched through the standard message renderer (no Static, no anchor, no flexbox pinning) — it scrolls with the conversation like every other system message. Tear out the sticky-banner machinery so recap matches that: - Recap is back in the `HistoryItemWithoutId` union and `addItem`'d into history (both from `/recap` and from auto-trigger), so it serializes into session saves and behaves like every other history item — no special clear paths, no resume-wrapper, no layout-effect re-measure dance. - `useAwaySummary` takes `addItem` again instead of a setter callback. - `AwayRecapMessage` renders the way Claude Code does: a 2-column gutter with `※`, then bold "recap: " and italic content, all in dim color. Drop the prior `StatusMessage`-shaped layout that fused prefix and label into "※ recap:". - Remove the AppContainer plumbing, the slashCommandProcessor state, the UIStateContext fields, the DefaultAppLayout / ScreenReader placement blocks, the test-utils mocks, and the noninteractive stub. Restore `useResumeCommand.handleResume` to a void return since callers no longer need the success boolean. Sweep the design doc so the architecture diagram, files table, and hook deps reflect the inline-history flow. * fix(cli): dedupe back-to-back auto-recaps with no new user turns between Two consecutive blur cycles, each over the threshold but with no new user activity in between, would each fire their own auto-recap and add two near-duplicate entries to history (same task, slightly different wording from temperature-driven LLM variance). Reported case: leaving the terminal twice while a /review of one PR was still on screen produced two recaps both about that same review. Add a `shouldFireRecap` gate before kicking off the LLM call: - Need at least 3 user messages in history total (don't fire on a near-empty session). - If a previous away_recap is already in history, need at least 2 new user messages since that one before another can fire. Same shape as Claude Code's `Ic1` gate (`Sc1=3`, `Rc1=2`). Read history through a ref so this isn't in the effect's deps and the effect doesn't re-run on every message. * fix(cli): type useResumeCommand.handleResume as Promise<void> Per gemini review on #3482: the interface declared this as `() => void` but the implementation is `async` and returns `Promise<void>`. The mismatch silently lost the chainable promise — tests had to launder it through `as unknown as Promise<void> | undefined` just to await. Tighten the interface to `Promise<void>` and drop the cast in the "closes the dialog immediately" test. * fix(cli): persist auto-fired recap to chat recording so /resume keeps it Per yiliang114 review on #3482: the manual `/recap` path persists across `/resume` because the slash-command processor records every output history item via `chatRecorder.recordSlashCommand({ phase: 'result', outputHistoryItems })`, but the auto path called `addItem` directly and bypassed that recorder. The result was an asymmetry where users who triggered recap manually saw it after `/resume`, while users whose recap fired automatically lost it. Mirror the manual recording from useAwaySummary's `.then` callback — record only the `result` phase (not invocation, since we don't want a fake `> /recap` user line replayed) with the away-recap item as the single output. Wrapped in try/catch because recap is best-effort and must never surface a failure to the user. Add useAwaySummary.test.ts covering: - the recording path is taken on a successful auto-trigger - the dedup gate (`shouldFireRecap`) suppresses the LLM call entirely, including the recording, when no new user turns happened since the last recap * fix(cli): cast recap item via spread to satisfy strict tsc --build CI's `tsc --build` (stricter than local `tsc --noEmit`) rejected the direct `item as Record<string, unknown>` cast: HistoryItemAwayRecap's literal `type: 'away_recap'` field doesn't overlap with `unknown`, TS2352. Use the `{ ...item } as Record<string, unknown>` spread pattern that the rest of the codebase (arenaCommand, slashCommandProcessor's serializer) already uses for the same SlashCommandRecordPayload field.
193 lines
5.5 KiB
TypeScript
193 lines
5.5 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen Code
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { act, renderHook } from '@testing-library/react';
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
import { useResumeCommand } from './useResumeCommand.js';
|
|
|
|
const resumeMocks = vi.hoisted(() => {
|
|
let resolveLoadSession:
|
|
| ((value: { conversation: unknown } | undefined) => void)
|
|
| undefined;
|
|
let pendingLoadSession:
|
|
| Promise<{ conversation: unknown } | undefined>
|
|
| undefined;
|
|
|
|
return {
|
|
createPendingLoadSession() {
|
|
pendingLoadSession = new Promise((resolve) => {
|
|
resolveLoadSession = resolve;
|
|
});
|
|
return pendingLoadSession;
|
|
},
|
|
resolvePendingLoadSession(value: { conversation: unknown } | undefined) {
|
|
resolveLoadSession?.(value);
|
|
},
|
|
getPendingLoadSession() {
|
|
return pendingLoadSession;
|
|
},
|
|
reset() {
|
|
resolveLoadSession = undefined;
|
|
pendingLoadSession = undefined;
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock('../utils/resumeHistoryUtils.js', () => ({
|
|
buildResumedHistoryItems: vi.fn(() => [{ id: 1, type: 'user', text: 'hi' }]),
|
|
}));
|
|
|
|
vi.mock('@qwen-code/qwen-code-core', () => {
|
|
class SessionService {
|
|
constructor(_cwd: string) {}
|
|
async loadSession(_sessionId: string) {
|
|
return (
|
|
resumeMocks.getPendingLoadSession() ??
|
|
Promise.resolve({
|
|
conversation: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
return {
|
|
SessionService,
|
|
};
|
|
});
|
|
|
|
describe('useResumeCommand', () => {
|
|
it('should initialize with dialog closed', () => {
|
|
const { result } = renderHook(() => useResumeCommand());
|
|
|
|
expect(result.current.isResumeDialogOpen).toBe(false);
|
|
});
|
|
|
|
it('should open the dialog when openResumeDialog is called', () => {
|
|
const { result } = renderHook(() => useResumeCommand());
|
|
|
|
act(() => {
|
|
result.current.openResumeDialog();
|
|
});
|
|
|
|
expect(result.current.isResumeDialogOpen).toBe(true);
|
|
});
|
|
|
|
it('should close the dialog when closeResumeDialog is called', () => {
|
|
const { result } = renderHook(() => useResumeCommand());
|
|
|
|
// Open the dialog first
|
|
act(() => {
|
|
result.current.openResumeDialog();
|
|
});
|
|
|
|
expect(result.current.isResumeDialogOpen).toBe(true);
|
|
|
|
// Close the dialog
|
|
act(() => {
|
|
result.current.closeResumeDialog();
|
|
});
|
|
|
|
expect(result.current.isResumeDialogOpen).toBe(false);
|
|
});
|
|
|
|
it('should maintain stable function references across renders', () => {
|
|
const { result, rerender } = renderHook(() => useResumeCommand());
|
|
|
|
const initialOpenFn = result.current.openResumeDialog;
|
|
const initialCloseFn = result.current.closeResumeDialog;
|
|
const initialHandleResume = result.current.handleResume;
|
|
|
|
rerender();
|
|
|
|
expect(result.current.openResumeDialog).toBe(initialOpenFn);
|
|
expect(result.current.closeResumeDialog).toBe(initialCloseFn);
|
|
expect(result.current.handleResume).toBe(initialHandleResume);
|
|
});
|
|
|
|
it('handleResume no-ops when config is null', async () => {
|
|
const historyManager = { clearItems: vi.fn(), loadHistory: vi.fn() };
|
|
const startNewSession = vi.fn();
|
|
|
|
const { result } = renderHook(() =>
|
|
useResumeCommand({
|
|
config: null,
|
|
historyManager,
|
|
startNewSession,
|
|
}),
|
|
);
|
|
|
|
await act(async () => {
|
|
await result.current.handleResume('session-1');
|
|
});
|
|
|
|
expect(startNewSession).not.toHaveBeenCalled();
|
|
expect(historyManager.clearItems).not.toHaveBeenCalled();
|
|
expect(historyManager.loadHistory).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('handleResume closes the dialog immediately and restores session state', async () => {
|
|
resumeMocks.reset();
|
|
resumeMocks.createPendingLoadSession();
|
|
|
|
const historyManager = { clearItems: vi.fn(), loadHistory: vi.fn() };
|
|
const startNewSession = vi.fn();
|
|
const geminiClient = {
|
|
initialize: vi.fn(),
|
|
};
|
|
|
|
const config = {
|
|
getTargetDir: () => '/tmp',
|
|
getGeminiClient: () => geminiClient,
|
|
startNewSession: vi.fn(),
|
|
getDebugLogger: () => ({
|
|
warn: vi.fn(),
|
|
debug: vi.fn(),
|
|
error: vi.fn(),
|
|
}),
|
|
} as unknown as import('@qwen-code/qwen-code-core').Config;
|
|
|
|
const { result } = renderHook(() =>
|
|
useResumeCommand({
|
|
config,
|
|
historyManager,
|
|
startNewSession,
|
|
}),
|
|
);
|
|
|
|
// Open first so we can verify the dialog closes immediately.
|
|
act(() => {
|
|
result.current.openResumeDialog();
|
|
});
|
|
expect(result.current.isResumeDialogOpen).toBe(true);
|
|
|
|
let resumePromise: Promise<void> | undefined;
|
|
act(() => {
|
|
// Start resume but do not await it yet — we want to assert the dialog
|
|
// closes immediately before the async session load completes.
|
|
resumePromise = result.current.handleResume('session-2');
|
|
});
|
|
expect(result.current.isResumeDialogOpen).toBe(false);
|
|
|
|
// Now finish the async load and let the handler complete.
|
|
resumeMocks.resolvePendingLoadSession({
|
|
conversation: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
|
});
|
|
await act(async () => {
|
|
await resumePromise;
|
|
});
|
|
|
|
expect(config.startNewSession).toHaveBeenCalledWith(
|
|
'session-2',
|
|
expect.objectContaining({
|
|
conversation: expect.anything(),
|
|
}),
|
|
);
|
|
expect(startNewSession).toHaveBeenCalledWith('session-2');
|
|
expect(geminiClient.initialize).toHaveBeenCalledTimes(1);
|
|
expect(historyManager.clearItems).toHaveBeenCalledTimes(1);
|
|
expect(historyManager.loadHistory).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|