Skip to content

feat: add commit attribution with per-file AI contribution tracking#88

Open
BingqingLyu wants to merge 22 commits into
mainfrom
pr-3115-feat-commit-attribution
Open

feat: add commit attribution with per-file AI contribution tracking#88
BingqingLyu wants to merge 22 commits into
mainfrom
pr-3115-feat-commit-attribution

Conversation

@BingqingLyu

@BingqingLyu BingqingLyu commented Apr 27, 2026

Copy link
Copy Markdown
Owner

Motivation

When developers use AI agents to write code, there is no way to distinguish AI-generated changes from human-authored ones in git history. This creates challenges for:

  • Open-source projects requiring AI contribution disclosure
  • Enterprises needing compliance audits on AI-assisted code

This PR adds automatic, per-file AI contribution tracking that attaches structured metadata to commits without polluting commit messages.

How it works

AI edits file             git commit succeeds
     |                           |
     v                           v
 EditTool / WriteFileTool   ShellTool detects git commit
     |                           |
     v                           v
 recordEdit(path,           generateNotePayload(name, baseDir)
   oldContent, newContent)       |
     |                           v
     v                      buildGitNotesCommand(note)
 CommitAttributionService        |
 (singleton Map)                 v
     |                      git notes --ref=refs/notes/ai-attribution
     |                        add -f -m '<json>' HEAD
     |                           |
     +---------------------------+
                                 v
                        clearAttributions()

Example output

git notes --ref=refs/notes/ai-attribution show HEAD
{
  "version": 1,
  "generator": "Qwen-Coder",
  "files": {
    "src/services/commitAttribution.ts": {
      "aiCharsAdded": 3200,
      "aiCharsRemoved": 0,
      "aiCreated": true,
      "aiContributionPercent": 100
    },
    "src/tools/shell.ts": {
      "aiCharsAdded": 1500,
      "aiCharsRemoved": 0,
      "aiCreated": false,
      "aiContributionPercent": 100
    }
  },
  "summary": {
    "totalAiCharsAdded": 4700,
    "totalAiCharsRemoved": 0,
    "totalFilesTouched": 2,
    "overallAiPercent": 100
  }
}

Design decisions

Decision Rationale
Git notes instead of commit message Keeps commit history clean; notes are opt-in to push
Singleton service Attribution must accumulate across multiple Edit/Write calls before a single commit
128 KB size limit Prevents exceeding shell ARG_MAX; covers 1000+ files comfortably
5s timeout on git notes add Prevents blocking the user; git notes is local and normally sub-millisecond
Tied to gitCoAuthor toggle If the user opts out of AI attribution, notes are also skipped - single control point
Relative paths in JSON Uses config.getTargetDir() as base to avoid leaking absolute directory structures
Model name sanitization Internal codenames (e.g. qwen-72b) are replaced with Qwen-Coder

Edge case behavior

Scenario Behavior
Commit fails or is aborted Attribution data is cleared (not carried to next commit)
gitCoAuthor disabled Notes skipped, attribution data cleared
git commit --amend New note attached to the new HEAD; old orphan note stays
User edits (modified_by_user) Not tracked - only AI-originated changes are recorded
Note JSON exceeds 128 KB Silently skipped with debug warning; commit succeeds normally
git notes add fails Non-fatal; warning logged; commit result unaffected
No AI edits before commit hasAttributions() returns false, entire flow skipped

Changed files

New files

File Lines Purpose
services/commitAttribution.ts 249 Singleton service: tracks per-file AI char additions/removals
services/attributionTrailer.ts 80 Generates shell-safe git notes commands with size guard
services/commitAttribution.test.ts 165 14 tests
services/attributionTrailer.test.ts 127 7 tests

Modified files

File Change
tools/edit.ts +10 lines: call recordEdit() after successful AI edit
tools/write-file.ts +12 lines: call recordEdit() after successful AI write
tools/shell.ts +100 lines: attachCommitAttribution() method
tools/shell.test.ts +4 lines: reset attribution singleton in beforeEach

Test plan

Automated

  • Unit tests pass: 5832 / 5836 (4 skipped) across 240 core test files
  • Attribution-specific suites:
    • commitAttribution.test.ts: 27 tests (character contribution, snapshot/restore, baselines, generated-file detection, surface, prompt counters)
    • attributionTrailer.test.ts: 7 tests (shell-safe note command, size guard, formatting)
    • tools/edit.test.ts / write-file.test.ts / shell.test.ts: 136 tests (integration with recordEdit and attachCommitAttribution)
    • core/client.test.ts: 69 tests incl. 3 asserting attribution snapshot fires on UserQuery + ToolResult and skips Retry
  • TypeScript compiles with 0 errors
  • ESLint + Prettier pass (verified by pre-commit hook)

End-to-end (scratch repo + real CLI + real LLM)

  • Pure AI edit → commit → git notes contain correct aiChars / aiPercent: 100 / surface: cli
  • Mixed AI (write_file) + shell-echo append → humanChars > 0, percent reflects real ratio
  • Generated file (package-lock.json) excluded from files, appears in excludedGenerated
  • Initial commit in empty repo (no HEAD~1) falls back to diff-tree --root and records correctly
  • general.gitCoAuthor: false → commit succeeds with no git notes written
  • gh pr create --body "..." → body auto-appended with 🤖 Generated with Qwen Code
  • Session resume: write file in turn 1 (no commit) → --resume → commit in turn 2 → notes contain the turn-1 edits (requires the ToolResult-turn snapshot fix in this PR)
  • clearAttributions(false) on failed commit preserves getPromptsSinceLastCommit() counter; next successful commit resets it

wenshao and others added 22 commits April 10, 2026 22:14
…ia git notes

Track character-level AI vs human contributions per file and store
detailed attribution metadata as git notes (refs/notes/ai-attribution)
after each successful git commit. This enables open-source AI disclosure
and enterprise compliance audits without polluting commit messages.
…ted file exclusion

- Replace line-based diff with a prefix/suffix character-level algorithm
  for precise contribution calculation (e.g. "Esc"→"esc" = 1 char, not whole line)
- Compute real AI vs human contribution percentages at commit time by analyzing
  git diff --stat output: humanChars = max(0, diffSize - trackedAiChars)
- Add generated file exclusion (lock files, dist/, .min.js, .d.ts, etc.)
  ported from an existing generatedFiles.ts
- Add file deletion tracking via recordDeletion()
- Update git notes payload format: {aiChars, humanChars, percent} per file
  with real percentages instead of hardcoded 100%
… PR attribution

Align with the full attribution feature set:
- Surface tracking: read QWEN_CODE_ENTRYPOINT env var (cli/ide/api/sdk),
  include surfaceBreakdown in git notes payload
- Prompt counting: incrementPromptCount() hooked into client.ts message
  loop, tracks promptCount/permissionPromptCount/escapeCount
- Session persistence: toSnapshot()/restoreFromSnapshot() for serializing
  attribution state; ChatRecordingService.recordAttributionSnapshot()
  writes to session JSONL; client.ts restores on session resume
- PR attribution: addAttributionToPR() in shell.ts detects `gh pr create`
  and appends "🤖 Generated with Qwen Code (N-shotted by Qwen-Coder)"
- Session baseline: saves content hash on first AI edit of each file
  for precise human/AI contribution detection
- generatePRAttribution() method for programmatic access
…iled commit counter preservation

- Handle initial commit (no HEAD~1) by detecting parent with rev-parse
  and falling back to --root for first commit in repo
- Exclude Cron-triggered messages from promptCount (not user-initiated)
- Add commitSucceeded parameter to clearAttributions() so failed/disabled
  commits don't reset the prompts-since-last-commit counter
- Add test for clearAttributions(false) behavior
- Normalize path.relative() to forward slashes for Windows compatibility
- Use diff-tree --root for initial commits (git diff --root is invalid)
- Replace String.replace() with indexOf+slice to avoid $& special patterns
- Fix clearAttributions(false→true) when co-author disabled but commit succeeded
- Use real newlines instead of literal \n in PR attribution text
- Add surface fallback in restoreFromSnapshot for version compatibility
- Fix single-quote regex to not assume bash supports \' escaping
- Case-insensitive directory matching in generated file detection
- Handle renamed file brace notation in parseDiffStat
# Conflicts:
#	packages/core/src/core/client.ts
# Conflicts:
#	packages/core/src/core/client.ts
#	packages/core/src/services/chatRecordingService.ts
…ool edits

Previously, recordAttributionSnapshot() only ran at the start of UserQuery
and Cron turns — before the tools for that turn had executed. A session
that wrote a file in turn 1 and committed in turn 2 (across process
boundaries via --resume) lost the tracked edit: the last persisted
snapshot was the turn-1-start snapshot (empty fileStates), so on resume
the attribution service restored empty state and no git notes were
attached to the commit.

Move the snapshot call out of the UserQuery/Cron conditional and run it
on every non-Retry turn. ToolResult turns are scheduled right after
tools execute, so their start-of-turn snapshot now captures any edits
those tools made. Retry turns are skipped since the state is unchanged
from the prior turn.

Added unit tests asserting the snapshot fires for ToolResult/UserQuery
turns and skips Retry turns.

Verified end-to-end in a scratch repo: write-file in turn 1 (no commit)
→ exit → --resume → commit in turn 2 → git notes now contain the
recorded file with correct aiChars and promptCount: 2.
Collapse the two back-to-back messageType !== Retry blocks in
sendMessageStream into one, and refresh chatRecordingService's
recordAttributionSnapshot doc comment to reflect that snapshots fire
on every non-retry turn (not just after user prompts).
…#3559)

params.pages !== undefined let "" fall through to parsePDFPageRange(''),
which returns null and surfaced "Invalid pages parameter: ''" for every
read_file call from models that default optional strings to "".

Switch to a truthy check so "" behaves the same as an omitted field, and
add a regression test.

Fixes QwenLM#3558
…QwenLM#3540)

* feat(session): auto-title sessions via fast model, add /rename --auto

The /rename work in QwenLM#3093 generates kebab-case titles only when the user
explicitly runs `/rename` with no args; until they do, the session picker
shows the first user prompt (often truncated or misleading). This change
adds a sentence-case auto-title that fires once per session after the
first assistant turn, using the configured fast model.

New service: `packages/core/src/services/sessionTitle.ts` —
`tryGenerateSessionTitle(config, signal)` returns a discriminated outcome
(`{ok: true, title, modelUsed}` | `{ok: false, reason}`) so callers can
either handle failures generically or map reasons to actionable messages.
Prompt shape: 3-7 words, sentence case, good/bad examples including a
CJK row, JSON schema enforced via `baseLlmClient.generateJson`.
`maxAttempts: 1` — titles are cosmetic metadata and shouldn't fight
rate limits.

Trigger point: `ChatRecordingService.maybeTriggerAutoTitle` runs after
`recordAssistantTurn`. Fire-and-forget promise, guarded by:

- `currentCustomTitle` — don't overwrite any existing title.
- `autoTitleController` doubles as in-flight flag; a second turn while
  the first is still pending is a no-op.
- `autoTitleAttempts` cap of 3 — the first assistant turn may be a
  pure tool-call with no user-visible text; retry for a handful of
  turns until a title lands. Cap bounds total waste.
- `!config.isInteractive()` — headless CLI (`qwen -p`, CI) never auto-
  titles; spending fast-model tokens on a one-shot session is waste.
- `autoTitleDisabledByEnv()` — `QWEN_DISABLE_AUTO_TITLE=1` opt-out.
- `config.getFastModel()` falsy — skip entirely rather than falling
  back to the main model; auto-titling on main-model tokens is too
  expensive to be silent.

Persistence: `CustomTitleRecordPayload` grows a `titleSource: 'auto' |
'manual'` field. Absent on pre-change records (treated as `undefined`
→ manual, safe default so a user's pre-upgrade `/rename` is never
silently reclassified). `SessionPicker` renders `titleSource === 'auto'`
titles in dim (secondary) color; manual stays full contrast. On resume,
the persisted source is rehydrated into `currentTitleSource` — without
this, finalize's re-append would rewrite an auto title as manual on
every resume cycle.

Cross-process manual-rename guard: when two CLI tabs target the same
JSONL, in-memory state can diverge. Before writing an auto record, the
IIFE re-reads the file via `sessionService.getSessionTitleInfo`. If a
`/rename` from another process landed as manual, bail and sync local
state — never clobber a deliberately-chosen manual title with a model
guess. Cost is one 64KB tail read per successful generation.

`finalize()` aborts the in-flight controller before re-appending the
title record. Session switch / shutdown doesn't have to wait on a slow
fast-model call.

New user-facing command: `/rename --auto` regenerates via the same
generator — explicit user trigger, overwrites whatever's there (manual
or auto) because the user asked. Errors route through
`autoFailureMessage(reason)` so `empty_history`, `model_error`,
`aborted`, etc. each get actionable guidance rather than a generic
"could not generate". `/rename -- --literal-name` is the sentinel for
titles that start with `--`; unknown `--flag` tokens error with a hint
pointing at the sentinel. Existing `/rename <name>` and bare `/rename`
(kebab-case via existing path) are unchanged, except the kebab path now
prefers fast model when available and runs its output through
`stripTerminalControlSequences` (same ANSI/OSC-8 hardening as the
sentence-case path).

New shared util: `packages/core/src/utils/terminalSafe.ts` —
`stripTerminalControlSequences(s)` strips OSC (\x1b]...\x07|\x1b\\), CSI
(\x1b[...[a-zA-Z]), SS2/SS3 leaders, and C0/C1/DEL as a backstop. A
model-returned `\x1b[2J` or OSC-8 hyperlink escape would otherwise
execute on every SessionPicker render; both sentence-case and kebab
paths now route titles through the helper before they reach the JSONL
or the UI.

Tail-read extractor: `extractLastJsonStringFields(text, primaryKey,
otherKeys, lineContains)` reads multiple fields from the same matching
line in a single pass. Two separate tail scans could return a mismatched
pair (primary from a newer record, secondary from an older one with only
the primary set); the new helper guarantees the pair is atomic. Validates
a proper closing quote on the primary value so a crash-truncated trailing
record can't win the latest-match race. `readLastJsonStringFieldsSync`
is its file-reading wrapper — same tail-window fast path and full-file
fallback as the single-field version, plus a `MAX_FULL_SCAN_BYTES = 64MB`
cap so a corrupt multi-GB session file can't freeze the picker. Session
reads now open with `O_NOFOLLOW` (falls back to plain RDONLY on Windows
where the constant isn't exposed) — defense in depth against a symlink
planted in `~/.qwen/projects/<proj>/chats/`.

Character handling: `flattenToTail` on the LLM prompt drops a dangling
low surrogate after `slice(-1000)` — otherwise a CJK supplementary char
or emoji cut mid-pair produces invalid UTF-16 that some providers 400.
`sanitizeTitle` applies the same surrogate scrub after max-length trim,
and strips paired CJK brackets (`「」 『』 【】 〈〉 《》`) as whole units so
a `【Draft】 Fix login` doesn't leave a dangling `】` after leading-char
strip. `lineContains` in the title reader is tightened from the loose
substring `'custom_title'` to `'"subtype":"custom_title"'` so user text
containing the literal `custom_title` can't shadow a real record.

Tests: 46 new unit tests across
- `sessionTitle.test.ts` (22): success/all-failure-reasons, tool-call
  filter, tail-slice, surrogate scrub, ANSI/OSC-8 strip, CJK brackets.
- `chatRecordingService.autoTitle.test.ts` (15): trigger/skip matrix,
  in-flight guard, abort propagation on finalize, manual/auto/legacy
  resume symmetry, cross-process race, env opt-out, retry-after-
  transient.
- `sessionStorageUtils.test.ts` (13): single-pass extractor, straddle
  boundary, truncated trailing record, lineContains, multi-field atom.
- `renameCommand.test.ts` (8): `--auto` success, all reasons, sentinel,
  unknown-flag hint, positional rejection, manual/SessionService
  fallbacks.

* docs(session): design doc for auto session titles

Matches the session-recap design doc shape (Overview / Triggers /
Architecture / Prompt Design / History Filtering / Persistence /
Concurrency / Configuration / Observability / Out of Scope) and adds a
Security Hardening section unique to the title path — titles render
directly in the picker and persist in user-readable JSONL, so
LLM-returned control sequences are an attack surface the recap path
doesn't have.

Captures decisions a code-only reader has to reverse-engineer:

- Why `maxAttempts: 1` (best-effort cosmetic metadata; no retry loop).
- Why `autoTitleAttempts` cap is 3 (first turn can be pure tool-call).
- Why the auto trigger does NOT fall back to the main model but
  session-recap does (auto-title fires on every turn; silently charging
  main-model tokens is a bill surprise).
- Why `titleSource: undefined` stays unwritten on legacy records (no
  rewrite risks silently reclassifying user intent).
- Why the cross-process re-read sits between the LLM await and the
  append (manual wins at both in-process and on-disk layers).
- Why `finalize()`'s abort tolerates a controller swap (in-flight
  identity check).
- Why JSON-schema function calling instead of tag extraction (avoid
  reasoning preamble bleed; cross-provider reliability).

Placed at docs/design/session-title/ alongside session-recap,
compact-mode, fork-subagent, and other per-feature design docs. No
sidebar index update required — the design folder is unindexed.

* test(rename): pin model choice in bare /rename kebab path

Addresses reviewer feedback: the bare `/rename` model selection
(`config.getFastModel() ?? config.getModel()`) had no test pinning
it either way. Previous tests mocked `getHistory: []`, which exits
the function before the model is ever chosen, so a silent regression
to either direction (always-main or always-fast) would pass CI.

Two explicit cases now:
- fastModel set → `generateContent` called with `model: 'qwen-turbo'`.
- fastModel unset → `generateContent` called with `model: 'main-model'`.

The tests intentionally mock a non-empty history so the kebab path
reaches the generateContent call site instead of bailing on empty input.
* fix(i18n): sync mismatched keys between en.js and zh.js (QwenLM#3503)

Add 4 keys missing from en.js that are actively used in source code,
add 5 missing Chinese translations to zh.js, integrate check-i18n
into CI to prevent future drift, and skip JSON file write in CI to
avoid dirtying the working tree.

---
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
…M#3509)

* fix(cli): remove residual blank lines after MCP init completes (QwenLM#3095)

ConfigInitDisplay rendered <Box marginTop={1}> plus a content line, so
the live area grew by 2 rows during startup. When initialization
finished and the component unmounted, Ink shrank the live area but the
rows it had already committed to the terminal scrollback cannot be
reclaimed, leaving a visible gap above the input.

Move the MCP init status into the Footer's left-bottom status slot
(always mounted, fixed height) so the live area height stays constant
across the init → ready transition. The status participates in the
existing priority chain: ctrlC / ctrlD / escape / vim / shell /
autoAccept / configInit / hint.

* fix(cli): suppress MCP init message when custom status line is active

Audit follow-up. Previously the configInit branch preceded the
suppressHint branch in the footer's left-bottom priority chain. With
a custom status line configured, <Text>{null}</Text> collapses to
zero rows in Ink, so the footer's bottom row went from 1 row during
init to 0 rows after — a 1-row height oscillation that reintroduces
the same scrollback-residue symptom the original fix eliminated in
the default case.

Swap the order so suppressHint short-circuits to null first: the
init message now shares the hint's suppression rule, keeping the
footer's height constant in every configuration.

Also:
- Gate the hook's return on isConfigInitialized directly instead of
  letting the effect clear state, avoiding a one-frame flash where
  the stale "Initializing..." message leaks through on the first
  render after init completes.
- Cover the new behavior with three Footer tests, including a
  regression test for the custom-status-line case.

* fix(cli): show MCP init progress even under a custom status line

Reverting a UX trade-off introduced in the previous commit. That
change suppressed the init message whenever a custom status line was
active, arguing that <Text>{null}</Text> collapses to zero rows in
Ink and any non-zero init row would re-create a one-row shrink on
completion.

Zero shrink was the wrong goal. Hiding init progress from users who
have configured a status line is a real usability loss — the status
line does not surface MCP connection state, so those users now see
no feedback during startup. A one-time, one-line shrink on init
completion is a far smaller regression than the original two-row
scrollback residue this PR was created to fix, and strictly better
than the silent alternative.

Keep the init message in the left-bottom slot and let it sit above
suppressHint in the priority chain. Update the regression test so
that it pins the new behavior (init is visible with or without a
status line) and prevents the suppression from being reintroduced.

* fix(cli): keep MCP init progress visible in screen-reader mode

Footer is gated behind !isScreenReaderEnabled, so moving the init
message inside Footer silenced it for screen-reader users. Render the
same message as a plain Text node in Composer when the screen reader is
active — screen-reader users don't suffer from the live-area residual
row issue that motivated the original move, so an independent node is
safe for them.

* refactor(cli): drop duplicated screen-reader init path and show progress under YOLO

- ScreenReaderAppLayout already mounts <Footer /> directly, so the
  separate <Text> branch in Composer was producing a duplicated
  'Connecting to MCP servers...' line in screen-reader mode. Remove it.
- Move configInitMessage ahead of AutoAcceptIndicator in the footer's
  priority chain so users launched with YOLO / auto-accept-edits still
  see the ~1s startup progress; the approval-mode indicator takes over
  as soon as init finishes.
- Add unit tests for useConfigInitMessage covering the idle, progress,
  reset, and unsubscribe paths.
Co-authored-by: lawrence3699 <lawrence3699@users.noreply.github.com>
…ased approach (QwenLM#3502)

* feat(web-search): add GLM (ZhipuAI) web search provider

- Add GlmProvider class implementing BaseWebSearchProvider using the
  ZhipuAI Web Search API (https://open.bigmodel.cn/api/paas/v4/web_search)
- Support multiple search engines: search_std, search_pro, search_pro_sogou,
  search_pro_quark
- Support optional config: maxResults, searchIntent, searchRecencyFilter,
  contentSize, searchDomainFilter
- Truncate query to 70 characters per API limit
- Register 'glm' in the provider discriminated union (types.ts) and
  createProvider() switch (index.ts)
- Add GlmProviderConfig to settingsSchema, ConfigParams, and Config class
- Add --glm-api-key CLI flag and GLM_API_KEY env var support in webSearch.ts
- Forward GLM_API_KEY in sandbox environment
- Update provider priority list: Tavily > Google > GLM > DashScope
- Add 17 unit tests for GlmProvider and 4 integration tests in index.test.ts
- Update docs/developers/tools/web-search.md with GLM configuration,
  env vars, CLI args, pricing, and corrected DashScope billing info
- Fix stale OAuth/free-tier references in web-search.md

Closes QwenLM#3496

* docs(web-search): fix DashScope note and add GLM server-side limitations

* fix(web-search): make DashScope provider work with standard API key, remove qwen-oauth dependency

- DashScopeProvider.isAvailable() now checks config.apiKey instead of authType
- Remove OAuth credential file reading and resource_url requirement
- Use standard DashScope endpoint: dashscope.aliyuncs.com/api/v1/indices/plugin/web_search
- Read DASHSCOPE_API_KEY env var and --dashscope-api-key CLI flag
- Forward DASHSCOPE_API_KEY into sandbox environment
- Update integration test to detect DASHSCOPE_API_KEY
- Update docs to reflect new API key based configuration

* feat(web-search): remove built-in web search tool

The web_search tool and all related provider implementations are removed.
Web search functionality will be provided via MCP integrations instead,
which is the direction the broader agent ecosystem is moving.

Removed:
- packages/core/src/tools/web-search/ (entire directory)
- packages/cli/src/config/webSearch.ts
- integration-tests/cli/web_search.test.ts
- ToolNames.WEB_SEARCH, ToolErrorCode.WEB_SEARCH_FAILED
- webSearch config in ConfigParams, Config class, settingsSchema
- CLI options: --tavily-api-key, --google-api-key, --google-search-engine-id,
  --glm-api-key, --dashscope-api-key, --web-search-default
- Sandbox env forwarding for TAVILY/GLM/DASHSCOPE/GOOGLE search keys
- web_search from rule-parser, permission-manager, speculation gate,
  microcompact tool set, and builtin-agents tool list

* fix: remove websearch reference

* docs: remove websearch tool

* docs: add break change guide

* fix review
)

Selecting an older entry from input history via the arrow keys and pressing
Enter now moves that entry to the most recent position, so the next Up press
surfaces it first. Previously two bugs combined to keep stale copies in place:
the history-navigation index was not reset on submit, and deduplication only
collapsed consecutive repeats, leaving non-consecutive duplicates intact.
…3525) (QwenLM#3550)

* refactor(core): make OpenAI converter stateless to prevent shared-state races

Follow-up to QwenLM#3525. QwenLM#3516 showed that OpenAIContentConverter's long-lived
per-pipeline state raced between concurrent streams; QwenLM#3525 scoped the
streaming tool-call parser, this removes the remaining shared state.

- OpenAIContentConverter is now a module of stand-alone functions; the
  exported symbol is a namespace object preserved for call-site
  compatibility.
- New RequestContext (in types.ts, alongside PipelineConfig and
  ErrorHandler) carries model, modalities, startTime, and an optional
  per-stream toolCallParser. The pipeline builds one per request and
  threads it through every conversion call.
- errorHandler drops duration/isStreaming; duration is recomputed from
  startTime at error time and troubleshooting text is uniform.
- convertOpenAIChunkToGemini now throws if toolCallParser is missing so
  future misuse surfaces loudly instead of silently constructing a
  one-shot parser per chunk.

* test(core): align timeout expectations
…'error' event (QwenLM#3481)

* fix: strengthen error handling in launchBrowser to prevent unhandled events

* fix: strengthen error handling with ChildProcess type and debugLogger

* fix: use type-only import for ChildProcess
In ACP mode, the Mcp server list sent by the IDE client can include
SSE (type: "sse") and HTTP (type: "http") transports, but the previous
implementation only handled stdio servers via toStdioServer(). Non-stdio
servers were silently skipped (continue), so any SSE/HTTP-configured
MCP server would never be registered.

Changes:
- Add toSseServer() helper: detects type=="sse" servers and maps them
  to MCPServerConfig(url=..., headers=...)
- Add toHttpServer() helper: detects type=="http" servers and maps them
  to MCPServerConfig(httpUrl=..., headers=...)
- Refactor newSessionConfig() loop to handle all three transport types
- Declare mcpCapabilities: { sse: true, http: true } in agentCapabilities
  so IDE clients know this agent supports these transports without needing
  a transparent proxy
- Export the three helper functions for unit testing

Tests:
- Unit tests for toStdioServer / toSseServer / toHttpServer helpers
  (type discrimination, mutual exclusion)
- Integration-style tests for QwenAgent.initialize() mcpCapabilities
- Integration-style tests for newSession() with SSE/HTTP MCP servers,
  verifying MCPServerConfig is constructed with the correct arguments
  (url vs httpUrl, headers passthrough, empty-headers → undefined)

Fixes QwenLM#3472
…#3463)

* fix(cli): run ACP Agent tool calls concurrently (QwenLM#2516)

When the model returns multiple Agent tool calls in a single turn, the
ACP Session previously executed them sequentially in a plain for-loop,
multiplying latency by the number of sub-agents spawned.

Mirror the partition logic in coreToolScheduler.partitionToolCalls:
consecutive Agent calls form a parallel batch (safe because sub-agents
have no shared mutable state); any other tool forms its own sequential
batch so the model's implicit ordering is preserved. Response-part
ordering still matches the original functionCalls order.

Add a focused test that uses controllable deferred executes to prove
both Agent calls start before either resolves, and that the fed-back
functionResponse ordering is stable regardless of resolution order.

* Address PR QwenLM#3463 review: bound concurrency + robust test timing

Two issues raised by the /review bot:

1. The raw Promise.all fan-out bypassed the bounded-concurrency guard
   that coreToolScheduler applies via QWEN_CODE_MAX_TOOL_CONCURRENCY.
   Replaced with an inline runBounded helper that mirrors core's
   runConcurrently (Promise.race on a bounded executing set, default
   cap 10), keeping in-order result collection.

2. The concurrency test used a 10-iteration microtask yield loop before
   asserting both execute() spies had been invoked. That's fragile —
   runTool's pre-execute path (build → getDefaultPermission →
   evaluatePermissionRules → permission branch → PreToolUseHook) has
   more await boundaries than 10 ticks guarantees, and the CI run
   reported call-a still at 0 invocations at the assertion point.

   Reworked the test to wait on an explicit `called` deferred that
   resolves *inside* the execute() mock body. Under sequential
   behaviour only one `called` would ever fire → `Promise.all([called-a,
   called-b])` deadlocks → vitest's per-test timeout surfaces the
   regression. Under the fix both fire before either result resolves.

* fix(acp): degrade gracefully when AgentTool invocation has no eventEmitter

The concurrency test for QwenLM#2516 timed out on CI with "Test timed out in
5000ms" after the `await Promise.all([called-a, called-b])` rewrite in
the previous review-fix commit. The 5000ms wait was the symptom; the
root cause is that neither `execute()` was ever being called.

runTool's AgentTool branch was guarded with `'eventEmitter' in invocation`,
which is a *key-presence* check. The test mock provides
`{ eventEmitter: undefined, ... }` — the key exists (value undefined),
the branch is entered, and `SubAgentTracker.setup` immediately throws
inside `eventEmitter.on(...)`. The try/catch in runTool swallows the
throw and returns an error response, so `invocation.execute()` never
runs, `called[id].resolve()` never fires, and the test deadlocks.

The earlier review commit (4519c5f) interpreted the CI symptom as
"10 microtask yields aren't enough" and rewrote the assertion around a
deferred `Promise.all`. But the old test's `toHaveBeenCalledTimes(1)`
failure with 0 invocations was already the same bug — execute was never
called. The new formulation just converted the visible failure from an
assertion mismatch into a timeout.

Switch the guard to a truthy check against `invocation.eventEmitter`.
Semantics for real AgentTool are unchanged — `agent.ts:392` declares
`readonly eventEmitter: AgentEventEmitter = new AgentEventEmitter()`,
so production always enters the branch. The only new behavior is that
incomplete invocations (or test mocks) skip SubAgentTracker setup
cleanly instead of crashing. `subAgentCleanupFunctions` stays `[]`,
so the cleanup forEach at the success/error paths is a no-op.
…d 9;5u output (QwenLM#3544)

* fix(cli): disable Kitty keyboard protocol on SIGINT to prevent garbled 9;5u output

When a Kitty-capable terminal (iTerm2, Kitty, WezTerm) is used, the CLI
enables the Kitty keyboard protocol at startup via ESC[>1u. On exit, the
protocol must be disabled with ESC[<u to restore the terminal's default
key encoding. Failing to do so leaves the terminal in Kitty mode: any
subsequent Ctrl+C press is encoded as ESC[99;5u, and since the shell does
not understand this sequence, it echoes the trailing '9;5u' as garbled
text.

Root cause: kittyProtocolDetector registered cleanup handlers for 'exit'
and 'SIGTERM', but omitted SIGINT. A process terminated via SIGINT (e.g.
kill -INT <pid>, a parent process sending SIGINT, or certain process
managers) would exit without disabling the protocol.

Fix:
1. Add process.on('SIGINT', disableProtocol) alongside the existing
   'exit' and 'SIGTERM' handlers in kittyProtocolDetector.ts.
2. Export a new disableKittyProtocol() function for explicit call sites.
3. Call disableKittyProtocol() in the registerCleanup callback in
   gemini.tsx before instance.unmount(), so the disable sequence is
   written while stdout is fully operational regardless of exit path.

Fixes QwenLM#3528

* fix(test): add disableKittyProtocol to kittyProtocolDetector mock
# Conflicts:
#	packages/core/src/services/chatRecordingService.ts
@BingqingLyu

BingqingLyu commented May 7, 2026

Copy link
Copy Markdown
Owner Author

Conflict Group 1

This PR shares modified functions with 31 other PR(s): #1, #10, #100, #106, #107, #109, #112, #113, #114, #117, #14, #17, #18, #2, #21, #22, #26, #31, #36, #42, #46, #52, #6, #61, #7, #71, #75, #86, #90, #94, #96.

These PRs should be reviewed as a batch — merging one may affect the others.

Function File Also modified by
SessionListItemView SessionPicker.tsx #112, #113, #114, #117
TestContextConsumer AppContainer.test.tsx #113, #114, #117
authWithQwenDeviceFlow qwenOAuth2.ts #114, #117
buildOpenAIRequestForLogging loggingContentGenerator.ts #1, #113, #114, #117, #61
buildPermissionRules rule-parser.ts #112, #113, #114, #117
buildRequest pipeline.ts #1, #113, #114, #117, #61
convertGeminiResponseToOpenAIForLogging loggingContentGenerator.ts #1, #113, #114, #117
convertGeminiToolParametersToOpenAI converter.ts #1, #113, #114, #117
createRequestContext pipeline.ts #1, #113, #114, #117
createStreamWithChunks Session.test.ts #114, #117
createStreamingInputWithControlPoint permission-control.test.ts #112, #113, #114, #117, #71
createToolRegistry config.ts #112, #113, #114, #117
detectAndEnableKittyProtocol kittyProtocolDetector.ts #114, #117
determineProvider index.ts #1, #113, #114, #117, #90
executeStream pipeline.ts #1, #113, #114, #117
extractToolNameFromRecord export-html-from-chatrecord-jsonl.js #112, #113, #114, #117
finalize chatRecordingService.ts #112, #113, #114, #117
findSessionsByTitle sessionService.ts #112, #113, #114, #117
generateSessionTitle renameCommand.ts #112, #113, #114, #117
getTimeoutTroubleshootingTips errorHandler.ts #1, #113, #114, #117
getToolCallComponent ChatViewer.tsx #112, #113, #114, #117, #42
handleQwenAuth handler.ts #112, #113, #114, #117, #36
initialize acpAgent.ts #114, #117
isBrowserLaunchSuppressed config.ts #112, #113, #114, #117
isSdkMcpServerConfig config.ts #106, #112, #113, #114, #117, #18, #46, #75, #86
isToolExecuting AppContainer.tsx #100, #107, #109, #113, #114, #117, #52, #96
isValidSessionId config.ts #112, #113, #114, #117
listSessions sessionService.ts #112, #113, #114, #117
loadCliConfig config.ts #106, #112, #113, #114, #117, #36, #46, #75, #86
main check-i18n.ts #112, #113, #114, #117, #2
makeConfig permission-manager.test.ts #112, #113, #114, #117
newSessionConfig acpAgent.ts #114, #117
normalizeConfigOutputFormat config.ts #106, #112, #113, #114, #117, #18, #75, #86
parseApprovalModeValue config.ts #10, #112, #113, #114, #117, #21, #22, #36, #46, #86
parseArguments config.ts #10, #112, #113, #114, #117, #14, #17, #18, #21, #22, #31, #36, #46, #7, #86
parseRules rule-parser.ts #112, #113, #114, #117
prompt Session.ts #114, #117
readLastJsonStringFieldSync sessionStorageUtils.ts #112, #113, #114, #117
recordSlashCommand chatRecordingService.ts #112, #113, #114, #117
recordUiTelemetryEvent chatRecordingService.ts #112, #113, #114, #117
renameSession sessionService.ts #112, #113, #114, #117
resolveDefaultPermission permission-manager.ts #112, #113, #114, #117
startInteractiveUI gemini.tsx #114, #117, #14, #17, #31, #6, #94
start_sandbox sandbox.ts #10, #112, #113, #114, #117, #26, #7, #94
toPosixPath rule-parser.ts #112, #113, #114, #117
toStdioServer acpAgent.ts #114, #117
useDreamRunning Footer.tsx #112, #113, #114, #117, #86, #96
validateToolParamValues read-file.ts #112, #113, #114, #117

Posted by codegraph-ai conflict detection.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

conflicting-group-1 Conflicting PR group 1 — review as a batch conflicting-pr Shares at least one cross-PR dependency with other PRs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants