qwen-code/integration-tests/tsconfig.json
Shaojin Wen 89da433888
feat: delegate a subagent turn to an external agent over ACP (Claude Code first) (#11003)
* feat: delegate a subagent turn to an external agent over ACP

A subagent definition can now declare an `executor` block naming an external
agent process. The turn runs there over ACP and is re-published as the same
AgentEventEmitter events the in-process path emits, so the JSONL transcript
writer, SubAgentTracker's nested-permission bridge, virtual subagent sessions
and the Web Shell subagent panel all work unchanged. Claude Code is the first
agent wired up.

Delegating rather than exposing a foreign agent as a peer session backend keeps
the parent Qwen session authoritative: no re-keying of workspace identity from
cwd to (cwd, backend), and no change to the bridge's one-channel-per-runtime
invariants. Agent definition files already mirror Claude Code's
`.claude/agents/*.md` schema verbatim, so this extends that compatibility from
the definition layer to the execution layer.

Core side: a `SubagentExecutor` interface narrowed to the members production
callers actually use, which `AgentHeadless` now implements so drift fails at
compile time; `createAgentHeadless` returns the interface and dispatches on
`config.executor`; `AgentCore.buildChatSystemPrompt` is extracted as
`renderSubagentSystemPrompt` (a verbatim move, the private method delegates) so
an executor that never builds an AgentCore produces a byte-identical prompt;
`dispose?()` is composed into the returned dispose so the external process
cannot outlive its subagent; and the host factory is injected through
`Config.setExternalAgentExecutor`, following the existing
`setSessionWorkflowEnabledProvider` inversion, so core gains no ACP dependency.
The types a host needs are exposed on a narrow `./subagentRuntime` subpath
rather than the package root. Because the key (`subagentRuntime`) does not mirror
its source stem (`subagent-runtime.ts`), the wildcard cannot resolve it, so every
resolution map that fronts core's exports carries an explicit entry: the cli
`tsconfig` paths and vitest alias, `integration-tests/tsconfig.json` paths, and
the skill-review-harness loader's named map. A cross-package contract test pins
the export entry, the barrel's re-exports and the cli resolutions; the repo's own
`integration-tsconfig-core-paths-sync` and `text-capture-core-loader-sync` guards
pin the other two, so a future named export that forgets a mirror fails CI rather
than silently falling through to a stale `dist`.

Everything that could silently substitute a different agent, or grant authority
nobody approved, fails loud instead. Three bypass paths are closed. Frontmatter
rejects a malformed `executor` block outright rather than dropping it — dropping
it leaves `config.executor` undefined, so no downstream check engages and the
turn runs in-process under Qwen's model with nothing on stdout, stderr or the
debug log — and the block is re-validated at the consumption point, because
session-level subagents are injected as plain objects and bypass frontmatter
parsing entirely, and at serialization, so a save/reload round trip cannot drop
the backend. An executor-bearing file whose frontmatter YAML does not parse is
rejected too, but only such a file: the validation reads the original document
node and refuses when `parseDocument` reports an error AND the file claims a
top-level `executor` — by AST key, by the sanitized frontmatter value, or by a
raw-text probe anchored to column 0 with optional quotes, so a quoted
`"executor":` is caught (the lenient parser keeps the quotes in the key and a
repaired `parseDocument` nests it, so both miss it) while an indented `executor:`
under `mcpServers:` or inside a `description: |` block scalar is not (it declares
nothing and must not hard-refuse an unrelated definition). The error must also be
able to reach the executor. The guard refuses when `parseDocument` — the real
YAML parser — lost a text-claimed executor key (`!hasExecutor`): YAML errors are
not line-local, so a compact-mapping error on an earlier line drops the whole
remainder, and the lenient `parseSimple` fallback must not be trusted to rebuild
it — it is a line-based heuristic that mangles block scalars and sequences,
turning `command: |` plus an indented `npx` into `command: '|'` and so spawning
an executable literally named `|` that the file never declared. It also refuses
when `parseDocument` kept the node but an error sits at or after the executor's
line, where a repair could rewrite it into a different command/args. Only an
error strictly before the executor line that `parseDocument` survived
(`hasExecutor` still true) leaves the subtree byte-faithful, so a valid
external-agent definition — e.g. one carrying a duplicate `name:` from a bad
merge — keeps loading instead of vanishing from `/agents` over an unrelated
quirk. And because `parseDocument` tolerates an unresolved YAML alias
(`command: *undef`) with an *empty* `document.errors` while `toJS()` throws when
it resolves the node, the `toJS()` read is wrapped so that case is refused as an
invalid executor block rather than escaping parseSubagentContent as a raw parse
error the errors-based guard cannot see. For a real
executor the lenient parser would otherwise repair a malformed document into a
node that dispatches a different command/args than the file declares, or drops
the key and runs in-process. Every other definition keeps loading through the shared
parser's lenient `parseSimple` fallback — a description containing a colon, a
tab-indented field, or a duplicate key from a bad merge must not silently vanish
from `/agents`. The refusal message carries `invalid executor block` so
`warnInvalidSubagentFile` surfaces it on stderr instead of the debug log.
`convertToRuntimeConfig` rejects executor definitions,
which is the path team and background spawning use. Workflow `agent()` rejects
them before spawning, because token budgets, schema output and workflow tool
restrictions cannot be enforced across the process boundary — that covers bounded
and unbounded workflows alike. Cold resume is denied for a transcript carrying
`executor: 'acp'` or a legacy `external-acp:*` model label; the label is
deny-only and never selects an executor. With no factory registered, `create()`
throws before warming providers or resolving a model, and an ordinary Error from
an external factory propagates unwrapped instead of being relabelled as an
AgentHeadless construction failure. This deliberately diverges from the lenient
drop used for mcpServers and hooks: losing those degrades a capability, losing
this one substitutes a different agent.

The permission mode is the host's effective, already-clamped approval policy —
not the definition's raw request and never the external agent's own config. The
Agent tool resolves the definition's `approvalMode` against the parent session's
mode and folder trust and stamps the result onto the runtime context, and the
manager reads that resolved value back when it dispatches, so a definition
cannot escalate the peer past the parent session's limit. The executor requires
the resolved mode to be present in `session.modes.availableModes` and applies it
with `session/set_mode` before the first prompt, because the agent's own
`defaultMode` was measured to let a Write execute with no permission request
emitted at all. When the agent offers no option matching the user's outcome the
executor denies rather than falling back to the first offered option — approving
"proceed once" against `[allow_always, reject_once]` must not answer
`allow_always`. `optionKindForOutcome` is exhaustive over the enum with a `never`
check and a runtime default that denies, so an unmapped outcome cannot become a
grant. A host-policy denial (headless, permission avoidance, a display-only
TOOL_WAITING_APPROVAL listener, or a routed interactive question) rejects the
TOOL by selecting a `reject_once` option rather than answering ACP `cancelled`,
because `cancelled` tells a conforming peer the whole turn is over and would
abort the delegation on its first sensitive tool; it falls back to `cancelled`
only when the peer offered no reject option, and the stream-json responder path
still answers normally. An explicit user rejection of a single tool — the
dialog's Cancel/Esc, which `selectPermissionOption` maps to undefined — likewise
selects the peer's `reject_once` option instead of answering `cancelled`, so
denying one action scopes to that tool and the turn continues, matching the
button's "suggest changes" promise and the in-process sibling (which records
"User did not allow tool call" and lets the model proceed) rather than abandoning
the remaining work and billing a fresh prompt to re-delegate.

Child-process handling: the child is spawned detached with `sanitizeChildEnv`,
because `executor.command` comes from a project-level file, so a repository the
user merely cloned picks the executable and must not receive the daemon bearer
token. The handshake races a 10s deadline against both the `error` and `exit`
events, since a command that spawns and stays silent would otherwise hang
`create()` forever. Turn wall time defaults to 10 minutes when
`max_time_minutes` is omitted, matching the workflow subagent default, and an
invalid value throws before spawn. The child is owned by acp-bridge's
ProcessRegistry with `ownsProcessTree`, so disposal escalates SIGTERM to
SIGKILL and a SIGTERM-resistant descendant cannot survive; `dispose()` suppresses
only the two shapes where the foreign root is already gone — the signal it was
sent, and a root that exits on its own mid-snapshot (a Linux race where the
prompt rejects on stdout EOF before the process `exit` event, invalidating the
initial tree snapshot) — via the exported, unit-tested
`isExpectedExternalAgentCleanupExit`. The second shape covers the peer's own exit
status for ANY code/signal (`exited uncleanly during shutdown`), which acp-bridge
raises only after it has driven every owned process group to empty, so it reports
a foreign agent that exited — already reflected in `terminateMode` — not a cleanup
failure; letting `dispose()` reject on it would replace the turn's declared
terminal state (a user-CANCELLED turn would surface to the parent as `failed`).
Every genuine cleanup-PROOF failure (a truncated snapshot, an absent or
non-group-leader root, a failed snapshot/signal/inspect, or an exceeded exit
deadline — i.e. a descendant that may have survived) still propagates. A
post-handshake exit handler drains parked approvals;
its ERROR emit is guarded by `rawListeners`, because AgentEventType.ERROR is
Node's 'error' event and the background, resume and workflow emitters attach no
listener, so an unguarded emit would turn a child crash into an uncaught
exception. stderr goes through acp-bridge's `createStderrForwarder` for chunk
buffering and credential redaction. `methodNotFound` returns a real
`RequestError`, the only form the SDK preserves as -32601 rather than
repackaging as -32603, and the SDK's `extMethod` hook answers the same way
instead of hanging the agent.

Token usage for an external turn is unknown rather than free: the adapter
exposes only a context-window gauge, and feeding a level into accumulating
statistics would inflate totals past the window. The Agent tool therefore
suppresses execution stats for external subagents, skips live and completion
stat refresh, reports no execution summary, and appends "[External executor
token usage and cost are unavailable.]" to the result. That notice is a suffix
appended after the model-visible text and its empty-text fallbacks on both the
foreground and background completion paths, never baked into the text itself —
baking it in would make a non-GOAL background run that produced no text publish
the notice in place of the real failure reason (`Agent terminated with mode:
TIMEOUT`), because the `finalText || <reason>` fallback would see a non-empty
string. Transcript metadata records `executor: 'acp'` and no `persistedCliFlags`,
so the provenance is inspectable and the resume denial above has something to key
on. Stop reasons are mapped narrowly — end_turn to GOAL, max_turn_requests to
MAX_TURNS, cancelled to CANCELLED, and refusal, max_tokens and anything unknown
to ERROR — so a truncated external turn cannot be reported as completed, and a
peer that *refused* the task is not relabelled as a user cancel (CANCELLED
renders "cancelled by the user", a false statement that also hides the refusal
from telemetry). A wall-time expiry is classified by the timer's own
`ExternalAgentTimeoutError` identity rather than re-reading `Date.now()` in the
catch, because a second realtime clock can miss the monotonic deadline by under a
millisecond and misreport a turn that merely reached its budget as ERROR instead
of TIMEOUT. The continuation loop also re-checks that budget before dispatching
each round: `remaining` clamps to 0 once the wall time is spent, and without a
guard `connection.prompt(...)` is evaluated first — a new, billed model turn
really reaches the peer — one tick before `wait`'s 0ms timer rejects with
`ExternalAgentTimeoutError`, so the catch disposes (SIGTERM/SIGKILL) a peer that
just started work and the message the round already drained and recorded as
delivered is never processed. The loop now sets `TIMEOUT` and breaks before that
dispatch when `remaining <= 0`, mirroring the in-process sibling, which checks
the budget at the top of every round; it sets `terminateMode` directly rather
than throwing, because the catch classifies wall-time expiry by error identity.
And a continuation `execute()` turn — a resident external agent
re-invoked per incoming user message — emits the task as a user-side
`EXTERNAL_MESSAGE`, matching the in-process sibling, so the JSONL transcript does
not lose every message after the first. When a turn ends with a tool call still
open (cancel sets `cancelled` before the peer's terminal `tool_call_update` can
arrive, and timeout or peer crash leave it mid-flight), `runTurn`'s finally
flushes every entry left in the tool map as a failed `TOOL_RESULT` +
`TOOL_RESPONSES_FINALIZED` before `FINISH`, so the inline frame and Web Shell row
stop showing a tool executing forever, the JSONL `functionCall` is paired with a
`tool_result`, and the `FINISH` totals sum — the in-process sibling closes the
same gap via `onAllToolCallsComplete`. The session-update entry point drops any
update once the turn is over for ANY reason — its guard now also checks
`disposed` and the integrity-abort signal, not just `cancelled` — because on the
timeout and crash paths the catch awaits `dispose()` before the finally clears
`executing` and `cancelled` stays false, so a peer still writing would otherwise
be appended to `finalText`, emitted as `STREAM_TEXT`/`ROUND_TEXT`, and counted
into the `FINISH` totals for up to `EXIT_DEADLINE_MS` after termination, handing
a partial result to the parent verbatim on the non-rethrowing TIMEOUT path.
Safe mode also refuses an external executor at the dispatch gate, not only by
filtering discovery: `loadSubagent` resolves a `subagent_type` from disk
regardless of safe mode, and a cloned repo can ship `.qwen/agents/<builtin>.md`
carrying an `executor` that resolves before the built-in of the same name, so
safe mode now throws rather than spawn a definition-supplied binary.

The design doc now describes only the shipped product and these fail-loud
rules; the rejected peer-backend alternative is recorded as rejected.

Verification: core and cli `tsc` 0 errors, ESLint and Prettier clean, `git diff
--check` clean, cli executor tests 48/48, core subagent/agent/runtime suites
1483/1483, cross-package contracts and the two core-export sync guards 17/17.
Mutation
proofs, each run in both directions: removing the frontmatter executor guard
fails 8 tests, removing the workflow `agent()` rejection fails 6, replacing the
deny fallback with the first offered option fails 3, returning a plain Error
instead of `RequestError` fails the -32601 wire assertion, narrowing
`isExpectedExternalAgentCleanupExit` back to the signal-only form fails the
cleanup-exit classification test (which now also pins tolerating the peer's own
non-zero exit code), answering a host-policy denial with `cancelled`
instead of a `reject_once` selection fails 5, deriving the peer mode from the raw
definition instead of the host-resolved policy fails its dispatch test, reading
the sanitized frontmatter value instead of the original YAML node fails the
null-args rejection, making the `document.errors` refusal unconditional (instead
of executor-scoped) fails the non-executor lenient-load test, un-anchoring the
executor-claim text probe (dropping the column-0 / optional-quote form) fails
both the quoted-key refusal and the nested-key lenient-load tests, disabling the
turn-end `flushOpenTools` fails the open-tool flush test, deleting the
`loadCliConfig` executor registration fails the registration test, narrowing the
session-update guard back to `cancelled`-only lets a post-timeout `LATE` chunk
into `finalText` (failing the post-terminal-drop test), and disabling the
safe-mode dispatch refusal lets an external executor run in a trusted safe-mode
folder (failing the safe-mode refusal test), routing a user's Cancel back to ACP
`cancelled` (dropping the `reject_once` fallback at the respond site) fails the
user-cancel denial test, and reverting the executor-error guard to fire on any
`document.errors` entry instead of scoping to errors that reach the executor line
fails the unrelated-error-before-executor load test while the quoted-key and
unterminated-quote refusals stay green, removing the continuation budget guard
lets a 0-`remaining` round dispatch a second prompt (failing the
no-dispatch-after-budget test, which then sees two prompts and GOAL instead of
one and TIMEOUT), and reverting `astLostExecutor` to also require
`frontmatter.executor === undefined` lets a `parseDocument`-dropped executor load
through the `parseSimple` fallback as `command: '|'` (failing the dropped-node
refusal test), and re-throwing the raw error from the `toJS()` wrapper instead of
the graceful `SubagentError` fails the unresolved-alias refusal test (the
rejection becomes the raw "Unresolved alias" YAML error, not /invalid executor
block/). Mapping
`refusal` back to CANCELLED fails the continuation test, and dropping the
continuation `EXTERNAL_MESSAGE` emit fails the transcript test. The wall-time
test was the flake the timeout-classification fix removes; it now passes 5/5
deterministic runs. The real-subprocess suite is gated
`describe.skipIf(process.platform === 'win32')` like the repo's other
real-process suites, because Windows tree-kill reports a numeric exit code that
`dispose()` surfaces as unclean — the `test_windows` lane is merge-queue-only, so
an ungated suite there would eject the entry. The executor tests
drive a real ACP child process speaking the wire protocol, across
init/session/mode/prompt hangs and exits, max_tokens and unknown stop reasons,
duplicate permission callIds and optionIds, ask-user denial, descendant tree
kill, env sanitization and unsupported-extension responses. A separate
end-to-end run drove the real `@agentclientprotocol/claude-agent-acp` adapter and
a real model through this executor: `session/set_mode` `default` landed before
the prompt despite conflicting factory inputs, one permission request was emitted
and answered, a `Cancel` produced no file while a `ProceedOnce` selected the
adapter's `allow-once` (not its broader `allow-always`) and wrote exactly the
requested content, and both turns reported zero tokens — which is why the Agent
tool suppresses the summary rather than presenting zeros as free.

Not done: the real-adapter run above is a direct-factory harness, not the full
settings-loader → CLI → Web Shell path, and it does not exercise a browser
approval dialog, a second permission request in one turn, or headless auto-denial
against the real adapter; only macOS has been exercised, so the Windows `.cmd`
spawn path is unimplemented and untested (R3-1, left open — `cross-spawn`, the
repo's safe Windows launcher, is not a `packages/cli` dependency, and `shell:
true` around a project-supplied command is an injection surface, so this needs a
dependency decision and a Windows-testable design rather than a guess); the
optional mid-turn external-message waiter is unimplemented (R3-6, left open —
queued messages are still drained between turns, but an external subagent cannot
park for a not-yet-arrived one); and the Web Shell approval dialog still uses the
`info` confirmation variant, so it renders no file diff and has no screenshot
here. `ROUND_START`/`ROUND_END`/`USAGE_METADATA` are still not emitted, but the
one Critical consumer — the workflow stall watchdog, which arms only on those
events — can no longer be reached by an external agent at all, because workflow
`agent()` rejects external-executor definitions before spawn; `USAGE_METADATA`
also has no honest source (the adapter exposes a context-window gauge, not
per-turn token deltas). The round-4 through round-7 fixes in this commit were
audited to convergence by mutation proof and direct full-diff review; the earlier
rounds' agent-driven directionless audit did not converge because those agents
died on infrastructure failures.

* fix(subagent): close the round-10 review Criticals on the external executor

Five Criticals from the round-10 review of the external-agent executor and its
definition loader. Each is mutation-verified (reverting the fix turns its test
red) and the surrounding guarantees are pinned.

R10-1 — the executor-frontmatter parser derived both the claim and the value
from a column-0 regex plus the lenient `parseSimple` fallback instead of the
real YAML AST, which got the verdict wrong in both directions. Under-detection:
a TAB-indented top-level `executor:` is invalid YAML the AST drops, and a
column-0 probe missed it, so the definition loaded executor-less and ran
in-process under a Qwen model with nothing on any channel. Invented dispatch:
`parseSimple` hoists an `executor:` line sitting inside a `description: |` block
scalar into a top-level key, so a file declaring no executor loaded as external
and dispatched a command that exists only as prose. Both legs now come from the
AST: the claim probe is indentation-tolerant (`^[ \t]*`), `claimsExecutor` no
longer reads `frontmatter['executor']`, and `executorRaw` is `undefined` (never
the `parseSimple` value) when `parseDocument` has no top-level executor node.
The accepted cost — an `executor:` token nested under another key in an
otherwise-malformed file is now refused — is a visible, user-fixable
over-refusal, which beats an invisible substitution or an invented dispatch.

R10-2 — the load-time executor refusals were file *skips*, so `loadSubagent`'s
session>project>user>extension>builtin fall-through substituted a
lower-precedence in-process definition (or a case-insensitive builtin) of the
same name and the delegated turn ran under a Qwen model — the exact substitution
this feature prevents, with only a discovery-time `console.warn`. Executor-block
refusals are now recorded by declared name during the level scan (the
`parseSubagentContent` re-wrap preserves the `SubagentError` and its
`subagentName`), and the by-name fall-through path throws the recorded refusal
before descending or reaching `getBuiltinAgent`. Scoped to executor refusals (not
parse failures generally) so an arbitrary malformed repo file cannot disable an
unrelated builtin, and scoped to the no-level fall-through path (the explicit
-level path does not fall through, so it cannot substitute) to keep the
management callers' contract unchanged; `isNameAvailable` treats a refusal as
"name taken".

R10-3 — `isExpectedExternalAgentCleanupExit` swallowed acp-bridge's
`exited before its initial process-tree snapshot completed`, which
`mergeAsynchronousSnapshot` records and returns BEFORE `collectOwnership`, so a
detached (`setsid`) descendant was never enumerated, signalled or reaped — the
tree was not proven gone, yet `dispose()` resolved silently and the parent saw a
clean teardown. That shape is now `isUnprovenExternalAgentTreeExit`: `dispose()`
reports it (`debugLogger.warn` + an `AgentEventType.ERROR`) and resolves rather
than rethrowing (it is awaited between terminal-state classification and return,
so rethrowing would convert a classified TIMEOUT/CANCELLED into a thrown ERROR).
`isExpectedExternalAgentCleanupExit` is narrowed to the peer's own unclean exit
(raised only once the tree is proven gone); every other cleanup-proof failure
still propagates.

R10-4 — `runTurn` destructively drained the external-input queue and recorded the
messages as delivered before the round-top budget check, so a wall-time expiry
(or a bottom-of-loop abort) discarded user messages already emitted as delivered
— the transcript certified delivery of a message never sent, and the registry had
already spliced it out. The drain is now gated on the budget and the abort signal
first, mirroring the in-process sibling (`agent-core` checks the budget before
`drainExternalInputs`): when the budget is spent or the signal aborted, the loop
breaks before draining, so the message stays queued for a future turn.

R10-5 — an absent `max_time_minutes` silently meant "10 minutes then terminate"
for an external agent, while meaning "no cap" everywhere else, so the same
definition behaved differently based only on whether it declared an executor, and
a TIMEOUT turn (handed to the parent as the answer with no truncation marker) was
reachable by default. The cited rationale was void: `DEFAULT_WORKFLOW_SUBAGENT_
MAX_TIME_MINUTES` is read only on the workflow path, which hard-rejects external
executors upstream, so it never reaches this executor. An absent value is now
passed through as `undefined` (no timer), matching `agent-core`; a caller that
wants a cap supplies `max_time_minutes` (validated positive, finite, ≤ the Node
timer range).

Verification: core and cli `tsc` 0 errors (after a full `npm run build` to refresh
the workspace `dist` the merge had left stale); ESLint and Prettier clean; `git
diff --check` clean; cli executor suite 50/50; core subagent/agent/runtime
suites 2510 passed (6 skipped); cross-package contracts and the two core-export
sync guards 17/17. Mutation proofs, each run in both directions: reinstating
`?? 10` fails the no-default-cap test; removing the pre-drain budget/abort gate
fails the queue-preserved test (the message is drained, recorded delivered, and
lost); making `dispose()` swallow the snapshot-race error fails the
unproven-tree-report test; reverting the claim probe to column-0 anchoring fails
the nested-token refusal test; restoring the `parseSimple` fallback for
`executorRaw` fails the block-scalar-prose test (it loads `command:'npx'` from
prose); and no-oping the recorded-refusal throw fails the by-name dispatch test
(it resolves the builtin instead of rejecting). Reverse-audit converged after one
fix (scoping the R10-2 throw to the fall-through path so the explicit-level
management callers keep their null contract), then clean passes over the
production and test diffs.

* fix(subagent): close the round-11 review Criticals on the external executor

Six Criticals from the round-11 review: five on the executor and its definition
loader, one a regression the round-10 R10-2 fix itself introduced. Each is
mutation-verified (reverting the fix turns its test red).

R11-1 — a file that CLAIMS an executor but fails an EARLIER validation (a
missing description, a bad approvalMode) was skipped with nothing recorded, so
loadSubagent's fall-through silently substituted the builtin of the same name.
The executor claim and the trusted declared name are now computed at function
scope, right after the frontmatter is parsed (BEFORE any validation can throw),
so the catch converts ANY load failure of an executor-claiming file into a named
executor refusal the fall-through throws. (A file whose own name is unparseable
stays undefined-keyed and falls through to the generic wrap — it cannot be
matched by name anyway.)

R11-2 — the wall-time budget was computed per-turn (timeoutMs - elapsed this
turn), so a resident agent re-invoked per incoming message (resetStats:false,
which preserves durationMs) got a FRESH max_time_minutes cap each continuation
and could overrun the whole-delegation budget indefinitely. Both remaining
computations now subtract the accumulated durationMs, matching the in-process
sibling's preserveStats base: max_time_minutes caps the whole delegation. Both
production continuation callers (background-agent-resume, agent) pass
resetStats:false, so the cumulative cap reaches the real path.

R11-3 — the host approval mode was used AS the peer's mode id, but the
vocabularies differ (host/qwen: auto-edit/auto/yolo; Claude: acceptEdits/
bypassPermissions), so a host auto-edit could select a peer mode that does not
exist (or, worse, a qwen peer's broader auto). resolvePermissionMode now maps the
host policy to a peer-vocabulary-independent TOKEN (default/plan/acceptEdits/
bypass) and connect() picks the first id the peer actually advertises via a
canonical-first alias table, refusing (naming the policy) when none is
advertised. The mapping never WIDENS: acceptEdits prefers the peer's narrowest
edit-only alias.

R11-4 — the executor refusal was keyed by the lenient parseSimple name, which
strips only double quotes, so a single-quoted `name: 'Explore'` was recorded
under "'explore'" and missed the 'explore' dispatch lookup, falling through to
the builtin. The refusal is now keyed by the real YAML AST's name (parseDocument
strips both quote styles), with the lenient value only as a fallback when the
AST read throws.

R11-5 — the approval confirmation's prompt rendered the OPTION LABELS (which the
dialog already shows as buttons) and dropped the action's arguments, so the user
approved "Write" without seeing the `rm -rf ./build` it would run. The prompt is
now describeExternalAction(toolCall): the title plus the rawInput, bounded to
300 chars and stripped of C0/C1/DEL control chars (foreign-process data, never
rendered as markup).

R10-2 (fix-induced) — extension agents load via loadSubagentFromDir, which skips
+ warns on a refusal, so the R10-2 level-scan recording never ran for them and
the extension leg of the fall-through read an empty map. loadSubagentFromDir now
takes an optional refusal collector; the runtime loadExtension records each
extension's executor refusals onto extension.agentExecutorRefusals, and
listSubagentsAtLevel('extension') merges them into the 'extension' refusal
bucket before findSubagentByNameAtLevel returns, so the by-name fall-through
refuses them too. (The install/update consent path also calls
loadSubagentFromDir, but only for display — it does not feed the dispatch
fall-through, so it needs no collector.)

Verification: core and cli tsc 0 errors; ESLint and Prettier clean; cli executor
suite 57/57; core subagent-manager suite 199/199; core subagent/agent/runtime
regression net 2513 passed (6 skipped). Mutation proofs: dropping the cumulative
durationMs subtraction fails R11-2 (turn 2 ends GOAL, not TIMEOUT); removing the
peer-canonical acceptEdits alias fails R11-3 (the auto-edit dispatch finds no
advertised mode); reverting the approval prompt to the title fails R11-5 (no
`rm -rf ./build`); neutralizing the catch conversion fails R11-1 (loadSubagent
resolves the builtin); keying by the lenient name fails R11-4 (single-quoted
name resolves the builtin); and no-oping the extension-refusal merge fails the
R10-2 extension leg. Reverse-audit converged after one fix (the R11-2 test burned
the whole budget in turn 1, tripping the R10-4 pre-drain gate's GOAL-to-TIMEOUT
reclassification; redesigned so turn 1 ends GOAL and turn 2 hits the cumulative
cap), then clean passes over the production and test diffs.

* fix(subagent): close the round-12 review Criticals on the external executor

Six Criticals from the round-12 review — three on the executor, one a
regression the R11-5 fix itself introduced, and two on the definition loader's
refusal bookkeeping. Each is mutation-verified (reverting the fix turns its
test red).

R12-1 — execute() re-rendered and re-sent the entire system-prompt bundle on
every continuation turn, although the live ACP session has held it since turn 1
(NewSessionRequest has no system-prompt channel, so the prompt content block is
the bundle's only channel and the FIRST turn must still send it). A resident
external agent re-invoked per incoming message re-sent the definition's
systemPrompt + the appended rules + the whole memory hierarchy (~16.5 KB) every
turn, billed by the peer and pushing its context toward compaction, while the
transcript recorded only the task. The bundle is now gated on the first turn:
`const continuation = this.started` is hoisted above the render and
continuations send only the task.

R12-2 — the turn-entry `emitInputs` recorded the caller's messages as delivered
BEFORE the round-top wall-time guard, so an over-budget continuation wrote
EXTERNAL_MESSAGE user records for a prompt that was never dispatched (the guard
breaks TIMEOUT first). The entry emit moved to the loop's commit point — after
the budget guard, before the prompt — keyed on the ENTRY round (`entryRound`,
not `this.round === 1`, which is broken under resetStats:false because the round
counter is preserved across continuations). Emitting before the dispatch keeps
user-before-assistant order in the JSONL transcript.

R11-5 (fix-induced) — the R11-5 confirmation carried the peer's arguments but
rendered them as MARKDOWN (the `confirmationDetails` omitted
`renderPromptAsPlainText`), so a glob `**` was eaten and a `[label](url)` could
mis-render, misrepresenting the action being approved. The info variant now sets
`renderPromptAsPlainText: true` (the bound and control-strip stay — the flag is
not a substitute for them). Consolidating the local control-char stripper into
the centralized `stripAnsiAndControl` was considered but requires a new
cross-package core export; the flag is what neutralizes the rendering, and the
local stripper already kills the ESC byte so no escape sequence survives.

R12-3 — `resolveResumeTarget` reaches the `loadSubagent` that R10-2/R11 made
throwing, unguarded, so background-agent discovery swallowed the throw into a
debug-only per-sidecar warning and the recovered row VANISHED from /tasks
instead of listing with a `resumeBlockedReason`. The `loadSubagent` call is now
wrapped and the throw converted into the existing `unavailableReason` shape
(both call sites already fold it into `resumeBlockedReason`). The
executor-provenance early return stays ahead of the call, so the four
cold-external-resume provenance legs still refuse before any load.

R12-4 — `executorRefusals` was only rewritten when a level scan reached the end
of its `try`, so a scan that could not read the directory left the previous
scan's refusals in place and `loadSubagent` kept throwing for a file that no
longer exists. Both non-scan paths now reset the level's map: the `readdir`
catch and the `project == home` early return.

R12-5 — the indentation-tolerant raw-text probe made `claimsExecutor` true for
frontmatter that only MENTIONS `executor:` as prose inside a `description: |`/`>`
block scalar, so `astLostExecutor` hard-refused an in-process definition that
carried an unrelated tolerated YAML quirk (duplicate key, tab indent), and keyed
the refusal by its name — blocking every lower-precedence definition and builtin
of that name. The probe match is now excluded when its offset lies inside a
block scalar (walked via the real AST's BLOCK_LITERAL/BLOCK_FOLDED scalar
ranges, fail-closed if the walk throws). A genuine top-level — even
TAB-misindented or nested — `executor:` key is not inside a block scalar, so the
R10-1/R9-2/R7-1 refusals all still hold.

Verification: core and cli tsc 0 errors; ESLint and Prettier clean; cli executor
suite 61/61; core subagent-manager 202/202 and background-agent-resume 56/56;
core subagent/agent/runtime regression net 2517 passed (6 skipped). Mutation
proofs: re-sending the bundle on continuation fails R12-1 (turn 2 carries 2
blocks); moving the entry emit back above the guard fails R12-2 (`delivered`
becomes `['second']`); dropping `renderPromptAsPlainText` fails the R11-5 case;
rethrowing in `resolveResumeTarget` fails R12-3 (the row vanishes); deleting the
catch reset fails R12-4 (stale refusal persists); dropping the block-scalar
exclusion fails both R12-5 variants (they reject). Reverse-audit converged to
clean passes over the production and test diffs.

* fix(subagent): resolve the two carried Criticals via maintainer decision

Closes the two Criticals standing since round 3, per the maintainer's decision
on the round-12 review: both are resolved by an explicit, surfaced limitation
rather than an untestable/billing-risky implementation. Each is
mutation-verified (reverting the fix turns its test red).

R3-1/R3-22 (Windows spawn) — decision: POSIX-only for this release. On Windows
an npm-installed adapter resolves to a `.cmd` launcher that libuv's PATH search
never finds (bare name + `.exe` only) and Node >= 18.20.2 refuses to spawn
without a shell; the process-tree reaping is likewise POSIX-specific. The
executor previously failed at dispatch with a misleading `spawn <cmd> ENOENT`
(reporting an installed adapter as missing). A new
`assertExternalAgentSpawnPlatformSupported` guard now fails closed at the top of
`create()` with a clear, actionable "POSIX-only in this release" error, before
any spawn. A cross-spawn-style PATHEXT resolution + quoted `cmd.exe` arm is a
tracked follow-up (it changes the security-critical detached-spawn path and is
untestable without a Windows lane).

R3-6/R3-51 (mid-turn waiter) — decision: explicitly decline + surface the
limitation. ACP v1 has no mid-turn injection primitive (no `session/steer`), so
the executor deliberately does NOT implement `setExternalMessageWaiter` /
`setExternalMessageWaitPredicate` (documented on the class); input arriving
mid-prompt is delivered at the next turn boundary via the provider. To stop the
background loop presenting a queued steer as if delivered mid-turn, the
delegation result for an external executor now appends
`EXTERNAL_MID_TURN_INPUT_NOTICE` (alongside the existing usage notice) stating
queued input is delivered between turns, not mid-turn. True mid-turn steering
(cancel + re-prompt) re-bills the in-flight turn — a protocol/billing decision,
tracked as a follow-up.

Verification: cli + core tsc 0 errors; ESLint + Prettier clean; cli executor
suite 63/63 (incl. a win32-stubbed `create()` rejection that never reaches
spawn); core agent suite 288/288; core subagent/agent/runtime regression net
2518 passed (6 skipped). Mutation proofs: removing the win32 throw fails both
R3-1 tests; dropping the mid-turn notice fails the R3-6 result assertion.
Reverse-audit converged to clean passes over the production and test diffs.

---------

Co-authored-by: probe <probe@local>
2026-09-10 03:13:05 +00:00

196 lines
9.3 KiB
JSON

{
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"allowJs": true,
// Nothing references this project and it emits nothing, but the root
// config turns `composite` on for the packages that do. Composite demands
// that every file in the program appear in `include`, and these tests
// import package sources across the repo by relative path, so inheriting
// it produced 300+ TS6307 "not listed within the file list" errors.
"composite": false,
// The root turns `noPropertyAccessFromIndexSignature` on, which forced
// bracket-access rewrites in production SDK sources just to satisfy this
// test program. Relax it here so
// packages keep their own compiler regime and the tests keep dot access.
"noPropertyAccessFromIndexSignature": false,
// Matches packages/cli. The suite drives browser-side code in
// `terminal-capture/` and pulls SDK sources that reference `WebSocket` /
// `HeadersInit`, none of which exist in the root's ES2023-only lib.
"lib": ["DOM", "DOM.Iterable", "ES2023"],
"baseUrl": ".",
// Resolve workspace packages from source so `tsc -p` here does not depend
// on whether someone had built them recently — a missing dist used to fail
// resolution outright and a stale one silently typechecked against old
// declarations. nodenext does no extension or index probing on
// substituted paths, but it does substitute `.js` for `.ts`, so the
// `@qwen-code/qwen-code-core/*` wildcard below covers every specifier
// whose source file sits at the matching path under `packages/core/src/`.
// Explicit entries remain necessary only where the specifier does not name
// its file — `noFollowOpen` lives at `utils/no-follow-open.ts`, and the
// wildcard would map it to a file that does not exist. Do not delete those
// entries: since core's exports map gained a `./*` catch-all they would
// still resolve, silently, against dist declarations rather than failing.
// Keep these in sync with the packages' exports maps. The runtime vitest
// aliases
// (`integration-tests/vitest.config.ts`) still point at the built SDK
// bundle to exercise the published-bundle shape; these entries only affect
// type resolution.
//
// Keep notes like this OUT of `paths` itself: every value there must be an
// array, so a `"//"` string key makes tsc abort with TS5063 before it type
// checks a single file — which is how this project silently went unchecked.
"paths": {
// These tests import package sources by relative path
// (`../../packages/cli/src/...`), so those files get checked here too
// and must resolve their own imports.
"@qwen-code/qwen-code-core": ["../packages/core/src/index.ts"],
"@qwen-code/qwen-code-core/subSessionConstants": [
"../packages/core/src/tools/sub-session-constants.ts"
],
"@qwen-code/qwen-code-core/transcriptRecords": [
"../packages/core/src/utils/transcript-records.ts"
],
"@qwen-code/qwen-code-core/noFollowOpen": [
"../packages/core/src/utils/no-follow-open.ts"
],
"@qwen-code/qwen-code-core/toolWriteOrigin": [
"../packages/core/src/services/tool-write-origin.ts"
],
"@qwen-code/qwen-code-core/envVarResolver": [
"../packages/core/src/utils/envVarResolver.ts"
],
"@qwen-code/qwen-code-core/conversationsRuntimeMarker": [
"../packages/core/src/utils/conversations-runtime-marker.ts"
],
"@qwen-code/qwen-code-core/subagentRuntime": [
"../packages/core/src/subagent-runtime.ts"
],
"@qwen-code/qwen-code-core/storage": [
"../packages/core/src/config/storage.ts"
],
"@qwen-code/qwen-code-core/atomicFileWrite": [
"../packages/core/src/utils/atomicFileWrite.ts"
],
"@qwen-code/qwen-code-core/debugLogger": [
"../packages/core/src/utils/debugLogger.ts"
],
"@qwen-code/qwen-code-core/*": ["../packages/core/src/*"],
"@qwen-code/qwen-code-core/goalWire": [
"../packages/core/src/goals/goal-wire.ts"
],
"@qwen-code/qwen-code-core/memoryScopes": [
"../packages/core/src/memory/scopes.ts"
],
"@qwen-code/qwen-code-core/userPromptSubmitContext": [
"../packages/core/src/hooks/user-prompt-submit-context.ts"
],
"@qwen-code/sdk": ["../packages/sdk-typescript/src/index.ts"],
"@qwen-code/sdk/daemon": [
"../packages/sdk-typescript/src/daemon/index.ts"
],
"@qwen-code/sdk/daemon/transcript": [
"../packages/sdk-typescript/src/daemon/transcript.ts"
],
"@qwen-code/sdk/daemon/transports": [
"../packages/sdk-typescript/src/daemon/transports.ts"
],
"@qwen-code/sdk/daemon/types": [
"../packages/sdk-typescript/src/daemon/types.ts"
],
"@qwen-code/sdk/daemon/ui/transcript": [
"../packages/sdk-typescript/src/daemon/ui/transcript.ts"
],
"@qwen-code/acp-bridge": ["../packages/acp-bridge/src/index.ts"],
"@qwen-code/acp-bridge/bridge": ["../packages/acp-bridge/src/bridge.ts"],
"@qwen-code/acp-bridge/bridgeClient": [
"../packages/acp-bridge/src/bridgeClient.ts"
],
"@qwen-code/acp-bridge/bridgeErrors": [
"../packages/acp-bridge/src/bridgeErrors.ts"
],
"@qwen-code/acp-bridge/bridgeFileSystem": [
"../packages/acp-bridge/src/bridgeFileSystem.ts"
],
"@qwen-code/acp-bridge/bridgeOptions": [
"../packages/acp-bridge/src/bridgeOptions.ts"
],
"@qwen-code/acp-bridge/bridgeTypes": [
"../packages/acp-bridge/src/bridgeTypes.ts"
],
"@qwen-code/acp-bridge/channelControlTimeouts": [
"../packages/acp-bridge/src/channel-control-timeouts.ts"
],
"@qwen-code/acp-bridge/childHeapPolicy": [
"../packages/acp-bridge/src/child-heap-policy.ts"
],
"@qwen-code/acp-bridge/daemonEventTypes": [
"../packages/acp-bridge/src/daemonEventTypes.ts"
],
"@qwen-code/acp-bridge/daemonMemoryBudget": [
"../packages/acp-bridge/src/daemon-memory-budget.ts"
],
"@qwen-code/acp-bridge/eventBus": [
"../packages/acp-bridge/src/eventBus.ts"
],
"@qwen-code/acp-bridge/externalToolGuard": [
"../packages/acp-bridge/src/externalToolGuard.ts"
],
"@qwen-code/acp-bridge/logRedaction": [
"../packages/acp-bridge/src/logRedaction.ts"
],
"@qwen-code/acp-bridge/mcpTimeouts": [
"../packages/acp-bridge/src/mcpTimeouts.ts"
],
"@qwen-code/acp-bridge/sessionArtifacts": [
"../packages/acp-bridge/src/sessionArtifacts.ts"
],
"@qwen-code/acp-bridge/spawnChannel": [
"../packages/acp-bridge/src/spawnChannel.ts"
],
"@qwen-code/acp-bridge/status": ["../packages/acp-bridge/src/status.ts"],
"@qwen-code/acp-bridge/transcriptReplay": [
"../packages/acp-bridge/src/transcript-replay.ts"
],
"@qwen-code/acp-bridge/workspacePaths": [
"../packages/acp-bridge/src/workspacePaths.ts"
],
// qwen-serve-web-shell-live-journal-recovery.test.ts imports Web Shell's
// daemon bindings; map to source so typecheck does not depend on dist.
"@qwen-code/web-shell/daemon-react-sdk": [
"../packages/web-shell/client/daemon-react-sdk.ts"
],
// channel-plugin.test.ts and the plugin-example sources it imports
// both import `@qwen-code/channel-base`. Map it to source so the
// typecheck does not depend on channel-base's dist and both import
// sites share one declaration — mixing src and dist declarations
// produces duplicate-private-class errors.
"@qwen-code/channel-base": ["../packages/channels/base/src/index.ts"],
// cli's channel-registry.ts imports the nine builtin channel
// adapters and html.ts imports web-templates; without entries they
// resolve through their exports maps to dist, leaving the typecheck
// dependent on those packages being built.
"@qwen-code/channel-telegram": [
"../packages/channels/telegram/src/index.ts"
],
"@qwen-code/channel-weixin": ["../packages/channels/weixin/src/index.ts"],
"@qwen-code/channel-dingtalk": [
"../packages/channels/dingtalk/src/index.ts"
],
"@qwen-code/channel-dws": ["../packages/channels/dws/src/index.ts"],
"@qwen-code/channel-wecom": ["../packages/channels/wecom/src/index.ts"],
"@qwen-code/channel-feishu": ["../packages/channels/feishu/src/index.ts"],
"@qwen-code/channel-qqbot": ["../packages/channels/qqbot/src/index.ts"],
"@qwen-code/channel-github": ["../packages/channels/github/src/index.ts"],
"@qwen-code/channel-gitlab": ["../packages/channels/gitlab/src/index.ts"],
"@qwen-code/web-templates": ["../packages/web-templates/src/index.ts"],
// node-pty declares `types` at the top level but its `exports` map is a
// bare string with no `types` condition, so nodenext resolution never
// reaches the declarations and every pty handle degrades to `any` —
// which is what silently untyped the `data` / `exitCode` callbacks in
// test-helper.ts. Point at the shipped .d.ts directly.
"@lydell/node-pty": ["../node_modules/@lydell/node-pty/node-pty.d.ts"]
}
},
"include": ["**/*.ts", "**/*.tsx"]
}