qwen-code/packages/web-shell/client/test/setup.ts
Tianyuan 71165fce0f
feat(cli): improve subagent observability — untruncated live commands, transcript path, approval context (#6580)
* feat(cli): improve subagent observability in detail view and approval prompts

The agent detail view now renders the newest in-progress tool call in full (wrapped) instead of truncating it to one line, shows up to 10 recent activities (registry buffer raised from 5), and surfaces the JSONL transcript path so the full execution trace can be followed live or reviewed post-mortem. Inline subagent approval prompts now show the agent's last three prior tool calls as context for the permission decision.

Refs #6569

* refactor(cli): address review — shared tool display map, C0 sanitization, named caps, boundary tests

Extract the four duplicated tool-name→display-name maps into a single shared module; export the activity buffer cap from core so the detail view's slice bound can't drift; sanitize bare C0 control bytes in the live activity row and approval context lines; name the live-label and approval-context magic numbers; add boundary tests for the progress buffer and approval-context edge cases (empty context, executing glyph, unknown tool fallback, empty description).

Refs #6569

* fix(cli): keep live-row prefix spacing under MaxSizedBox wrap layout

The wrapped live activity row rendered as `>Shell(...` — MaxSizedBox's wrap layout drops the prefix's trailing space when prefix and label arrive as separate Text segments. Concatenate them into a single string child so the glyph renders as `> Shell(...`.

* test(integration): add deterministic capture scenario for subagent observability evidence

Drives the fake OpenAI server through an 11-read + long-shell-command subagent run and screenshots the detail view (yolo) and the inline approval banner (default mode) — the reproducible source of PR #6580's Before/After captures.

* refactor(cli): address follow-up review — consistent C0 sanitization, named web-shell activity cap

Switch the two remaining activity-label surfaces (live panel row, inline parallel-agents row) to the same bare-C0-stripping sanitizer the dialog and approval context use; name web-shell's 5-row activity display budget with a comment marking the intentional divergence from core's 10-entry retention cap.

Refs #6569

* fix(cli): budget subagent observability surfaces against short terminals

Addresses the review verification report on #6580 (issue #6569):

- Approval context (Finding 1, blocking): the prior-call context lines
  added above a subagent approval prompt were unbudgeted, so on a short
  terminal they pushed the question and its options off-screen and Enter
  approved blind. Reserve one line per rendered context call out of the
  confirmation's height budget so the confirmation prompt always wins.
- Detail view live row (Finding 2): MaxSizedBox clips from the bottom, so
  a full 10-row history could push the live command and Transcript pointer
  off a short terminal. Render the live row separately and budget the
  always-valuable sections first, dropping the OLDEST history rows when
  space is tight so the live row survives. Move Transcript above Prompt.
- Transcript path (Finding 3): render the path with wrap="wrap" instead of
  truncate-end so the full ~130-char path can actually be copied / tail -f'd.
- Minor: correct the MAX_LIVE_LABEL_CHARS comment (a very large terminal
  could display past the cap; on such a description the row is truncated).

Also addresses inline suggestions: cache hot-path string-width measurements
in the detail view, sanitize the transcript path for defense-in-depth,
update the height-budget comment to mention the Transcript section, and add
a tool-display-map parity test that fails on core ToolNames/ToolDisplayNames
drift. New regression tests cover the confirmation-height reservation, the
short-terminal live-row survival, and the full transcript path.

* fix(cli): harden LiveAgentPanel task-description label against bare C0 controls

The glance-panel `label` (the subagent task description) still used
escapeAnsiCtrlCodes while the sibling `activity` line and every other
subagent surface were hardened to sanitizeMultilineForDisplay. The task
description is model-generated, so bare C0 controls (\r, BS, BEL) could
pass through the ANSI-sequence escape and corrupt the panel chrome. Route
it through sanitizeMultilineForDisplay to match.

* fix(cli): resolve duplicate i18n 'Transcript' key after main merge

Merging main brought in its own `Transcript` locale key (the conversation
TranscriptView), colliding with the one this PR added for the subagent
detail view — `no-dupe-keys` failed CI's ESLint step across en/zh/zh-TW.
Drop the PR's duplicate entries; `t('Transcript')` now resolves to the
shared canonical key (main's), so both surfaces render one consistent label.

* feat(web-shell): align tasks panel with CLI subagent surfaces

Address the three web-shell review suggestions on #6580. The web-shell is a
browser bundle with no dependency on @qwen-code/qwen-code-core, so it can't
import the CLI's shared helpers — mirror them locally instead:

- Sanitize LLM-generated activity descriptions: add `sanitizeControlChars`
  (mirrors the CLI's `sanitizeMultilineForDisplay`) and apply it in
  `formatActivityLabel`, so a stray \r/BEL/ESC can't garble the panel.
- Reduce display-name drift: document `TOOL_DISPLAY_NAMES` as a deliberate
  standalone copy of core's `ToolDisplayNames`, and sync the entries it was
  missing (loop_wakeup, create_sub_session, read_mcp_resource,
  team_plan_approval, artifact, record_artifact) with their zh translations.
- Add test coverage: sanitizeControlChars unit tests, and a render test
  asserting the detail progress list caps at the newest
  MAX_DISPLAYED_ACTIVITIES rows.

* test(web-shell): polyfill Range.getClientRects for CodeMirror in jsdom

CodeMirror's text measurement calls `range.getClientRects()` from a
requestAnimationFrame measure pass. jsdom (26.1.0) doesn't implement
`Range.prototype.getClientRects` / `getBoundingClientRect`, so that async
callback throws `TypeError: getClientRects is not a function`. Vitest
surfaces it as an *unhandled error* that fails the whole run (exit 1) even
though every test passes — and because it depends on rAF timing it's flaky
(it fired on CI, passed locally). `useComposerCore.dom.test.tsx` (added via
the main merge) is the trigger. Polyfill both Range methods in the shared
test setup with empty rects; CodeMirror already handles the no-layout case.

* fix(cli): also reserve the approval header line in the confirmation budget

Follow-up to the approval-context budgeting: the "Approval requested by"
header renders one line above the context block, but only `contextLines`
was subtracted from the confirmation's `availableTerminalHeight`. Total
rendered height was `1 (header) + contextLines + (availableHeight -
contextLines)` = `availableHeight + 1`, so on a short terminal the
confirmation options overflowed by one line — the exact blind-approval
failure this budgeting prevents. Subtract the header line too.

* test(cli): prove the live row survives the short-terminal budget (no transcript)

The re-review flagged a suspected off-by-one in `reservedLines` (missing the
Progress spacer+header). It isn't: the `2` in the `liveActivity` reservation
IS the Progress `<Box/>` spacer + bold header, and the live row has no spacer
of its own — so the accounting already balances (reservedLines + historyBudget
== maxHeight). Add the exact reviewer scenario as a regression test — 10
activities, no outputFile, maxHeight=14 — asserting the live command still
renders. It passes, confirming no clip.

* test(web-shell): guard TOOL_DISPLAY_NAMES against drift from core ToolNames

The web-shell map is a standalone copy of core's tool list (the browser
bundle can't depend on core). Add a build-time drift guard that reads core's
tool-names.ts source and asserts this map covers every enumerated wire name,
so a tool added to core can't silently leak a raw internal name into the web
panel via the fallback. Mirrors the CLI's tool-display-map.test.ts parity
check without pulling core into the bundle.

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-07-11 11:21:23 +00:00

105 lines
2.8 KiB
TypeScript

import { vi } from 'vitest';
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
const globalWithDom = globalThis as typeof globalThis & {
Element?: typeof Element;
Range?: typeof Range;
ResizeObserver?: typeof ResizeObserver;
};
function createEmptyDOMRect(): DOMRect {
if (typeof DOMRect === 'function') {
return new DOMRect(0, 0, 0, 0);
}
return {
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect;
}
function createEmptyDOMRectList(): DOMRectList {
return {
length: 0,
item: () => null,
} as DOMRectList;
}
if (typeof globalWithDom.ResizeObserver === 'undefined') {
globalWithDom.ResizeObserver = class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
} as typeof ResizeObserver;
}
if (
typeof globalWithDom.Element !== 'undefined' &&
!globalWithDom.Element.prototype.scrollIntoView
) {
globalWithDom.Element.prototype.scrollIntoView = () => {};
}
// jsdom implements getClientRects()/getBoundingClientRect() on Element but not
// on Range. CodeMirror's `measureTextSize` calls them on a text Range from a
// `requestAnimationFrame` measure pass, so in jsdom that async callback throws
// `TypeError: getClientRects is not a function`. Vitest surfaces it as an
// *unhandled error* that fails the whole run (exit 1) even when every test
// passes — and because it depends on rAF timing, it's flaky. Return empty
// geometry (CodeMirror already handles the no-layout case).
if (typeof globalWithDom.Range !== 'undefined') {
const rangePrototype = globalWithDom.Range.prototype as Range & {
getBoundingClientRect?: () => DOMRect;
getClientRects?: () => DOMRectList;
};
rangePrototype.getBoundingClientRect ??= createEmptyDOMRect;
rangePrototype.getClientRects ??= createEmptyDOMRectList;
}
if (typeof navigator !== 'undefined' && !navigator.clipboard) {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
writeText: vi.fn(() => Promise.resolve()),
},
});
}
if (typeof window !== 'undefined' && !window.matchMedia) {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: vi.fn((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
if (typeof navigator !== 'undefined' && !navigator.mediaDevices) {
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: {
getUserMedia: vi.fn(() =>
Promise.reject(new Error('getUserMedia is not mocked for this test')),
),
},
});
}