qwen-code/integration-tests/interactive/context-compress-interactive.test.ts
顾盼 c03610b7a7
feat(core,cli): auto-compact follow-up — /compress instructions, PreCompact hook plumb, plan/subagent attachments (#4688)
* feat(cli,core): /compress accepts custom focus instructions

Extends /compress to take a trailing instruction string (max 2000 chars)
that is passed through tryCompressChat → tryCompress → CompressOptions
and appended to the compression side-query system prompt as an
"Additional Instructions:" block. Mirrors claude-code /compact <text>.

Empty / whitespace-only args fall back to the prior behaviour.

* test(core): cover /compress customInstructions + PreCompact hook merge

* feat(core): restore plan-mode + subagent snapshot after compaction

Adds two optional ComposePostCompactOptions:
- planModeActive: when true, emits a <plan-mode-active> reminder so the
  post-compact agent does not forget destructive tools remain gated.
- runningSubagents: when non-empty, emits a <background-tasks> block
  listing each running/paused task by id, status, and description.

Both blocks are spliced into the merged user attachment Content before
file/image restorations. XML-significant characters in descriptions are
escaped to prevent an adversarial subagent description from closing the
wrapper tag.

Wiring at the call site arrives in the next commit.

* feat(core): wire plan-mode + subagent snapshot into post-compact attachments

ChatCompressionService.compress now passes:
- planModeActive: derived from config.getApprovalMode() === ApprovalMode.PLAN
- runningSubagents: filtered from BackgroundTaskRegistry to agent-kind tasks
  in 'running' or 'paused' state

into composePostCompactHistory. Adds collectActiveSubagents() helper that
returns [] when the registry is absent so older SDK consumers without it
keep working.

* fix(core,test): use HookSystem.getAdditionalContext accessor; smoke-test /compress

- chatCompressionService: read PreCompact hook output via
  result.getAdditionalContext() — the wrapper returns DefaultHookOutput
  (not the raw AggregatedHookResult), and the accessor sanitises < / >
  consistently with every other call-site in the repo.
- Test mocks now return a DefaultHookOutput-shaped stub via a tiny
  makeHookOutput() helper rather than the aggregator shape.
- New integration smoke test for `/compress focus on the scientist
  mentioned` exercising the args plumbing end-to-end.

* test(core): update client.test.ts to match new tryCompressChat signature

* feat(core): cap subagent snapshot at 30 entries with overflow notice

Code-review follow-up. Pathological sessions with hundreds of
backgrounded agents could otherwise produce a multi-KB block. Newest 30
rows are kept (highest startTime); older ones are summarised on a
trailing line so the model knows the snapshot is partial.

* fix(core,test): flatten subagent description newlines; type-safe ApprovalMode in tests

Second code-review pass found two real issues:

1. Subagent descriptions containing `\n`/`\r`/`\t` would split across
   multiple lines inside the `<background-tasks>` bullet list, letting
   the second line read as a sibling row (or worse, an orphan paragraph
   between two `- [..]` entries). Flatten whitespace before the slice so
   each task stays on one line.

2. The plan-mode wiring tests passed `'plan'` / `'auto-edit'` as plain
   strings instead of `ApprovalMode.PLAN` / `ApprovalMode.AUTO_EDIT`.
   Source code compares against the enum; a future enum value change
   would have silently passed the tests. Import and use the enum.

* fix(core): move PreCompact hook fire after length-guard; align plan-mode tool names

Round 3 code review surfaced two issues:

1. PreCompact hook fired BEFORE the curatedHistory.length < 2 guard, so
   a single-message session would trigger any hook side effects
   (transcript dump, external notification, etc.) and then NOOP. Move
   the hook fire below the guard so hooks only run when compression is
   actually possible. New regression test asserts the contract.

2. PLAN_MODE_REMINDER_TEXT said "shell mutations" but the real qwen-code
   tool is `run_shell_command` (tool-names.ts:26). Use the verbatim
   tool names so a future rename is grep-discoverable.

* refactor(core): share escapeXml, drive plan-mode names from ToolNames, extract reminder builder

Code-review follow-ups on post-compact attachments:
- Replace the local 3-char escapeForXmlText with the shared 5-char
  escapeXml from utils/xml.ts, and apply it to the subagent id and
  status as well as the description. Subagent ids derive from a
  user-configurable subagentConfig.name, so an unescaped `<`/`&` there
  could close the <background-tasks> wrapper or forge sibling markup.
- PLAN_MODE_REMINDER_TEXT now interpolates ToolNames.WRITE_FILE / .EDIT /
  .SHELL instead of retyping the names, so a future rename stays in sync.
- Extract buildStateReminderParts() as the single source of truth for the
  plan-mode + subagent reminder blocks, used by both composePostCompactHistory
  and (next commit) its catch-fallback so the two paths can't drift.

* fix(core): scope subagent snapshot to backgrounded tasks; restore reminders on fallback; cap hook context

Three code-review fixes in the compaction service:
- collectActiveSubagents now also requires isBackgrounded — foreground
  agents are the parent's synchronously-awaited tool call and don't belong
  in a <background-tasks> roster. Mirrors getRunningBackgroundCount.
- The composePostCompactHistory catch-fallback re-applies the plan-mode +
  subagent reminders via the shared buildStateReminderParts (pure, no I/O),
  so a restoration failure no longer silently drops plan-mode enforcement
  and the subagent roster.
- The PreCompact hook's additionalContext is capped at
  MAX_HOOK_INSTRUCTIONS_CHARS before entering the side-query prompt,
  closing the unbounded-input hole the user-text cap was meant to prevent.

The fallback and hook-cap fixes have RED-verified regression tests.

* feat(cli): warn on /compress instruction truncation; fix integration-test pty typing

- /compress now emits an INFO notice (interactive), a stream message (acp),
  and a prefixed return message (non-interactive) when the instruction
  string exceeds MAX_COMPRESS_INSTRUCTIONS_CHARS, so the silent 2000-char
  clip is no longer invisible to the user.
- Annotate the three `ptyProcess.onData((data: string) => ...)` callbacks
  in the compress integration test to clear the TS7006 implicit-any the
  reviewer's typecheck flagged (fixed all three occurrences, not only the
  one inside this PR's diff).
2026-06-03 09:23:00 +08:00

160 lines
4.7 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { expect, describe, it, beforeEach, afterEach } from 'vitest';
import { TestRig, type } from '../test-helper.js';
describe('Interactive Mode', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => {
await rig.cleanup();
});
it.skipIf(process.platform === 'win32')(
'should trigger chat compression with /compress command',
async () => {
await rig.setup('interactive-compress-test', {
settings: {
security: {
auth: {
selectedType: 'openai',
},
},
},
});
const { ptyProcess } = rig.runInteractive();
let fullOutput = '';
ptyProcess.onData((data: string) => (fullOutput += data));
// Wait for the app to be ready
const isReady = await rig.waitForText('Type your message', 15000);
expect(
isReady,
'CLI did not start up in interactive mode correctly',
).toBe(true);
const longPrompt =
'Dont do anything except returning a 1000 token long paragragh with the <name of the scientist who discovered theory of relativity> at the end to indicate end of response. This is a moderately long sentence.';
await type(ptyProcess, longPrompt);
await type(ptyProcess, '\r');
await rig.waitForText('einstein', 25000);
await type(ptyProcess, '/compress');
// A small delay to allow React to re-render the command list.
await new Promise((resolve) => setTimeout(resolve, 100));
await type(ptyProcess, '\r');
const foundEvent = await rig.waitForTelemetryEvent(
'chat_compression',
90000,
);
expect(foundEvent, 'chat_compression telemetry event was not found').toBe(
true,
);
},
);
it.skip('should handle compression failure on token inflation', async () => {
await rig.setup('interactive-compress-test', {
settings: {
security: {
auth: {
selectedType: 'openai',
},
},
},
});
const { ptyProcess } = rig.runInteractive();
let fullOutput = '';
ptyProcess.onData((data: string) => (fullOutput += data));
// Wait for the app to be ready
const isReady = await rig.waitForText('Type your message', 25000);
expect(isReady, 'CLI did not start up in interactive mode correctly').toBe(
true,
);
await type(ptyProcess, '/compress');
await new Promise((resolve) => setTimeout(resolve, 1000));
await type(ptyProcess, '\r');
const foundEvent = await rig.waitForTelemetryEvent(
'chat_compression',
90000,
);
expect(foundEvent).toBe(true);
const compressionFailed = await rig.waitForText(
'Nothing to compress.',
25000,
);
expect(compressionFailed).toBe(true);
});
it.skipIf(process.platform === 'win32')(
'should forward /compress instructions through to the side-query',
async () => {
await rig.setup('interactive-compress-instructions-test', {
settings: {
security: {
auth: {
selectedType: 'openai',
},
},
},
});
const { ptyProcess } = rig.runInteractive();
let fullOutput = '';
ptyProcess.onData((data: string) => (fullOutput += data));
const isReady = await rig.waitForText('Type your message', 15000);
expect(
isReady,
'CLI did not start up in interactive mode correctly',
).toBe(true);
// Seed history so /compress has material to summarize.
const seedPrompt =
'Dont do anything except returning a 1000 token long paragragh with the <name of the scientist who discovered theory of relativity> at the end to indicate end of response. This is a moderately long sentence.';
await type(ptyProcess, seedPrompt);
await type(ptyProcess, '\r');
await rig.waitForText('einstein', 25000);
// Fire /compress with a trailing instruction. We are not asserting on
// summary CONTENT (model behaviour) — only that the wiring runs
// end-to-end and the compression telemetry event lands. Earlier unit
// tests cover the prompt-composition path; this is the smoke test that
// the args plumbing reaches the side-query.
await type(ptyProcess, '/compress focus on the scientist mentioned');
await new Promise((resolve) => setTimeout(resolve, 100));
await type(ptyProcess, '\r');
const foundEvent = await rig.waitForTelemetryEvent(
'chat_compression',
90000,
);
expect(foundEvent, 'chat_compression telemetry event was not found').toBe(
true,
);
},
);
});