Skip to content

fix(web-shell): keep inline chip annotations on the chip's real range - #12992

Merged
wenshao merged 6 commits into
mainfrom
fix/issue-12980-chip-annotation-offset
Oct 1, 2026
Merged

wenshao merged 6 commits into
mainfrom
fix/issue-12980-chip-annotation-offset

Conversation

@yiliang114

Copy link
Copy Markdown
Collaborator

What this PR does

When a prompt contained plain text spelled exactly like a later inline reference chip (e.g. typing literal @foo then and then inserting an @foo chip at the end), the submitted inputAnnotations attached to the first textual match instead of the chip, so the user-message bubble rendered the typed look-alike as the chip and the real chip as plain text. This PR threads the editor-known inline chip ranges through submit into annotation generation, so annotations are emitted at the chip's real offsets; the previous forward indexOf scan is kept for tags that have no known placement (composer-level top tags, tagsOverride, textarea/touch backend).

Why it's needed

The offset was wrong at generation time: createInputAnnotationsFromComposerTags discarded the CodeMirror placement ranges the composer already had and re-searched the final prompt text with content.indexOf(serialized, cursor), which returns the first textual occurrence regardless of whether it was a chip. The submitted prompt string itself was always correct — only the display annotation was misplaced — but the bubble then misrepresented which occurrence the user selected (#12980).

Reviewer Test Plan

How to verify

  1. In the Web Shell composer, type or paste literal @foo then as ordinary text (do not select a reference for this occurrence).
  2. Use the + file-reference picker to insert an @foo reference at the end.
  3. Submit and inspect the user-message bubble: the first @foo stays plain text, the trailing one renders as the chip. Before this PR the two were reversed.
  4. Regression checks covered by tests: a chip with no plain-text look-alike still annotates correctly, and a chip placed before matching plain text still wins its own range.

Component-level evidence (real CodeMirror editor + real useComposerCore submit pipeline + the same splitComposerTagContentByAnnotations the bubble renders from): packages/web-shell/client/hooks/useComposerCore.issue-12980.dom.test.tsx fails on unfixed code with the annotation at [8, 12) instead of [18, 22) — the exact offsets from the issue — and passes with this change. A full browser click-through of the + picker was not run here; the picker reaches this same insertion path (addTags({ placement: 'inline', position: 'end' })).

Test output:

✓ utils/composerTag.test.ts (34 tests)
✓ hooks/useComposerCore.test.ts (8 tests)
✓ hooks/useComposerCore.dom.test.tsx (100 tests)
✓ hooks/useComposerCore.issue-12980.dom.test.tsx (3 tests)
Test Files  4 passed (4)   Tests  145 passed (145)

pnpm typecheck in packages/web-shell passes; eslint on touched files is clean.

Evidence (Before & After)

Before: repro test above fails — expected 8 to be 18 (inputAnnotations[0].start lands on the plain-text look-alike). After: same test passes, annotation at [18, 22), and splitComposerTagContentByAnnotations keeps literal @foo then as text with only the trailing @foo rendered as the reference chip. No browser recording: covered at component level as described above.

Tested on

OS Status
🍏 macOS ⚠️ not tested
🪟 Windows ⚠️ not tested
🐧 Linux ✅ tested

Environment (optional)

Unit/component tests only: vitest run in packages/web-shell (jsdom + real CodeMirror view).

Risk & Scope

  • Main risk or tradeoff: placement offsets cross a coordinate-space boundary (editor document → final prompt text, shifted by the top-tag prefix and the shell-mode !); a slice-match guard drops any placement whose range does not match the serialized text, degrading to plain text instead of a wrong chip.
  • Not validated / out of scope: browser click-through of the + picker; daemon persistence/reload of annotations; the touch/textarea backend (never produces inline placements, unchanged). Deferred review findings from PR #12404: fix(web-shell): preserve reference tags across reloads #12426 (post-generation annotation rewrites) is a separate issue and untouched.
  • Breaking changes / migration notes: none. createInputAnnotationsFromComposerTags gains an optional third parameter; existing callers and the pinned ordered-matching test for repeated serialized references are unaffected.

Linked Issues

Fixes #12980

中文说明

本 PR 做了什么

当 prompt 中同时存在与某个内联引用 chip 序列化文本完全相同的普通文字时(例如先输入 literal @foo then ,再在末尾插入 @foo chip),提交生成的 inputAnnotations 会挂到文本上的第一个匹配处而不是 chip 本身,导致用户消息气泡把普通文字渲染成 chip、把真正的 chip 渲染成普通文字。本 PR 把编辑器已知的内联 chip 区间随提交路径传入注解生成,直接在 chip 的真实偏移处生成注解;对没有已知区间的 tag(composer 顶部 tag、tagsOverride、textarea/触摸后端)保留原有的前向 indexOf 扫描。

为什么需要

偏移在注解生成时就是错的:createInputAnnotationsFromComposerTags 丢弃了 composer 已有的 CodeMirror 区间,改用 content.indexOf(serialized, cursor) 在最终 prompt 文本里重新查找,命中的是第一个文本匹配而不论该处是否为 chip。提交的 prompt 字符串本身一直是对的,只有展示注解挂错,但气泡会错误呈现用户实际选中的位置(#12980)。

评审验证计划

如何验证

  1. 在 Web Shell composer 中输入或粘贴普通文字 literal @foo then (该处不要选择引用)。
  2. 用 + 文件引用选择器在末尾插入 @foo 引用。
  3. 提交并查看用户消息气泡:第一个 @foo 保持普通文字,末尾的渲染为 chip。在本 PR 之前两者是反的。
  4. 测试覆盖的回归项:没有同拼写普通文字时 chip 注解仍正确;chip 位于同文本之前的场景仍命中自身区间。

组件级证据(真实 CodeMirror 编辑器 + 真实 useComposerCore 提交管线 + 气泡渲染所用的同一个 splitComposerTagContentByAnnotations):packages/web-shell/client/hooks/useComposerCore.issue-12980.dom.test.tsx 在未修复代码上失败,注解落在 [8, 12) 而非 [18, 22)——与 issue 中的偏移完全一致;本改动后通过。未在浏览器中完整点击 + 选择器路径;该路径汇入同一个插入入口(addTags({ placement: 'inline', position: 'end' }))。

测试输出:

✓ utils/composerTag.test.ts (34 tests)
✓ hooks/useComposerCore.test.ts (8 tests)
✓ hooks/useComposerCore.dom.test.tsx (100 tests)
✓ hooks/useComposerCore.issue-12980.dom.test.tsx (3 tests)
Test Files  4 passed (4)   Tests  145 passed (145)

packages/web-shell 的 pnpm typecheck 通过;改动文件 eslint 干净。

前后对比证据

修复前:上述复现测试失败——expected 8 to be 18(inputAnnotations[0].start 落在普通文字上)。修复后:同一测试通过,注解位于 [18, 22),splitComposerTagContentByAnnotations 把 literal @foo then 保持为文本、仅末尾 @foo 渲染为引用 chip。无浏览器录屏:如上所述以组件级覆盖。

测试平台

OS Status
🍏 macOS ⚠️ 未测试
🪟 Windows ⚠️ 未测试
🐧 Linux ✅ 已测试

环境(可选)

仅单元/组件测试:在 packages/web-shell 下 vitest run(jsdom + 真实 CodeMirror view)。

风险与范围

  • 主要风险或取舍:区间偏移跨越坐标系边界(编辑器文档 → 最终 prompt 文本,需加上顶部 tag 前缀与 shell 模式 ! 的偏移);对切片校验不匹配的区间直接丢弃,降级为普通文字而不是挂错 chip。
  • 未验证 / 不在范围内:+ 选择器的浏览器完整点击路径;注解经 daemon 持久化与重载;触摸/textarea 后端(不产生内联区间,行为不变)。Deferred review findings from PR #12404: fix(web-shell): preserve reference tags across reloads #12426(注解生成后的改写)是独立问题,未改动。
  • 破坏性变更 / 迁移说明:无。createInputAnnotationsFromComposerTags 新增可选第三参数;既有调用方与固化重复序列化文本顺序匹配行为的测试均不受影响。

关联 Issue

Fixes #12980

createInputAnnotationsFromComposerTags re-located every tag in the final
prompt with indexOf, so plain text spelled like an inline chip's
serialized text stole the annotation and the bubble rendered the typed
look-alike as the chip (#12980). The composer already knows each inline
chip's editor range; thread those placements through submit (shifted
past the prompt prefix) and emit their annotations directly, keeping the
forward indexOf scan for tags without a known placement.

Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>
Patrol-Run: qwen-issue-patrol/jmum9v0vg30
Every case added for #12980 ran with promptPrefixLength === 0, so the one
line of new arithmetic (promptText.length - text.length, and the shifts
that use it) was unpinned. Adds a dom case with a top tag attached
(prefix 6, chip must land on [24, 28) instead of the plain-text look-alike
at [14, 18)) and one in shell mode (prefix 1, chip must land on [19, 23)).
Forcing the prefix to 0 makes each lose its chip annotation entirely.

Also applies Prettier to this PR's two test files, which was the only
thing `Lint & Static` objected to.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmumcpwj7eg
@yiliang114

Copy link
Copy Markdown
Collaborator Author

Both items from the review are done at 7f9f109.

1. Prettier — prettier --write on exactly the two files Lint & Static named. Line-wrapping only: one import collapsed to a single line, two createInputAnnotationsFromComposerTags call sites re-wrapped. No production file is in the commit.

2. The prefix shift is pinned — two dom cases asserting the chip's absolute prompt offset:

case promptPrefixLength chip lands on
top tag attached 6 [24, 28), not the look-alike at [14, 18)
shell mode 1 [19, 23)

Both were mutation-checked individually with promptPrefixLength forced to 0: the top-tag case loses the chip annotation ([[0,4]] instead of [[0,4],[24,28]]) and the shell case loses it outright ([] instead of [[19,23]]) — the slice guard rejects the unshifted range. Mutation reverted; useComposerCore.ts carries no diff in this commit.

39/39 across the two touched test files, tsc --noEmit clean for packages/web-shell, eslint clean on both.

I left the two non-blocking items alone as you framed them: the useAtMentionMenu trimEnd()/trim() divergence (your own trace has it failing safe — plain text, never a chip on the wrong occurrence) and the doc-comment note about the caller-side sort. Neither belongs in an offset fix.

One line on the red Test (ubuntu-latest) at fe708af, since it is not this PR: 7 rows of packages/core/src/agents/background-agent-resume.test.ts ("matches the launch-time skill listing…", expected false to be true) from the #12545 × #12838 interaction, already fixed on main by #12996 (e44e45e53c, 07:53Z) — which landed after that job started at 07:10Z. This PR's diff touches nothing under packages/core or packages/cli.

中文

评审提的两件事都已在 7f9f109 完成:

  1. Prettier:只对 Lint & Static 点名的两个测试文件跑 --write,纯换行调整(一条 import 收成单行、两处 createInputAnnotationsFromComposerTags 调用重新折行),提交里没有任何生产文件。
  2. 前缀位移补上断言:顶部 tag(promptPrefixLength=6,chip 落在 [24,28) 而非同形纯文本 [14,18))与 shell 模式(=1,落在 [19,23))各一例。两例都逐个单独做了变异验证——把 promptPrefixLength 强制为 0,前者丢掉 chip 注解([[0,4]]),后者整个丢空([]),切片守卫会拒绝未位移的区间;变异已还原,useComposerCore.ts 本次无 diff。

两个非阻塞项按你的定性保留不动:useAtMentionMenu 的 trimEnd()/trim() 分歧(你自己的追踪结论是安全方向失效——退化成纯文本,绝不会把 chip 挂到错误的同形文本上),以及调用方排序的文档注释。都不属于一个偏移修复的范围。

fe708af 上那个红的 Test (ubuntu-latest) 与本 PR 无关:失败的是 packages/core 的 background-agent-resume.test.ts 中 7 行 skill listing 用例(expected false to be true),源于 #12545 与 #12838 的组合,已由 #12996(e44e45e53c,07:53Z)在 main 上修好——而那次 job 07:10Z 就已开跑。本 PR 的 diff 不涉及 packages/core / packages/cli。

@yiliang114

Copy link
Copy Markdown
Collaborator Author

Correction to my previous comment: re-running CI does not clear that Test (ubuntu-latest) red, and my "already fixed on main" line was wrong about the mechanism.

The job checks out refs/pull/12992/head — the PR head alone, not a merge preview against main. So #12996's fix never enters the tested tree:

The re-run at 7f9f109 failed identically: same 7 background-agent-resume.test.ts rows, 7 failed | 33269 passed | 11 skipped (33287).

So this is branch staleness, not a defect in this PR — the branch carries #12545's fixture without #12996's repair, and nothing under packages/web-shell can clear it. It needs main merged in (or a rebase), after which the 7 rows should pass on their own.

Lint & Static (ubuntu-latest, Node 22.x) is green at 7f9f109.

中文

更正上一条:重跑 CI 不会清掉那个 Test (ubuntu-latest) 的红,我说的"已在 main 上修好"对机制的判断是错的。

该 job 检出的是 refs/pull/12992/head,即只有 PR head,不是与 main 的 merge preview,所以 #12996 的修复根本进不了被测树:

7f9f109 上的重跑失败形态完全一致:同样 7 行 background-agent-resume.test.ts,7 failed | 33269 passed | 11 skipped (33287)。

因此这是分支陈旧,不是本 PR 的缺陷——分支带着 #12545 的 fixture 却没有 #12996 的修复,packages/web-shell 下改任何东西都清不掉它。需要合入 main(或 rebase),之后这 7 行应当自行通过。

Lint & Static (ubuntu-latest, Node 22.x) 在 7f9f109 上已绿。

@yiliang114

yiliang114 commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator Author

⚠️ RETRACTION — this comment's earlier version mis-attributed the Test failure and I am correcting it.
The previous version claimed attempt 1 failed in packages/desktop-shell with Killed / exit code 137 (OOM), and concluded that "neither cause is actionable inside this PR". Both statements were wrong. I relayed an interim automated report without verifying it against the logs. The accurate attribution and the actionable remedy are below. What the earlier version got right and still stands: Lint & Static is green, and this PR's own diff did not cause the Test red.

Corrected CI note on head 7f9f109bcb

Lint & Static (ubuntu-latest, Node 22.x) = completed | success. That was the gate this push targeted (CI's step is node scripts/lint.js --prettier → prettier --experimental-cli --check ., whole-repo), and it is cleared.

Test (ubuntu-latest, Node 22.x) is red on both attempts of run 36540980283, with the same cause — not two different ones:

❯ src/agents/background-agent-resume.test.ts (81 tests | 7 failed)
   BackgroundAgentResumeService > matches the launch-time skill listing when the definition …
   → expected false to be true // Object.is equality      (each row, retry x2)
Tests  7 failed | 33269 passed | 11 skipped (33287)

These are real, deterministic assertion diffs — not an environment or resource failure. Neither attempt's log contains Killed, heap out of memory, Failed to resolve import, or onTaskUpdate; desktop-shell does not appear at all.

Root cause: branch staleness, and main already has the fix

  • The 7 rows come from a parameterized title added by fix(core): withhold the SkillManager from subagents whose tool policy has no Skill tool #12545 (d5a157c45e, merged to main 05:55:15Z). git merge-base --is-ancestor d5a157c45e 7f9f109bcb → rc=0, so this branch does contain that regression.
  • Main has since been fixed by test(core): register the Skill tool in the resume listing fixture #12996 (test(core): register the Skill tool in the resume listing fixture, merged 2026-09-29T07:53:19Z). Containment check: git merge-base --is-ancestor e44e45e53c 7f9f109bcb → rc=1, so the fix is not in this branch. Confirmed by content too — grep -c registeredToolNames on background-agent-resume.test.ts is 0 at this head and 3 on origin/main.
  • The Test job checks out refs/pull/12992/head — the bare head, not a merge preview — so a main-only fix can never reach the tested tree. That is why the re-run failed identically, and why re-running again cannot help.
  • This PR's diff is 4 files under packages/web-shell/client/ only; git diff origin/main...HEAD -- packages/core and -- packages/cli are both empty. Zero overlap with the failing suite.

Remedy — this one IS actionable inside the PR

Merging origin/main into this branch picks up #12996 and should clear the Test lane. It was not done this round: the single-push budget had already been spent on the Prettier + test-coverage delivery, and the freshness gate reported status: fresh (rc=0), so no mandated base merge fired. Flagging it explicitly so it is not mistaken for something unfixable — a base merge on the next round is the fix, not another re-run.

Tracked upstream as #12991 for the main-side breakage itself.

For reference, what the delivered commit contains

No production file is touched:

packages/web-shell/client/hooks/useComposerCore.issue-12980.dom.test.tsx | +68/-4
packages/web-shell/client/utils/composerTag.test.ts                      | +27/-19

Every case added for #12980 ran with promptPrefixLength === 0, so the new offset arithmetic was unpinned; the added cases use a nonzero prefix (6 in DOM mode, 1 in shell mode), and forcing the prefix back to 0 makes each lose its chip annotation — which is what gives them teeth. Locally: npx vitest run client/hooks/useComposerCore.issue-12980.dom.test.tsx client/utils/composerTag.test.ts → 39 passed (39), exit 0; npm run typecheck --workspace=packages/web-shell → exit 0.

yiliang114 and others added 2 commits September 29, 2026 17:43
Picks up #12996 (e44e45e), which registers the Skill tool in the
resume-listing fixture. Without it the branch still carries the pre-#12545
fixture, so 7 BackgroundAgentResumeService "launch-time skill listing" cases
fail on `expect(initialMessages.includes('auto-skill-demo')).toBe(true)`.

This branch does not touch packages/core/src/agents/background-agent-resume.*,
so the merge takes main's version wholesale.

Co-authored-by: Qwen-Coder <[email protected]>
…acements

Known placements are pre-substitution editor offsets, so once the document
text under a chip drifts from its serialized form every later chip's range
goes stale too and the slice guard dropped all of their annotations. On a
mismatch, fall back to the text when the serialized form occurs exactly
once — a unique match cannot annotate the wrong span — and keep dropping to
plain text when it is repeated or absent. Addresses review thread R1-1.
@yiliang114
yiliang114 dismissed a stale review September 29, 2026 13:11

Reviewed fe708af, which is superseded. The three threads are addressed on f2b9ae5: the stale-placement cascade (R1-1) is fixed with a unique-match fallback plus test, R1-2 declined (equivalent mutants, behavioral coverage exists), R1-3 narrowed by the fallback. All threads resolved with replies.

yiliang114 and others added 2 commits September 30, 2026 00:48
A stale known placement recovers through content.indexOf(serialized), which searches from the prompt start. Nothing pinned that origin: every existing placement either starts at 0 or names a serialized form absent from the content, so anchoring the search at the recorded start was behaviourally indistinguishable. Add a placement whose recorded start sits past its true occurrence; it fails under an indexOf(serialized, start) mutant and passes without it.

Co-authored-by: Qwen-Coder <[email protected]>

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmumw0cjpfe
…ertions

R3-1: the two multi-annotation assertions in composerTag.test.ts projected
each annotation down to [start, end], so nothing in the repo pinned that a
recovered span is emitted with its own placement's tag. Offsets come from the
span's serialized form while identity comes from placement.tag, and only the
placement loop joins them, so a mis-zip that preserves annotation order was
invisible. A wrong pairing renders a chip whose reference.id points at a
different file, and that id is embedded in the submit-path dedupe key, so it
propagates into replay reconciliation and is persisted with the transcript.

Both assertions now project [start, end, reference.id]:
- 'combines searched tags with known inline placements'
- 'recovers a stale placement whose true occurrence is behind its recorded
  start' (the only test where two placements both take the stale fallback)

The expectations assert the ids the fixtures actually declare (file:@ctx /
file:@foo and file:@Alpha / file:@beta) and deliberately do not gain a
serialized key: these fixtures set only id/kind/value, and
createReferenceAnnotation spreads serialized conditionally.

Discriminating power verified with a temporary mis-zip mutant in the stale
fallback branch (collect spans and tags separately, emit spans[i] with
tags[n-1-i].tag, annotation order preserved): the strengthened assertion reds
with "expected [ [ +0, 6, 'file:@beta' ], ...(1) ] to deeply equal
[ [ +0, 6, 'file:@Alpha' ], ...(1) ]" and it is the only one of the 36 tests
in the file that does. The mutant was reverted; production code is unchanged.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmun8vb7vg3
@wenshao

wenshao commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator

Real-environment verification: PR #12992 @ 6ad9468d

Verdict: the fix works end to end in a real browser against a real daemon. I found no regression and recommend merging. CI is green at this head and all 5 review threads are resolved. The PR still needs an approving review.

Rig

  • I built the PR head 6ad9468d from source (npm run build + npm run bundle) in an isolated worktree. The daemon was node dist/cli.js serve from that build, with isolated QWEN_HOME/QWEN_RUNTIME_DIR, the repo's fake OpenAI-compatible model (integration-tests/fake-openai-server.ts), and a fixture workspace containing foo.md.
  • Two Web Shell clients ran against the same daemon: vite dev from the merge-base fa4a4c92 (before) and vite dev from the PR head (after). The two trees differ only in the 4 PR files. The production bundle that the daemon serves itself (dist/web-shell) was a third arm.
  • Environment: Playwright 1.61.1 / Chromium, macOS (Darwin 25.6, arm64), Node 24.18.1. Every run used a fresh browser context and a fresh session. Each run recorded four things: the editor structure before Send (text vs. chip widgets), the inputAnnotations in the browser's own POST /session/:id/prompt body, the rendered bubble, and screenshots.
  • Final pass: 18/18 runs completed (8 scenarios × before/after, plus 2 on the production bundle).

Results

"#n" means the range covers the n-th occurrence of @foo.md in the prompt.

Scenario (UI path) Before fa4a4c92 After 6ad9468d
S1 Issue repro: paste literal @foo.md then , then add @foo.md with + → Reference file [8,15) #1 (the typed text); bubble literal [chip] then @foo.md [21,28) #2 (the chip); bubble literal @foo.md then [chip]
S1 Same session after a page reload (bubble replayed from the daemon; chats/<id>.jsonl) still swapped; JSONL start 8, end 15 correct; JSONL start 21, end 28
S2 Chip inserted from the typed @ mention menu [8,15) #1 [21,28) #2
S4 chip · look-alike · chip [0,7) + [11,18) #2: the second chip renders as text and the look-alike renders as a chip [0,7) + [22,29) #3
S7 First send fails (HTTP 400; separately, a connection reset) → Restore local copy → resend [8,15) on both sends [21,28) on both sends
S6 Prompt sent while a turn is running (mid-turn queue) the model receives literal then @foo.md + attachment the model receives literal @foo.md then + attachment
S3 Control: chip placed before the look-alike [0,7) [0,7) (identical)
S5 Shell mode ! no annotations sent (POST /session/:id/shell, plain command) identical
— Production bundle served by the daemon, S1 and S4 — same ranges as "After"

Figure 1: #12980 repro with the + picker, before vs after, including reload

Figure 2: @ menu, chip/look-alike/chip, restore-after-failed-send, and control

Figure 3: mid-turn queue, where the annotation range decides the text the model receives

Worth knowing: before this PR, the bug also changed what the model received

The PR description says the submitted prompt string was always correct. That is true for the normal path: in S1 both arms send literal @foo.md then @foo.md. It is not true for the mid-turn queue. There, annotatedFiles() (packages/web-shell/client/hooks/useQueuedPrompts.ts:172) removes each annotated file range from the text and sends the file as an attachment. With the pre-PR range it removed the @foo.md the user typed, so the model received literal then @foo.md@attachment:///foo.md (Figure 3). This PR fixes that path too, without changing it. None of the PR's tests cover this consumer. A queue-level regression test would be a reasonable follow-up, but it should not block the merge.

Tests and static checks (macOS, Node 24.18.1)

  • The PR's 4 test files on the head: 149/149 pass.
  • The same test files against the merge-base source: 7 of 41 fail (4 in composerTag.test.ts and 3 in the new DOM test, for example expected [[8, 12]] to deeply equal [[18, 22]]). Both control cases pass. So the new tests catch the bug.
  • Full packages/web-shell vitest suite on the head: 394 files / 10410 tests pass (3 m 04 s). The PR's "Tested on" table leaves macOS unchecked; this run covers it.
  • tsc --noEmit for packages/web-shell, plus eslint and prettier on the 4 touched files: all clean.
  • I ran 8 mutation probes against the 4 test files. Each mutant was reverted and the tree checked clean afterwards.
    • 7 killed:
      • prefix shift forced to 0
      • shift that ignores the shell !
      • caller reverted to the pre-PR search
      • slice-match guard removed
      • stale-placement fallback removed
      • uniqueness check removed
      • fallback searching from the recorded start
    • 1 survived: deleting start >= 0 (composerTag.ts:221). This is the bot's R1-2, which the author declined. It cannot be reached today: the only caller clamps each start with Math.max(0, …) (useComposerCore.ts:2833), and the prefix shift is never negative.

Merge readiness

  • The head stayed at 6ad9468d throughout. These CI checks are green at this head: Test (ubuntu), Lint & Static, web-shell E2E Smoke, Integration (no-AK), Desktop Shell (ubuntu + windows) and web-shell visuals. All 5 review threads are resolved, and the last bot round (R4) posted no new finding.
  • git merge-tree against current main (afb911a3) is clean. main is 45 commits past the merge-base and changed 65 web-shell client files in that span, but none of those changes touch inputAnnotations, createInputAnnotationsFromComposerTags, inline tag placements or annotatedFiles.

Same on both arms (FYI, not caused by this PR)

  • Up-arrow history recall restores the prompt as plain text with no chip, and resending it sends no annotation.
  • After a mid-turn delivery, the bubble shows neither a chip nor an attachment marker for the reference.
  • Stock Web Shell sends ! commands to POST /session/:id/shell and drops inputAnnotations (App.tsx:17204). So the PR's shell-mode shift is only visible to hosts that read the onSubmit metadata. The unit test covers it (the "ignores the shell !" mutant was killed). Top tags (placement: 'top') are likewise host-API only, and the unit tests cover them too (the "prefix shift forced to 0" mutant was killed).

Not covered here: Windows and Linux browsers, the touch/textarea (mobile) backend, and custom host providers whose composerTag.serialized differs from the inserted text. That last case is the stale-placement fallback, which only the unit tests exercise.

中文版

真实环境验证:PR #12992 @ 6ad9468d

结论:修复在真实浏览器 + 真实 daemon 下端到端成立,未发现回归,建议合入。 该 head 上 CI 全绿,5 个评审 thread 已全部解决;仍需一个 approving review。

装置

  • 在隔离 worktree 中从源码构建 PR head 6ad9468d(npm run build + npm run bundle)。daemon 用该构建的 node dist/cli.js serve,QWEN_HOME/QWEN_RUNTIME_DIR 隔离,模型用仓库自带的假 OpenAI 兼容服务(integration-tests/fake-openai-server.ts),夹具工作区含 foo.md。
  • 同一个 daemon 上挂两个 Web Shell 客户端:merge-base fa4a4c92 的 vite dev(修复前)与 PR head 的 vite dev(修复后),两棵树只差 PR 的 4 个文件。daemon 自己托管的生产构建(dist/web-shell)作为第三组。
  • 环境:Playwright 1.61.1 / Chromium,macOS(Darwin 25.6,arm64),Node 24.18.1。每次运行都用全新浏览器上下文和全新会话,每次记录四项:发送前编辑器结构(文字 vs chip)、浏览器自己发出的 POST /session/:id/prompt 请求体中的 inputAnnotations、渲染出的气泡、截图。
  • 终轮:18/18 次运行全部完成(8 个场景 × 前/后两组,外加生产构建 2 个场景)。

结果

「#n」表示该区间覆盖 prompt 中第 n 次出现的 @foo.md。

场景(UI 路径) 修复前 fa4a4c92 修复后 6ad9468d
S1 issue 复现:粘贴 literal @foo.md then ,再用 + → Reference file 添加 @foo.md [8,15) #1(手打文字);气泡 literal [chip] then @foo.md [21,28) #2(chip);气泡 literal @foo.md then [chip]
S1 同一会话刷新页面后(气泡由 daemon 回放;chats/<id>.jsonl) 仍然颠倒;JSONL start 8, end 15 正确;JSONL start 21, end 28
S2 通过手打 @ 提及菜单插入 chip [8,15) #1 [21,28) #2
S4 chip · 同形文字 · chip [0,7) + [11,18) #2:第二个 chip 被渲染成文字,同形文字被渲染成 chip [0,7) + [22,29) #3
S7 首次发送失败(HTTP 400;另测连接重置)→ Restore local copy → 重发 两次发送都是 [8,15) 两次发送都是 [21,28)
S6 在一轮运行中发送(mid-turn 排队) 模型收到 literal then @foo.md + 附件 模型收到 literal @foo.md then + 附件
S3 对照:chip 在同形文字之前 [0,7) [0,7)(一致)
S5 shell 模式 ! 不发送注解(POST /session/:id/shell,纯文本命令) 一致
— daemon 托管的生产构建,S1 与 S4 — 区间与「修复后」相同

(图 1–3 见上方英文部分。)

值得注意:修复前这个 bug 也会改变模型收到的内容

PR 描述说提交的 prompt 字符串一直是对的。普通路径确实如此:S1 两组发出的都是 literal @foo.md then @foo.md。但 mid-turn 排队路径不是这样:annotatedFiles()(packages/web-shell/client/hooks/useQueuedPrompts.ts:172)会把每个带注解的文件区间从文本中删掉,改成附件发送。修复前的区间删掉的是用户手打的 @foo.md,于是模型收到的是 literal then @foo.md@attachment:///foo.md(图 3)。本 PR 没有改动这条路径,但也把它修好了。PR 的测试都没有覆盖这个消费方;补一个排队层面的回归测试可以作为后续工作,不应阻塞合入。

测试与静态检查(macOS,Node 24.18.1)

  • PR 的 4 个测试文件在 head 上:149/149 通过。
  • 同样的测试文件跑在 merge-base 源码上:41 个中 7 个失败(composerTag.test.ts 4 个,新 DOM 测试 3 个,例如 expected [[8, 12]] to deeply equal [[18, 22]]),两个对照用例通过。说明新测试确实能抓住这个 bug。
  • head 上 packages/web-shell 全量 vitest:394 个文件 / 10410 个用例全部通过(3 分 04 秒)。PR 的「Tested on」表里 macOS 未勾选,这次运行补上了。
  • packages/web-shell 的 tsc --noEmit,以及 4 个改动文件的 eslint 和 prettier:全部干净。
  • 针对这 4 个测试文件跑了 8 个变异体,每个都在事后还原并确认树干净。
    • 杀死 7 个:
      • 前缀位移强制为 0
      • 位移忽略 shell 的 !
      • 调用方回退到 PR 前的搜索方式
      • 删除切片校验
      • 删除陈旧区间回退
      • 删除唯一性检查
      • 回退改为从记录的起点开始搜索
    • 存活 1 个: 删除 start >= 0(composerTag.ts:221)。这就是机器人的 R1-2,作者已拒绝修改。目前这条路径不可达:唯一的调用方会用 Math.max(0, …) 钳住起点(useComposerCore.ts:2833),前缀位移也不会为负。

合入就绪情况

  • 验证全程 head 都停在 6ad9468d。该 head 上这些 CI 检查全绿:Test(ubuntu)、Lint & Static、web-shell E2E Smoke、Integration(no-AK)、Desktop Shell(ubuntu + windows)、web-shell visuals。5 个评审 thread 全部已解决,机器人最后一轮(R4)没有新发现。
  • 对当前 main(afb911a3)的 git merge-tree 干净。main 比 merge-base 多 45 个提交,期间改了 65 个 web-shell 客户端文件,但没有一处涉及 inputAnnotations、createInputAnnotationsFromComposerTags、内联 tag 区间或 annotatedFiles。

两组表现一致(仅供参考,非本 PR 引起)

  • ↑ 历史回溯只会把 prompt 恢复成纯文本、没有 chip,重发时也不带注解。
  • mid-turn 送达后,气泡里既不显示 chip,也不显示该引用的附件标记。
  • stock Web Shell 把 ! 命令发到 POST /session/:id/shell,并丢弃 inputAnnotations(App.tsx:17204)。因此 PR 的 shell 模式位移只对读取 onSubmit 元数据的宿主可见;单测覆盖了它(「位移忽略 shell 的 !」这个变异体被杀死)。顶部 tag(placement: 'top')同样只有宿主 API 能触发,也有单测覆盖(「前缀位移强制为 0」这个变异体被杀死)。

本次未覆盖:Windows 与 Linux 浏览器、触摸/textarea(移动端)后端,以及 composerTag.serialized 与插入文本不一致的自定义宿主 provider。最后这种情况对应陈旧区间回退,只有单测覆盖。

@doudouOUC doudouOUC 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.

Approving after a second independent pass at 6ad9468: no Critical found; all 5 threads resolved; the coordinate-space, prefix-arithmetic and repeated-text cases hold. CI green; BLOCKED is solely REVIEW_REQUIRED.

@wenshao
wenshao added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit 339bdeb Oct 1, 2026
155 checks passed
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.

bug(web-shell): reference chip attaches to earlier matching plain text after submit

3 participants