Merge branch 'main' into chore/11336-deferred-playwright-doc-followups

This commit is contained in:
易良 authored and GitHub committed 2026-09-09 19:14:02 +08:00
commit 0ad5e897c1
358 files changed
+34067 -4106

No files matched your search

@@ -124,6 +124,15 @@ describe('ECS runner qwen update workflow', () => {
);
});
it('pins the 90-minute resolve budget', () => {
const resolveJob = workflow.slice(
workflow.indexOf(' resolve:'),
workflow.indexOf(' update:'),
);
assert.ok(resolveJob.includes('timeout-minutes: 100'));
assert.ok(resolveJob.includes("RESOLVE_TIMEOUT_SECONDS: '5400'"));
});
it('runs only when this workflow changes on main', () => {
assert.ok(
workflow.includes(
+21 -15
View File
@@ -73,14 +73,28 @@ jobs:
mkdir -p dist/standalone
gh release download "${RELEASE_TAG}" --dir dist/standalone --pattern '*.tar.gz' --pattern '*.zip' --pattern 'SHA256SUMS'
- name: 'Detect OpenTUI Preview Assets'
id: 'flavor'
run: |-
set -euo pipefail
# These steps come from the default branch while the checkout and the
# downloaded assets come from the tag being synced, which on a
# re-dispatch can predate the preview flavor entirely. A repository
# variable describes the default branch, not that tag, so derive the
# flavor from the archives this release actually shipped.
shopt -s nullglob
preview_archives=(dist/standalone/*-opentui-preview.*)
preview_args=''
if [[ ${#preview_archives[@]} -gt 0 ]]; then
preview_args='--include-opentui-preview'
fi
echo "preview_args=${preview_args}" >> "${GITHUB_OUTPUT}"
- name: 'Verify Downloaded Release Assets'
env:
OPENTUI_PREVIEW_RELEASE_ENABLED: "${{ vars.OPENTUI_PREVIEW_RELEASE_ENABLED == 'true' }}"
PREVIEW_ARGS: '${{ steps.flavor.outputs.preview_args }}'
run: |-
PREVIEW_ARGS=""
if [[ "${OPENTUI_PREVIEW_RELEASE_ENABLED}" == "true" ]]; then
PREVIEW_ARGS="--include-opentui-preview"
fi
npm run verify:installation-release -- --dir dist/standalone ${PREVIEW_ARGS}
- name: 'Package Hosted Installation Assets'
@@ -141,14 +155,10 @@ jobs:
env:
ALIYUN_OSS_BUCKET: "${{ vars.ALIYUN_OSS_BUCKET || 'qwen-code-assets' }}"
RELEASE_TAG: '${{ env.RELEASE_TAG }}'
OPENTUI_PREVIEW_RELEASE_ENABLED: "${{ vars.OPENTUI_PREVIEW_RELEASE_ENABLED == 'true' }}"
PREVIEW_ARGS: '${{ steps.flavor.outputs.preview_args }}'
run: |-
set -euo pipefail
PREVIEW_ARGS=""
if [[ "${OPENTUI_PREVIEW_RELEASE_ENABLED}" == "true" ]]; then
PREVIEW_ARGS="--include-opentui-preview"
fi
mapfile -t release_assets < <(node scripts/verify-installation-release.js --dir dist/standalone --list-release-asset-paths ${PREVIEW_ARGS})
node scripts/upload-aliyun-oss-assets.js \
--bucket "${ALIYUN_OSS_BUCKET}" \
@@ -160,14 +170,10 @@ jobs:
env:
ALIYUN_OSS_PUBLIC_BASE_URL: "${{ vars.ALIYUN_OSS_PUBLIC_BASE_URL || 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com' }}"
RELEASE_TAG: '${{ env.RELEASE_TAG }}'
OPENTUI_PREVIEW_RELEASE_ENABLED: "${{ vars.OPENTUI_PREVIEW_RELEASE_ENABLED == 'true' }}"
PREVIEW_ARGS: '${{ steps.flavor.outputs.preview_args }}'
run: |-
set -euo pipefail
PREVIEW_ARGS=""
if [[ "${OPENTUI_PREVIEW_RELEASE_ENABLED}" == "true" ]]; then
PREVIEW_ARGS="--include-opentui-preview"
fi
npm run verify:installation-release -- --base-url "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/${RELEASE_TAG}" ${PREVIEW_ARGS}
- name: 'Sync Hosted Installation Assets to Aliyun OSS'
+6 -7
View File
@@ -25,7 +25,7 @@ jobs:
name: 'Resolve version'
if: "${{ github.repository == 'QwenLM/qwen-code' }}"
runs-on: 'ubuntu-latest'
timeout-minutes: 30
timeout-minutes: 100
outputs:
version: '${{ steps.version.outputs.version }}'
steps:
@@ -36,12 +36,11 @@ jobs:
# npm publishes asynchronously: `npm publish --provenance` returns
# "Your package is being processed and may take a few minutes to
# become available", and release.yml dispatches this workflow as soon
# as it returns. For v0.22.3 the gap was ~16 minutes (published
# 17:14Z, resolvable 17:30Z), so the single un-retried `npm view` this
# step used to run 404'd on 3 of the 4 pools and left the fleet split
# across two CLI versions until a maintainer noticed. Wait the
# registry out rather than losing the race.
RESOLVE_TIMEOUT_SECONDS: '1500'
# as it returns. v0.23.0 took 55m22s to become resolvable, so the
# previous 25-minute budget expired while the package was still in
# flight. Wait up to 90 minutes and treat anything longer as an
# anomalous publish.
RESOLVE_TIMEOUT_SECONDS: '5400'
RESOLVE_INTERVAL_SECONDS: '30'
run: |-
set -euo pipefail
@@ -6,6 +6,10 @@ status: 'implemented'
# Unified Reasoning Effort (/effort)
> **GPT update:** [GPT-5 and GPT-6 reasoning effort](gpt-5-reasoning-effort.md)
> supersedes this document's GPT wire mapping and OpenAI tier ceiling. GPT Chat
> Completions uses flat `reasoning_effort`, with model-specific tier support.
> **Implementation status.** Landed: the 5-tier ladder + `core/reasoning-effort.ts`
> (rank clamp/normalize), the global `model.reasoningEffort` setting + runtime
> `Config.setReasoningEffort`/`getReasoningEffort` (re-applied across model
@@ -0,0 +1,110 @@
# DingTalk Tool Permission Cards
## Context
The DingTalk channel already presents running status and `ask_user_question` interactions with native cards. Ordinary non-YOLO tool permission requests still expose `/approve`, `/approve-always`, and `/deny` commands as plain text. This change extends the existing Channel presentation boundary without changing ACP, daemon routing, permission semantics, or other channel adapters.
## Goals
- Present an attended DingTalk tool permission request as a native interactive card.
- Offer allow once, deny, and the daemon-provided persistent grant when available.
- Bind callbacks to the original request, run, session, chat or thread, and prompt owner.
- Resolve the original permission at most once and terminalize stale cards.
- Preserve the existing text request when interactive cards are disabled or delivery fails.
## Non-goals
- No permission-policy, approval-mode, ACP, or session-language API changes.
- No new DingTalk card template. The existing question form template is reused with one required, single-choice permission field.
- No cross-client or group-wide voting. Only the user who started the attended Channel run may operate the card.
- No native permission-card implementation in CLI, Web, IDE, or other IM adapters.
## Channel presentation contract
`ChannelBase` exposes a second optional structured presentation hook for ordinary permissions. The context contains only adapter-neutral data: request and run identity, prompt owner, resolved Channel target, a sanitized tool title, the decisions actually offered by the permission request, a settlement subscription, and a one-shot response closure.
```ts
export type ChannelPermissionDecision = 'allow_once' | 'allow_always' | 'deny';
export interface ChannelPermissionRequestContext {
requestId: string;
sessionId: string;
runId: string;
owner: ChannelPromptOwner;
target: SessionTarget;
precedingSegmentId?: string;
title: string;
decisions: Array<{
kind: ChannelPermissionDecision;
label: string;
}>;
onSettled(listener: (reason: UserInputSettlementReason) => void): () => void;
respond(decision: ChannelPermissionDecision): Promise<boolean>;
}
```
The hook is eligible only for the current attended, non-loop prompt with an owner. `ChannelBase` derives decisions from the original permission options and never lets the adapter invent an option ID. Deny remains available through the existing reject-or-cancel mapping. The existing question presenter runs first for `ask_user_question`; ordinary permission presentation runs only when that path is unsupported.
The same pending-permission response promise serializes card and text-command responses. Text commands remain unchanged when no structured presenter is active. For a native permission card, commands from the owning sender may still race safely with the card: the first accepted response wins and card settlement observes the shared pending record.
## DingTalk controller
`PermissionCardController` owns DingTalk-only state keyed by request ID and `outTrackId`. It reuses the existing question template with one `permission_decision` checkbox-group field and no free-form option. The rendered choices are literals selected from the context decisions, so `allow_always` is absent when the daemon did not advertise it.
Presentation follows four states: `reserved`, `pending`, `claimed`, and `terminal`. A record subscribes to Channel settlement before delivery so an outside resolution during the network request cannot reactivate it. Delivery failure removes the local record and returns `unsupported`; `ChannelBase` then sends the existing text request. A successful delivery starts the configured timeout.
A callback is accepted only when all of the following hold:
- `outTrackId` maps to a live pending record.
- The callback actor equals the prompt owner.
- The callback contains the expected submit or cancel action and business payload.
- For submit actions, the form contains exactly one advertised decision and no unknown fields.
The accepted callback claims the record synchronously before asynchronous permission settlement. Duplicate, malformed, stale, and terminal callbacks are acknowledged but ignored. A foreign actor receives generic owner-only feedback in the Channel locale and cannot mutate the permission or card.
## Terminal states
The controller projects these terminal states through the existing card-instance update API:
| Cause | Card state | Original permission |
| --------------------------- | ----------- | -------------------------------------------- |
| Accepted allow decision | `approved` | Selected original allow option |
| Accepted deny decision | `denied` | Selected original reject option or cancelled |
| Card cancel action | `cancelled` | Denied through the one-shot responder |
| Timeout | `expired` | Denied through the one-shot responder |
| Run or session cancellation | `cancelled` | Settled by existing Channel cleanup |
| Resolution outside the card | `expired` | Already settled elsewhere |
| Response rejected or throws | `expired` | No retry; duplicate callbacks remain blocked |
Card updates are best-effort projections. A failed update does not reopen or retry an already settled permission.
## Configuration and compatibility
`interactiveCards.permissionCard` mirrors question-card configuration:
```json
{
"interactiveCards": {
"permissionCard": {
"enabled": true,
"timeoutMs": 270000
}
}
}
```
When `interactiveCards` is configured, permission cards default to enabled with the same bounded positive timeout behavior as question cards. The root `enabled` flag and the nested permission flag can disable them independently. Existing configuration shapes remain valid, and other channels do not gain a native permission-card implementation.
## Language behavior
Channel startup reads the existing default `general.language` setting once. Underscores are normalized to hyphens, then Chinese values (`zh`, `zh-*`, `Chinese`, or `中文`) select Chinese permission copy; missing, automatic, and all other values use English. The daemon worker follows the same rule. No IM request calls `/daemon/session/:sessionId/language`, and language is not resolved per session.
The selected locale is passed through the existing Channel creation options. `ChannelBase` localizes only its stock permission decision labels and command fallback while preserving tool titles, custom option labels, option IDs, and slash commands. DingTalk applies the same locale to the permission-card title, field label, submit button, terminal descriptions, and owner-only interaction feedback. Protocol card states such as `pending`, `approved`, and `expired` remain unchanged.
## Validation
- ChannelBase tests cover eligibility, exact decision derivation, one-shot response arbitration, outside settlement, and text fallback.
- Controller tests cover rendering, optional persistent grant, owner enforcement, all decisions, delivery failure, timeout, duplicate callbacks, malformed payloads, and outside settlement.
- DingTalk adapter, presenter, callback-routing, configuration, and management-schema tests cover wiring.
- Package tests, repository build, typecheck, and diff self-audit run before submission.
- A real DingTalk run is reported separately and only when valid credentials and a published template are available.
@@ -0,0 +1,184 @@
# Peer messaging: admission control, and one receipt for a burst
Status: implemented alongside this document. Adds a fourth outcome
(`dropped`) below the inbound gate's accept/hold/refuse, plus the
reporting and sender-side pacing that make it usable.
## Problem
The gate decided _whether_ a message may act. Nothing decided how fast
messages may arrive.
### Everything downstream costs something per message
An accepted message goes to the input queue; a held one goes to the hold
buffer; each one draws a receipt. All three have ceilings, and all three
degrade badly when the ceiling is reached at socket speed rather than at
conversation speed. The hold buffer evicts its oldest entry per arrival,
so a flood walks a user's real backlog out of the buffer one message at a
time, receipting each eviction `expired`. The input queue simply fills.
### A flood starves exactly the receipts that matter
Every terminal outcome sends a receipt, and receipts share one outbound
ceiling (`MAX_CONCURRENT_SENDS`) with everything else the session sends.
Sends over the ceiling are dropped silently. So under a flood, the
receipts most likely to be lost are the ones belonging to the legitimate
messages arriving alongside it — the session gets quieter about the
things its user cares about, precisely when something is going wrong.
The same asymmetry shows in the transcript: a receipt per dropped message
means seventy lines for seventy dropped messages, which is the flood
reproduced inside the user's own scrollback.
### Nothing stopped a loop, and nothing stopped a repeat
Two sessions in the same review class auto-deliver to each other. A model
that answers a peer message with a peer message therefore closes a loop
that nothing bounded. Separately, a model in a retry loop re-sends the
same sentence with a fresh message id each time, which the gate's id
guard cannot see: it compares ids, and every one of them is new.
### A full queue was reported as an expiry
When the input queue could not take an accepted message, the gate
receipted `expired` — a word that means "a decision ran out". No decision
was pending, and the sender could not tell that answer from the one it
gets when the receiving session exits with the message unread.
### The sender learned all this a round trip too late
`sendToPeer` would write anything, any number of times. A model sending
its thirtieth message has already composed its thirty-first by the time
the answer to the first arrives.
## Design
**Meter arrivals before policy.** A token bucket per sender — 30 at once,
then one every two seconds — plus a global bucket of 32, then one a
second. A sender is identified by the reply address it puts on the frame,
which is self-asserted; the global bucket is what makes rotating it
pointless. The global figure is half the session's outbound send ceiling
of 64, because every admitted message draws its own receipt against that
ceiling: sized above it, one burst could occupy every slot, and the sends
it displaced are dropped silently — the starvation this metering exists
to prevent rather than to cause. It sits below both 50-message buffers
downstream, so the meter is what a rotating sender reaches first. Neither is a security boundary. A hostile same-uid process has
better options than flooding a socket; these bound the damage an ordinary
bug does.
**Judge repeats on the body.** The id guard cannot see a retry loop that
mints fresh ids, so the same body from one sender inside 30 seconds is a
drop. Only from another session: a process this session started, and a
controller the user minted a token for, are exempt. A hook that reports
two builds in thirty seconds is reporting two facts, and a user who says
"continue" twice to a voice front-end means it twice. Neither is the
model-driven repetition the check exists to stop, and both are still
metered by the buckets, which apply to every sender alike.
**Charge nothing for a message that was turned away.** The order inside
the meter is global bucket, then the repeat check, then the sender's own
bucket, and a token is taken only when all three pass. A repeat must not
also cost the sender the allowance it needs to say something new.
**Meter before the id lookups, not after.** The settled-id and held-id
lookups each answer with a receipt, so a peer looping on one id would
draw one outbound connection per message through them. The meter sits
above both.
**A drop leaves no trace.** No hold entry, no tombstone, no delivery. A
tombstone exists so a decided id cannot be decided twice; nothing was
decided here, and a sender that waits out its burst and retries should
find the gate it would have found if it had waited in the first place.
**The repeat record is rolled back wherever a message is settled unseen.**
A body is recorded when it is admitted, but admission is not arrival: the
gate can still refuse it, park it into a full buffer, or hand it to a
session that is shutting down. Left in place, the record turns the
sender's honest retry into a `duplicate` — a verdict whose whole premise
is that the content is already at the far side. The rollback restores
what the admission displaced rather than clearing the slot, so the body
admitted _before_ the failed one keeps its own protection, and it leaves
the token spent, which is the only bound on how often a peer can make the
receiver attempt a delivery that cannot land.
**One receipt for a burst.** The first drop from a sender answers
immediately — while the sender can still stop — and the rest are folded
into one receipt every five seconds that names the ids it stands for.
A `dropped` receipt carries `dropReason` (`rate-limited`, `duplicate`,
`queue-full`) and `droppedMsgIds`, so the sender moves every message it
lost to a terminal state from one frame. Receipts are capped globally at
40 a minute and are best-effort throughout: past the cap they are not
sent, because more of them would not help.
**One transcript line per sender per minute**, carrying the count of what
it stands for. A message about a flood must not scale with the flood.
**A full buffer is a drop, not an expiry.** Both of them: the input queue
and, now, the hold buffer. The hold buffer used to evict its oldest entry
to make room, so an arrival could destroy a message the user had not
reviewed yet and its uninvolved sender was told `expired` — a flood
walked the backlog out one message at a time, which is the first thing
the Problem section above names. It now turns the newcomer away with
`queue-full` instead, the same shape the accept path already had: the
cost falls on the sender that could not fit, it is told the truth, and it
can retry. With no eviction left, `expired` means only that a hold ran
out, that the session exited with the message unread, or that it arrived
during shutdown.
**Mirror the receiver's bucket on the sending side.** Each session tracks
what it has sent to each address — and the last body it sent there — with
the same arithmetic the receiver uses, and refuses a send the receiver
would drop, whether for rate or for repetition — so the model is told
to batch _before_ the message is written, and the receiver never spends a
connection on one it was going to turn away. The mirror is keyed by
socket path, which is what the receiver meters by and what changes when a
session restarts. It can only be approximate, since other sessions share
that bucket, so it is never stricter than the real limit and a
`rate-limited` receipt empties it: the receiver is the authority on its
own level. A frame that provably never left refunds its token.
## Trade-offs
**No hop chain.** Cutting a relay loop properly means carrying the path a
message travelled on the frame and knowing, at send time, which inbound
message the current turn is answering. That plumbing does not exist yet.
The buckets bound the loop to one message every two seconds in the
meantime, and an added field would not break any reader, so this stays a
separate change.
**No settings.** A session's protection against a peer that outruns it is
not something the peer's user, or a cloned repository, should be able to
widen. The limits are constants an order of magnitude above what a person
or a well-behaved model reaches.
**The model is not told about drops on the receiving side, and is told
about its own only through the send that fails.** Receipts go to the
user's transcript, as they have since receipts existed; the predictive
refusal is what reaches the model, and it arrives with the advice that
matters — batch, or wait.
**Evicting a sender's meter can forget its last body.** The sender table
is capped at 256 and prefers to evict a meter whose bucket has refilled,
which at worst admits one repeat. A bounded map is worth that; a meter
that is currently holding a flood back is not evicted first.
**Bodies are remembered as digests.** A frame can carry a megabyte, and
keeping the last one per sender would be a quarter of a gigabyte held to
answer "same or not". A digest collision would drop one message as a
repeat, and its sender is told.
## Files
- `packages/core/src/ipc/peer-admission.ts` — the meter and its limits.
- `packages/core/src/ipc/peer-drop-reports.ts` — folding receipts and
throttling notices.
- `packages/core/src/ipc/inbound-gate.ts` — the `dropped` outcome, and
the queue-full path.
- `packages/core/src/ipc/peer-frames.ts` — the `dropped` status,
`dropReason` and `droppedMsgIds`.
- `packages/core/src/ipc/peer-send.ts` — the sender-side mirror and the
receipt transitions.
- `packages/cli/src/peerMessaging/peer-messaging.ts` — wiring, and
applying a folded receipt.
- `packages/cli/src/ui/AppContainer.tsx` — the two transcript lines.
@@ -0,0 +1,54 @@
# Kimi, Qwen and DeepSeek reasoning model presets
[English](2026-09-08-reasoning-model-presets.md) | [简体中文](2026-09-08-reasoning-model-presets.zh-CN.md)
## Problem and boundaries
PR #10999 introduced declarative reasoning capabilities but configured tiers only for native DeepSeek V4 Pro. Other models still rely on the legacy table or generic tiers, and native Kimi installs an enable_thinking field outside its protocol. This change expands provider presets and regression coverage through the existing installer, registry, ACP/WebShell, TUI and request pipeline without adding model-name checks.
Qwen 3.8 Flash exposed a legacy Max-only DashScope check that appended enable_thinking even after tiers were declared. The existing tiered-protocol predicate now prioritizes the resolved capability, and UI static-override reporting uses the same predicate. Unconfigured models retain the name fallback. Alibaba Kimi K3 accepts text/images; native Moonshot K3 retains video input.
CLI mock acceptance also found that provider defaults overwrite the global off preference during initial authentication. refreshAuth previously restored only tiers. It now restores reasoning=false in both live and rebuildable configuration so headless startup's two consecutive authentications preserve Off; mandatory-thinking models remain exempt. Authentication and hot-reload rebuilds preserve the session preference following the existing tier policy; model switching still applies the target model's defaults through its existing flow.
## Configuration matrix
| Route / model | Tiers or toggle | Default tier | Disable field |
| ---------------------------------------------------------------- | ---------------------------- | ---------------- | --------------------- |
| Moonshot K3 | low / high / max | max | Cannot disable |
| Moonshot K2.7 Code / Highspeed | Mandatory thinking, no tiers | Provider default | Cannot disable |
| Moonshot K2.6 | Toggle | Provider default | thinking.type |
| Native DeepSeek V4 Pro / Flash | low / high / max | high | thinking.type |
| Alibaba Standard Qwen 3.8 Max / Max-0902 / Flash | low / medium / xhigh | xhigh | reasoning_effort=none |
| Existing Alibaba Qwen 3.5 / 3.6 / 3.7 and Qwen3 Max-0123 hybrids | Toggle | Retain preset On | enable_thinking |
| Alibaba DeepSeek V4 Pro / Flash | high / max | high | enable_thinking |
| Alibaba DeepSeek V4 Pro-0813 / Flash-0731 | low / high / max | high | enable_thinking |
| Alibaba Standard Kimi K3 | low / high / max | max | Cannot disable |
| Alibaba Standard / existing Token Plan Kimi K2.7 Code | Mandatory thinking, no tiers | Provider default | Cannot disable |
| Alibaba Standard / existing plan Kimi K2.6 / K2.5 | Toggle | Retain preset On | enable_thinking |
The existing Token Plan DeepSeek V3.2 entry also gains an enable_thinking toggle capability.
Token Plan adds the listed Qwen 3.8 Flash and DeepSeek V4 Pro-0813 entries, retaining the existing mandatory-thinking restrictions on 3.8 Max / Preview and reflecting them in canDisable. Preview remains as an existing compatibility alias. Coding Plan adds capabilities only to existing hybrids, without adding models. Native DeepSeek stable IDs already point to the newer snapshots; Alibaba snapshot IDs are not assumed to be native API IDs.
## Native tiers and compatibility inputs
Qwen 3.8's native choices are low / medium / xhigh. The official Chat parameter reference also maps high and max to xhigh, so high in the legacy input ladder does not introduce another native tier. Explicit capabilities expose native choices only; unsupported persisted values are omitted and use the provider default (xhigh for Qwen 3.8). Routes without capabilities retain their existing clamp and warning for compatibility; explicit extra_body / samplingParams remain provider overrides. This is intentional configuration precedence, not a claim that the provider rejects high or max. No-argument /effort reports the model/provider default for unsupported saved tiers without modifying settings.
## Implementation and acceptance
Update the moonshot, deepseek, alibaba-standard, alibaba-token-plan and alibaba-coding-plan presets. Native Kimi removes enableThinking because its API defaults thinking on. Mandatory models retain thinkingMandatory and declare canDisable=false for existing generation-config consumers. toggleOnly declares no fabricated tiers. Alibaba Standard DeepSeek V4 Pro removes the redundant enableThinking; high/max send reasoning_effort alone, while Off still sends enable_thinking=false.
Preset installation tests inspect real configuration output. Request tests build configurations from real presets and assert the final payload. ACP tests verify exact choices and defaults. Coverage includes Off, non-thinking side queries, invalid tiers and unknown models. Run build, typecheck, bundle, focused tests and lint, and drive CLI acceptance against a mock API with isolated settings. Live paid provider requests are not part of local validation.
Presets affect new installations or provider reconfiguration. Existing settings are not automatically migrated or modified. Regional availability depends on the provider and account; existing endpoint scope is not expanded. GLM, MiniMax, Step, OpenRouter, ModelScope, Idealab and independent boundaries tracked in #11328 are outside this change.
## Official evidence (checked 2026-09-08)
- [Kimi K3](https://platform.kimi.ai/docs/guide/kimi-k3-quickstart): three tiers, default max, always thinking.
- [Kimi K2.7 Code](https://platform.kimi.ai/docs/guide/kimi-k2-7-code-quickstart): thinking defaults on and cannot be disabled.
- [Kimi K2.6](https://platform.kimi.ai/docs/guide/kimi-k2-6-quickstart): thinking.type toggle.
- [DeepSeek Thinking Mode](https://api-docs.deepseek.com/guides/thinking_mode/) and [model entry point](https://api-docs.deepseek.com/): native stable-ID versions, three tiers and disable shape.
- [Alibaba Chat parameters](https://help.aliyun.com/zh/model-studio/qwen-api-via-openai-chat-completions): family-specific tiers/defaults, enable_thinking and Qwen 3.8 parameter exclusivity.
- [Alibaba DeepSeek](https://help.aliyun.com/zh/model-studio/deepseek-api): stable-versus-snapshot tier differences.
- [Alibaba Kimi](https://help.aliyun.com/zh/model-studio/kimi-api): models and routes; max-only kimi/kimi-k3 is not added.
- [Token Plan model list](https://help.aliyun.com/zh/model-studio/token-plan-personal-overview): added Flash, Pro snapshot and Preview alias.
@@ -0,0 +1,84 @@
# Kimi、Qwen 与 DeepSeek 推理模型配置补全
[English](2026-09-08-reasoning-model-presets.md) | [简体中文](2026-09-08-reasoning-model-presets.zh-CN.md)
## 问题与边界
PR #10999 已提供声明式 reasoning capability,但只有原生 DeepSeek V4 Pro
配置了档位。其他模型仍依赖旧表或通用档位,原生 Kimi 还会安装不属于其
协议的 `enable_thinking`。本次扩展 provider 预设及其回归测试,复用
安装、模型注册、ACP/WebShell、TUI 和请求流水线,不增加模型名判断。
Qwen 3.8 Flash 暴露了 DashScope 的旧 Max-only 判断:声明档位之后仍会追加
enable_thinking。让现有 tiered 协议判断优先读取已解析 capability,同时
让 UI 的高优先级参数提示使用相同判定。未配置模型保留原来的名称回退。
阿里云 Kimi K3 仅开放文本/图片;原生 Moonshot K3 保留视频输入能力。
CLI mock 验收另发现全局关闭偏好在初次认证时被 provider 默认值覆盖;
refreshAuth 原先只恢复档位。现在同时恢复 live 与可重建配置中的
reasoning=false,使 headless 的连续两次认证也保留关闭状态;强制思考
模型仍豁免。认证或热重载引发的重建按已有档位策略保留会话偏好;
模型切换仍通过已有流程应用新模型默认值。
## 配置矩阵
| 路由 / 模型 | 档位或开关 | 默认档位 | 关闭字段 |
| ---------------------------------------------------------------- | -------------------- | ------------- | --------------------- |
| Moonshot K3 | low / high / max | max | 不允许关闭 |
| Moonshot K2.7 Code / Highspeed | 强制思考,无可选档位 | provider 默认 | 不允许关闭 |
| Moonshot K2.6 | 开关 | provider 默认 | thinking.type |
| 原生 DeepSeek V4 Pro / Flash | low / high / max | high | thinking.type |
| Alibaba Standard Qwen 3.8 Max / Max-0902 / Flash | low / medium / xhigh | xhigh | reasoning_effort=none |
| 已有 Alibaba Qwen 3.5 / 3.6 / 3.7 与 Qwen3 Max-0123 混合思考条目 | 开关 | 保留预设开启 | enable_thinking |
| Alibaba DeepSeek V4 Pro / Flash | high / max | high | enable_thinking |
| Alibaba DeepSeek V4 Pro-0813 / Flash-0731 | low / high / max | high | enable_thinking |
| Alibaba Standard Kimi K3 | low / high / max | max | 不允许关闭 |
| Alibaba Standard / 已有 Token Plan Kimi K2.7 Code | 强制思考,无可选档位 | provider 默认 | 不允许关闭 |
| Alibaba Standard / 已有套餐 Kimi K2.6 / K2.5 | 开关 | 保留预设开启 | enable_thinking |
已有 Token Plan DeepSeek V3.2 也补上 enable_thinking 开关能力。
Token Plan 补充已列出的 Qwen 3.8 Flash 和 DeepSeek V4 Pro-0813,保留
3.8 Max / Preview 现有强制思考限制并同步 canDisable。Preview 作为已有
兼容别名保留。Coding Plan 只补已有混合思考条目的能力,不增加模型。
原生 DeepSeek 的稳定 ID 已指向新快照;不把阿里云的快照 ID 猜测为原生 API ID。
## 原生档位与兼容输入
Qwen 3.8 的原生选项为 low / medium / xhigh。官方 Chat 参数文档同时规定
high、max 映射到 xhigh,因此旧兼容表包含 high 并不代表新增了一个原生档位。
显式 capability 只展示原生选项;不在声明中的持久化值被省略,采用 provider
默认值(Qwen 3.8 为 xhigh)。未声明 capability 的路由继续使用原有 clamp
和警告,以保留兼容行为;显式 extra_body / samplingParams 仍作为 provider
参数透传。这是有意的配置优先规则,不代表 provider 会拒绝 high 或 max。
无参数 /effort 对不支持的旧档位报告使用模型/provider 默认值,查询不修改设置。
## 实现与验收
修改 moonshot、deepseek、alibaba-standard、alibaba-token-plan 和
alibaba-coding-plan 预设。原生 Kimi 删除 enableThinking;其 API 默认开启
思考。强制思考模型同时保留 thinkingMandatory 和声明 canDisable=false,
照顾已有 generation config 消费者。toggleOnly 不声明虚构档位。
Alibaba Standard DeepSeek V4 Pro 移除冗余 enableThinking,high/max 只发送
reasoning_effort;关闭仍发送 enable_thinking=false。
预设安装测试覆盖真实配置产物;请求测试从真实预设生成配置并断言最终
payload;ACP 测试验证精确选项和默认值。测试包含关闭、副请求关闭、非法
档位和未知模型。执行 build、typecheck、bundle、聚焦测试与 lint,并使用
隔离配置的 mock API 完成 CLI 验收;真实供应商付费调用不作为本地验证。
这些预设影响新安装或重新配置 provider。已有 settings 不会自动迁移,
不修改用户当前配置。地域可用性取决于账号和供应商,不扩大既有 endpoint
范围。GLM、MiniMax、Step、OpenRouter、ModelScope、Idealab 及 #11328 中
与本次条目无关的边界不在范围内。
## 官方依据(2026-09-08 核对)
- [Kimi K3](https://platform.kimi.ai/docs/guide/kimi-k3-quickstart):三档、默认 max、始终思考。
- [Kimi K2.7 Code](https://platform.kimi.ai/docs/guide/kimi-k2-7-code-quickstart):thinking 默认开启且不允许关闭。
- [Kimi K2.6](https://platform.kimi.ai/docs/guide/kimi-k2-6-quickstart):thinking.type 开关。
- [DeepSeek Thinking Mode](https://api-docs.deepseek.com/guides/thinking_mode/) 与 [模型入口](https://api-docs.deepseek.com/):原生稳定 ID 的版本、三档和关闭形状。
- [Alibaba Chat 参数](https://help.aliyun.com/zh/model-studio/qwen-api-via-openai-chat-completions):各系列档位、默认值、enable_thinking 与 Qwen 3.8 互斥字段。
- [Alibaba DeepSeek](https://help.aliyun.com/zh/model-studio/deepseek-api):稳定版与快照的档位差异。
- [Alibaba Kimi](https://help.aliyun.com/zh/model-studio/kimi-api):模型及路由;不加入仅支持 max 的 kimi/kimi-k3。
- [Token Plan 模型列表](https://help.aliyun.com/zh/model-studio/token-plan-personal-overview):新增 Flash、Pro 快照和 Preview 别名。
+55
View File
@@ -0,0 +1,55 @@
# DAC Chat Plan toggle
## Problem and scope
Web Shell uses one approval-mode value for both planning and execution permission. Plan appears in the permission menu, and confirming a plan may choose a different execution policy. DAC Chat needs a separate Plan toggle immediately after the permission control. The existing single-row responsive toolbar remains: narrow widths collapse labels to icons. No toolbar reorganization or CLI/TUI semantics change is included.
Generating a plan leaves Plan enabled until the user approves it. Approval exits planning and executes under the policy selected when the user confirms. Rejection leaves planning enabled. Changing permission while planning updates only the policy for execution and never auto-confirms the plan. Manual toggle-off exits planning under the selected permission; it does not submit the pending plan approval. Existing running-turn switch timing is preserved; this feature does not add cancellation or model steering.
## Runtime and transport
Retain runtime ApprovalMode.PLAN and its existing tool restrictions. Add an optional DAC-only selected execution policy to Config, populated exclusively by an explicit planMode boolean on the existing session approval-mode control. Legacy requests omit the flag and retain their behavior. Validate the flag and reject PLAN as an execution policy. Core applies the existing derived-config and folder-trust constraints before changing state. Updating execution policy while already planning does not end or restart the planning lifecycle.
For DAC selections, RestorePrevious captures the selected execution policy in the confirmation callback. Legacy confirmations still restore their original snapshot. Execution retains cancellation and approval-mode-revision checks. Policies chosen after confirmation must not silently alter that already-approved decision.
The existing route remains live-session-owner scoped through withOwnerMutableSession. Use its resolved runtime, bridge and Config throughout, with existing unknown/removed/draining failure behavior. No primary-runtime fallback, new workspace route or permission persistence is introduced. Transport the selected policy in approval responses, mode notifications and session-mode metadata; preserve it through bridge reconciliation, snapshots and client hydration. The effective runtime mode remains authoritative for whether the Plan toggle is on.
## Web Shell
Add a Plan Switch after approvalMode in ChatEditor, using the shared Switch primitive. The host must explicitly include `plan` in `composerToolbarActions` or `composerToolbarAdditionalActions`; omitted configuration hides it in main and split panes. The standalone browser host explicitly opts in. Plan never falls back into the permission menu. Wide layouts show the Plan label and switch; narrow layouts replace the label with the Plan icon and retain the switch. Preserve the single row, tooltip, accessible name and checked state. Hide the entire Composer and its status area while a plan awaits approval, matching ordinary permission and AskUserQuestion overlays. Restore them when the overlay resolves. Floating plan cards scroll only their plan content, keeping the heading and approval buttons visible, including text-only plans. Execution permission remains adjustable during planning before the approval overlay appears. Remove Plan only from Web Shell permission menus/cycling. Keep Auto alongside Default, Auto Edit and YOLO. Do not change the shared SDK approval enum or the agent-definition editor.
Main and split panes use the same daemon action contract. Plan confirmations expose one approval action labelled with the chosen execution policy and a rejection action. A permission change during Plan cannot auto-submit pending approval. Briefly disable plan confirmation while a mode update is pending, and prevent new mode updates during plan-approval handoff until the runtime exits Plan or the handoff fails; neither request may overtake the other. Buttons reflect only successful state changes; failures keep the previous state. Session-owner guards prevent late responses updating replacement sessions.
/plan toggles, /plan on enables, /plan off disables through the same action. Preserve /plan <prompt> as enter-and-send and /plan exit as a compatibility alias for off. A new session is created with its execution policy, then Plan is applied before submitting the first prompt; preparation failure prevents that prompt.
## Affected areas
- Core Config and exit-plan confirmation, with legacy and DAC regression coverage.
- Daemon session approval route, ACP control handler and mode metadata/notifications.
- ACP bridge control, cached snapshots and mode-event forwarding.
- TypeScript daemon client/state types and Web Shell session synchronization.
- Web Shell Composer, approval dialog, main/split command and permission wiring, translations and focused component tests.
## Verification
Exercise Plan with every non-Plan permission, permission changes before and while a plan awaits confirmation, approve/reject/manual exit, slash commands, welcome-session preparation, session switching and reconnect hydration. Verify core legacy confirmation snapshots remain unchanged. Validate toolbar order and one-row compact layout. Build, typecheck, bundle, focused tests and two clean full-diff audits are required.
## Open questions
None for the requested behavior. Cross-process restoration beyond existing session-mode persistence and new mid-turn steering guarantees are out of scope.
## Review corrections
Metadata reads that omit modes, fail, or are skipped during event-stream restart preserve the existing mode and execution policy together. An authoritative non-Plan mode clears the policy. Plan approval receives only the daemon-reported execution policy; a Composer display fallback must never label the approval as executing under a permission the daemon did not report.
For explicit DAC `planMode` controls, `persist: true` saves only the execution policy as the workspace default for future sessions. The requesting session publishes its actual Plan state and selected policy. Existing peer sessions receive neither a mode event nor a cache update. Requests without `planMode` retain the previous workspace-default broadcast behavior.
Regression checks cover metadata reuse after prompt restart, missing-mode context responses followed by a real Plan exit, approval with/without reported execution policy in main and split panes, DAC persistence with Plan/non-Plan peers, and the existing legacy persistence tests.
## Confirmation policy precondition
DAC RestorePrevious responses include the execution policy displayed by the approving client. The bridge preserves this permission-response extension and Session forwards it to the tool. Core reads the current DAC policy once, rejects a missing or different expected policy, and otherwise freezes that value until execution. Legacy Plan without a DAC policy keeps its pre-plan snapshot behavior. Policy equality is sufficient: a change away and back to the same policy does not change the permission the user approved. The lifecycle revision remains separate.
A rejected precondition consumes the old permission request and returns a tool error while retaining Plan. The model must request plan approval again; HTTP acknowledgement does not imply that Plan exited. No client-side retry of the consumed approval is attempted.
The Plan Switch retains its accessible name and checked state and directly references a persistent, dynamic execution-policy description. Mode controls becoming busy close the open permission menu. Slash completion includes the existing exit alias. Unused Plan & Review mode labels are removed; agent-editor Plan labels and workflow review translations remain.
@@ -14,15 +14,15 @@
## 2. 实现前核对的事实
| 事实 | 设计影响 |
| ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ |
| V2 搜索输出把 ID 截断到 128 code point、正文截断到 1000,并为总输出预算继续缩短正文。 | 搜索只能提供候选,不能提供完整删除确认;ID 不能继续静默截断后作为删除定位符。 |
| writer 的 `stored` 返回最长 256 字符的完整 ASCII memory ID;`accepted` 可能只有操作 ID。 | 完整写入回执可提供候选 ID;不将 operation ID 自动转换成 memory ID。 |
| 普通 MCP 的审批在实际执行工具之前发生,审批载荷携带当前完整参数。 | delete handler 内部的 GET 无法回填先前审批;完整正文须作为下一次 forget 调用的参数。 |
| Web Shell 已支持通用 MCP 参数的完整字面展示和格式字符转义。 | 复用这条路径,预计不改 UI 和 daemon wire protocol。 |
| 现有 MCP client 没有协商 elicitation,MCP App 结果资源也不是执行前确认。 | 不依赖额外客户端弹窗,不安装 TUI 专属确认 Hook。 |
| Holo 文档列出单条 GET/DELETE 使用同一个带记录 ID 的路径,但未给出完整 GET/DELETE 回执或条件删除语义。 | 路径可作为验收起点;不能推断 scope 字段、404、响应正文或原子条件删除行为。 |
| Mem0 Platform 文档的 GET 返回顶层 scope,未找到为 404;当前 OSS server 的 GET 未找到可返回 HTTP 200 + JSON null,删除成功 message 的标点也不同。 | 明确配置未找到的表示;仅识别有限的删除确认回执,不自动探测或改用批量接口。 |
| 事实 | 设计影响 |
| ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- |
| V2 搜索输出把 ID 截断到 128 code point、正文截断到 1000,并为总输出预算继续缩短正文。 | 搜索只能提供候选,不能提供完整删除确认;ID 不能继续静默截断后作为删除定位符。 |
| writer 的 `stored` 返回最长 256 字符的完整 ASCII memory ID;`accepted` 可能只有操作 ID。 | 完整写入回执可提供候选 ID;不将 operation ID 自动转换成 memory ID。 |
| 普通 MCP 的审批在实际执行工具之前发生,审批载荷携带当前完整参数。 | delete handler 内部的 GET 无法回填先前审批;完整正文须作为下一次 forget 调用的参数。 |
| Web Shell 已支持通用 MCP 参数的完整字面展示和格式字符转义。 | 复用这条路径,预计不改 UI 和 daemon wire protocol。 |
| 现有 MCP client 没有协商 elicitation,MCP App 结果资源也不是执行前确认。 | 不依赖额外客户端弹窗,不安装 TUI 专属确认 Hook。 |
| Holo 文档列出单条 GET/DELETE 使用同一个带记录 ID 的路径,但未给出完整 GET/DELETE 回执或条件删除语义。 | 路径可作为验收起点;不能推断 scope 字段、404、响应正文或原子条件删除行为。 |
| Mem0 Platform 文档的 GET 返回顶层 scope,未找到为 404;当前 OSS server 的 GET 未找到可返回 HTTP 200 + JSON null,删除成功 message 的标点也不同。 | 明确配置未找到的表示;DELETE 按 HTTP 状态和 JSON 解析处理,不自动探测或改用批量接口。 |
Holo 路径依据:[创建和调用长记忆服务](https://www.alibabacloud.com/help/tc/hologres/user-guide/create-and-use-long-memory-service)。Platform 回执依据:[Get Memory](https://docs.mem0.ai/api-reference/memory/get-memory)、[Delete Memory](https://docs.mem0.ai/api-reference/memory/delete-memory)。OSS 依据为固定提交 [dae67f74 的 server](https://github.com/mem0ai/mem0/blob/dae67f74f5cc7bf138c7d7d6f9cec5ce4b4373b3/server/main.py#L443) 和 [memory 实现](https://github.com/mem0ai/mem0/blob/dae67f74f5cc7bf138c7d7d6f9cec5ce4b4373b3/mem0/memory/main.py#L1208),它们不是 Holo 部署版本的实现证明。此次限定检索未找到独立的外部记忆显式删除 issue,不据此断言没有其他跟踪项。
@@ -46,7 +46,7 @@ sequenceDiagram
D->>S: GET 同一精确 ID
D->>D: ID、scope、全文一致才继续
D->>S: 单次 DELETE 同一 ID
S-->>D: 已识别的成功回执
S-->>D: 成功 HTTP 状态与有效 JSON
D->>S: 单次 GET 复核不存在
D-->>A: deleted / not_deleted / unknown
A-->>U: 删除结果
@@ -132,8 +132,8 @@ sequenceDiagram
- GET、DELETE 共享同一个记录地址。pathPrefix 是通过现有静态路径校验、以 `/` 结束的绝对路径;pathSuffix 只能为 `""` 或 `"/"`。把已验证的 ID 作为单个路径段进行编码。必须拒绝 `.`/`..`,避免 URL 规范化把单条 DELETE 变成集合 DELETE。禁止请求模板、动态 origin、任意 headers、GET/DELETE body、filters 和批量路径回退。
- 成功 GET 只接受 HTTP 200 的单个根对象;idField 复用 `id` / `memory_id`,contentField 复用 `memory` / `content` / `text`。scope 只读取服务定义的顶层 user_id、agent_id、app_id,全部已配置字段严格匹配,不从自定义 metadata 猜归属。
- `record.notFound` 只能为 `http-404` 或 `null-200`,分别匹配 HTTP 404、HTTP 200 且完整 JSON 值为 null。只按选定规则解释;空数组、空对象、缺字段和另一种形状不是“不存在”。
- 首版只识别同步 DELETE 的 HTTP 200 对象回执,message 必须精确为已核实的两种单条成功文本之一:`Memory deleted successfully` 或 `Memory deleted successfully!`。error/errors 存在且非 null(含空字符串或空数组)、冲突 status/event、非零或非法 cascade_count 都归为 unknown。可接受缺省或 SUCCEEDED status、缺省或 DELETE event;其余未知状态不猜测。其后还须做一次精确 GET 复核。
- 不支持 202 异步回执、204 空响应、自定义成功表达式、JSONPath、轮询和协议自动探测。取得新的真实契约后再决定是否扩充,不从其他厂商或批量删除文档推导支持。
- DELETE 与官方客户端一致:接受成功 HTTP 状态并解析有界 UTF-8 JSON,不匹配 message 文案,也不额外约束 status/event/cascade_count/error 字段。随后必须执行一次精确 GET,只有确认不存在才返回 deleted。
- 202 等成功状态仍需有效 JSON 和随后的精确 GET 确认不存在;不轮询。204 空响应、无效 JSON 或非成功 HTTP 状态返回 unknown,不自动重试。
每个配置文件最多 64 KiB;每个 HTTP 响应最多 1 MiB,严格 UTF-8/JSON,有效目标正文再受 4000 code point 上限约束。禁用重定向。timeoutMs 允许 100–30000 ms,forget 的一个总 deadline 覆盖前置 GET、DELETE 和复核 GET,开始于工具实际执行后,不包含人工审批等待。合并调用取消信号,不给每个阶段重新发放完整时间预算。
@@ -143,13 +143,13 @@ sequenceDiagram
| 状态 | 条件 | 含义 |
| ------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
| `deleted` | 已识别的同步成功回执,且紧接的一次精确 GET 按选定协议确认不存在 | 服务确认删除,单条读取已复核;不保证所有索引、备份或历史消息同步清除。 |
| `deleted` | 成功 HTTP 状态与有效 JSON,且紧接的一次精确 GET 按选定协议确认不存在 | 成功 HTTP 状态与有效 JSON,精确读取已复核不存在,不解释服务回执正文;不保证所有索引、备份或历史消息同步清除。 |
| `not_deleted` | 输入/配置不可用、调用在 DELETE 前取消,或前置 GET 失败、找不到目标、scope/正文不匹配 | 本次没有发送 DELETE;使用固定 reason 区分 invalid_input、target_unavailable、target_changed、verification_failed、cancelled。 |
| `unknown` | DELETE 已开始后取消、超时、断线、非预期 HTTP/回执,或成功回执后的 GET 没能确认不存在 | 本次可能已删除;停止,不自动重试,不声称恢复或撤销。 |
输出包含状态、合法输入的 memoryId 和固定说明;不回显正文、credential、endpoint 或上游 message。not_deleted/unknown 使用 MCP isError;非法 MCP schema 也可能在 handler 前被协议层直接拒绝。
DELETE 自身的 404 不能当作“本次删除成功”;成功回执后的 GET 失败也不能借搜索结果为空补成 deleted。若 DELETE 回执未知,立即返回 unknown,不通过后续观察推断是哪一个调用造成的删除。用户可显式发起 context_get 核查,但该读取不追溯证明原请求执行结果。
DELETE 自身的 404 不能当作“本次删除成功”;成功响应后的 GET 失败也不能借搜索结果为空补成 deleted。非成功 HTTP 状态或 JSON 解析失败立即返回 unknown。用户可显式发起 context_get 核查。
## 7. 审批、信任与并发边界
@@ -198,7 +198,7 @@ deleted 不代表服务搜索索引已同步,也不会移除 Qwen 已有 trans
Holo 待确认的事实为:精确 GET 的完整 ID/正文/真实 scope 形状,未找到的表示,单条 DELETE 的同步回执、无级联/批量副作用,以及 ID/归属的生命周期保证。确认这些事实后,使用本次新建的合成目标和同/异 scope 对照记录完成拒绝、批准、精确 GET 消失、搜索传播和对照记录不变验证,再按精确 ID 清理。不得删除既有业务记录。
服务端协议验收通过后才能宣布 Holo 支持;本地实现和合成 daemon 验收可先独立进行。若 Holo 响应不符合上述有限语法,应据真实证据调整设计,不能用宽泛 2xx 判成功或静默选择另一个路径。
服务端协议验收通过后才能宣布相应服务支持。成功 HTTP 状态不是删除完成的充分条件,必须解析 JSON 并通过精确 GET 确认不存在;不静默选择其他路径。
## 10. 设计调研基线的代码依据
+164
View File
@@ -0,0 +1,164 @@
# GPT reasoning effort
GPT-5 models currently lack model-specific reasoning controls in ACP and Web
Shell previews. The OpenAI pipeline also sends the internal `reasoning.effort`
object to Chat Completions, which expects `reasoning_effort`. Configuring
`samplingParams` bypasses the internal effort entirely. GPT-6 Astra has the
same gaps when it falls through to the generic provider behavior.
## Design
Share GPT model capabilities in core's existing reasoning-effort module.
Recognize an explicit list of documented GPT-5 models and GPT-6 Astra; unknown
minor versions and family suffixes keep the generic provider behavior. Reuse
the shared model normalizer for provider prefixes, whitespace and routing tags.
Strip `:batch` locally before normalization; other capability lookups retain
their existing token-limit and modality behavior.
Dated snapshots and numeric patch versions inherit a known model's capabilities.
Chat variants remain excluded. Use the existing `low`, `medium`, `high`, `xhigh`,
`max` ladder without adding CLI tiers.
GPT-5 and GPT-5.1 stop at high, except GPT-5.1-Codex-Max, which supports xhigh.
Known GPT-5.2, GPT-5.3 Codex, GPT-5.4 and GPT-5.5 variants support xhigh;
GPT-5.6 (Sol, Terra and Luna) supports max. Pro variants start at medium
(GPT-5 Pro only supports high). The listed GPT-5, Codex and Pro models require
thinking. GPT-5.1, GPT-5.2 and GPT-5.4 default to disabled;
Base GPT-5.5 and GPT-5.6 default to medium and support disabling.
GPT-5 Pro and GPT-5.5 Pro default to high; GPT-5.2 Pro and GPT-5.4 Pro
default to medium. These Pro variants require thinking.
The [GPT-5 family guide](https://developers.openai.com/api/docs/guides/latest-model?model=gpt-5)
names `gpt-5`, `gpt-5-mini`, and `gpt-5-nano` and lists `minimal`, `low`,
`medium`, and `high`. Their `minimal` mode still uses reasoning; `none` was
introduced with [GPT-5.1](https://developers.openai.com/api/docs/guides/latest-model?model=gpt-5.1).
The shared SDK union is not a per-model support list. Keep these three models
mandatory and do not translate an off request into unsupported `none`.
GPT-6 Astra supports all five tiers and requires thinking. Recognize its
exact identifier, dated snapshots, and provider prefixes; do not infer
capabilities for other GPT-6 variants. Its controls start enabled at medium
and reject off. The shared helper covers both GPT generations.
The pipeline's mandatory-thinking check must consume the same capabilities
for the wire model, including flat disable values and automatic OpenRouter
off requests.
Chat Completions providers, including DashScope-compatible gateways serving
GPT models, map configured effort to the flat wire
field and clamp to the model's supported subset. Explicit `samplingParams`
and `extra_body` reasoning overrides keep their existing priority. OpenRouter
keeps its nested reasoning protocol. Only the translated effort is removed
from the nested object; sibling values such as the reasoning budget remain.
Nullish and empty-string flat placeholders do not suppress configured effort.
While reasoning is enabled, raw nested `reasoning`, including `null`, remains an explicit whole-object
override; it is not interpreted as a cleared flat field.
Sampling options unrelated to reasoning
must not suppress GPT's configured effort. The pipeline emits flat `none` on native GPT endpoints when thinking is disabled on models that support it; OpenRouter receives its nested disable shape.
Explicit model reasoning capabilities take precedence over the built-in tier
list and retain their declared disable field. GPT configured tiers are validated before provider mapping, then translated only after raw request overrides merge. Known mandatory GPT models still
cannot be disabled. The existing runtime-snapshot and ACP-route guards remain
in effect.
GPT overrides are excluded from the existing Qwen-specific ACP override
cleanup. Controls display the same clamped tier as the provider, while a shared
preference survives a model switch that uses the built-in GPT fallback for use on other models. Explicit configured capabilities retain their stricter reconciliation.
A saved off preference also survives when the mandatory constraint comes from
the GPT table over declared capabilities; an explicit configured `canDisable: false`
retains the existing strict reconciliation.
Raw request overrides may determine a different effective tier. Native flat
values and supported OpenRouter nested efforts report the effective raw tier;
OpenRouter sampling flat values do too when they suppress configured nested effort.
OpenRouter enabled flags and token budgets report enabled state with the model
default tier. Native nested values and unrecognized raw tiers remain opaque.
Any raw override that prevents replacing the configured tier rejects explicit
tier changes, including a tier equal to the displayed value, without persisting
or leaving a live mutation behind. OpenRouter extra-body-only flat fields do
not block configured nested effort; sampling flat fields do unless explicit
configured capabilities inject the nested object.
Explicitly disabling a non-mandatory model still works. Blocked raw overrides
advertise `enableValue: 'default'` only when the raw state permits thinking and
the configured reasoning default is not disabled. The thinking switch can then
restore the configured raw defaults after disabling. Otherwise, `canEnable: false`
disables the off switch without changing the saved preference. This also covers
configured tiers without a default and toggle-only controls. Direct blocked tier
requests still fail. Explicit `default` commands retain their reset semantics,
including off defaults. When
mandatory cleanup removes a raw flat `none` after it suppressed the configured
tier, controls report the model default and reject ineffective changes.
Generalizing CLI/SDK override reporting is
outside this change.
ACP uses the shared capabilities to advertise supported efforts, mandatory
thinking, and default enabled state. Existing Web Shell consumers use these
options. The thinking switch selects the advertised default effort when
turning a tiered model on, so a model whose API default is off can be enabled.
Welcome keeps switch-generated tiers tied to their source model and discards that
pending intent on a model change, preserving the target's existing preference;
an explicit compatible tier still migrates. Welcome model changes do not turn
existing max or none preferences into reset commands.
Disabled previews with `canEnable: false` reject pending enabling intent both
on model changes and when refreshed metadata is applied before the first prompt.
Previews with a raw override also reject pending explicit tiers while already
enabled: `enableValue: 'default'` or `canEnable: false` identifies controls
whose configured tier cannot replace the raw value.
Both cold workspace provider previews use the target provider's generation
defaults to project raw override state and the same enable constraints as a live
session. Provider defaults do not inherit ignored top-level generation settings.
For models that allow thinking to be disabled, a saved off preference remains
off in the preview. Enabling a raw-controlled model selects `default` when
permitted; raw configurations that cannot enable thinking prevent turning it
on before a session is created.
ACP `default` retains its reset semantics. There are no new daemon routes or persistence
formats. Responses-only models still require a compatible Chat Completions
gateway; adding a Responses transport is outside this change.
GPT-6 Astra supports Chat Completions, but its official tool-calling API
requires Responses; the configured gateway remains responsible for that
compatibility.
## Affected areas and validation
- Core reasoning-effort capabilities and tests.
- Default OpenAI provider request mapping and tests.
- OpenAI pipeline sampling and disable handling and tests.
- CLI ACP model configuration and tests.
Verify global CLI baseline and local bundled CLI with a local recording mock
endpoint, plus focused core/CLI tests, build, typecheck, formatting, and lint.
The E2E plan and results live in `.qwen/e2e-tests/gpt-5-reasoning-effort.md`.
Live tests use the user's configured providers with isolated settings and
runtime directories. GPT-5.5 and GPT-5.6 Sol pass; GPT-5.4 is unavailable on
the configured gateway. GPT-6 Astra's pre-extension bundle returns real
responses but sends nested high, clamps max to xhigh, and loses effort with
sampling parameters. Its live verification plan is
`.qwen/e2e-tests/gpt-6-reasoning-effort-live.md`.
## Sources
- [Chat Completions request](https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create)
- [GPT-5](https://developers.openai.com/api/docs/models/gpt-5)
- [GPT-5.1](https://developers.openai.com/api/docs/models/gpt-5.1)
- [GPT-5.4](https://developers.openai.com/api/docs/models/gpt-5.4)
- [GPT-5.5](https://developers.openai.com/api/docs/models/gpt-5.5)
- [GPT-5.6 Sol](https://developers.openai.com/api/docs/models/gpt-5.6-sol)
- [GPT-5 Pro](https://developers.openai.com/api/docs/models/gpt-5-pro)
- [GPT-5.4 Pro](https://developers.openai.com/api/docs/models/gpt-5.4-pro)
- [GPT-6 Astra](https://developers.openai.com/api/docs/models/gpt-6-astra)
- [GPT-6 reasoning and transport requirements](https://developers.openai.com/api/docs/guides/latest-model)
- [gpt-5-mini](https://developers.openai.com/api/docs/models/gpt-5-mini)
- [gpt-5-nano](https://developers.openai.com/api/docs/models/gpt-5-nano)
- [gpt-5.1-codex](https://developers.openai.com/api/docs/models/gpt-5.1-codex)
- [gpt-5.1-codex-max](https://developers.openai.com/api/docs/models/gpt-5.1-codex-max)
- [gpt-5.2](https://developers.openai.com/api/docs/models/gpt-5.2)
- [gpt-5.2-codex](https://developers.openai.com/api/docs/models/gpt-5.2-codex)
- [gpt-5.2-pro](https://developers.openai.com/api/docs/models/gpt-5.2-pro)
- [gpt-5.3-codex](https://developers.openai.com/api/docs/models/gpt-5.3-codex)
- [gpt-5.4-mini](https://developers.openai.com/api/docs/models/gpt-5.4-mini)
- [gpt-5.4-nano](https://developers.openai.com/api/docs/models/gpt-5.4-nano)
- [gpt-5.5-pro](https://developers.openai.com/api/docs/models/gpt-5.5-pro)
- [gpt-5.6-terra](https://developers.openai.com/api/docs/models/gpt-5.6-terra)
- [gpt-5.6-luna](https://developers.openai.com/api/docs/models/gpt-5.6-luna)
- [Model catalog, including the GPT-5.6 alias](https://developers.openai.com/api/docs/models)
Raw gateway extensions are preserved; their internal behavior cannot be inferred
from the native Chat Completions protocol.
@@ -1,5 +1,10 @@
# Model reasoning capabilities
> **GPT extension:** [GPT reasoning effort](gpt-5-reasoning-effort.md) adds
> known GPT model capabilities from the shared core definition, ahead of
> this exact-id manifest. The manifest remains the source for curated Qwen
> models; GPT tiers must stay consistent with the provider's capabilities.
## Goal
Expose accurate reasoning controls for common Alibaba Cloud Coding Plan and
+24
View File
@@ -0,0 +1,24 @@
# Remote serve quickstart
## Problem
A non-loopback `qwen serve` currently refuses to start without an operator-created bearer token. The built-in Web Shell also requires an origin allowlist for same-origin mutations, and offers no recovery when a tab lacks a valid token.
## Design
- Resolve explicit `--token` before `QWEN_SERVER_TOKEN`, preserving trimming and explicit-empty shadowing. Only when neither source is present and the requested `--hostname` is non-loopback (the literal `localhost` resolved once first), generate 16 random bytes as a base64url bearer (128-bit, 22 URL-safe characters — short enough to type or scan, and far beyond any online brute-force budget for a credential that is never stored and rotates per process); a `localhost` request that resolves outside loopback keeps the existing fail-closed refusal and never generates, while a non-literal name that resolves to loopback generates and its bearer prints token-only. Explicit empty sources still fail the existing remote-bind check. Loopback and `--require-auth` defaults are unchanged.
- Keep generated credentials ephemeral and process-local. After successful listen, print the generated bearer once as intentional stdout credential output, separate from diagnostics. Never add it to ordinary URLs, logs or discovery metadata. Show token-free local/network URLs for wildcard listeners, advertised from Local Control's private IPv4 population (RFC 1918 / RFC 3927) plus fc00::/7 ULAs of non-software interfaces on dual-stack wildcard binds — the ULA addition is quickstart-only, since Local Control itself stays IPv4-only. Excluding software interfaces (VPN, container bridges, VM adapters) is a NAME heuristic (#9158), not a guarantee, so k8s overlay names such as `cbr0`, `cali*` and `vxlan.*` can slip through; scoped link-local IPv6 addresses and routable public addresses are never enumerated on a wildcard bind. No public-IP lookup. Wildcard detection is canonicalised (trimmed/lowercased, including the IPv4-mapped `::ffff:0.0.0.0`); a zone-scoped explicit bind prints no quickstart entries. The whole block writes through the non-throwing stdout helper, and the serve entry point installs a guard that swallows EPIPE on the stdout/stderr `error` events (rethrowing anything else), so a vanished pipe reader (`qwen serve | head`) cannot take the listening daemon down. Warn that HTTP is unencrypted.
- Preserve existing loopback origin handling. Add a narrowly scoped remote same-origin middleware immediately before the origin policy: only on the primary non-loopback listener with a configured token, compare a canonical HTTP(S) Origin to the direct socket scheme and normalized Host authority, ignoring forwarded headers. Authenticate matching requests before stripping their Origin. Cross-origin requests still use existing CORS policy; null origins remain rejected. The runtime admission order is access log, trace-id capture, Host gate, remote same-origin, CORS wall, pre-auth health, bearer auth; the bootstrap app mounts the same chain (minus runtime-only layers) so a request receives the same reject body in every boot phase. Static routes, Local Control, and body parsing keep their positions. Both bootstrap and fully mounted apps use this guard. Its pre-auth predicate comes from the dependency-light `web-shell-preauth` module so the serve fast-path static closure keeps deferring the express-static/CSP machinery.
- In standalone Web Shell bootstrap, probe authenticated `/capabilities` before mounting the workspace. A 401 opens a token password form; a successful retry stores the token in the existing per-tab cache/sessionStorage and mounts the app. A 403 explains origin/Host policy separately rather than requesting another password. Transient outcomes auto-recover instead of dead-ending: a response carrying `Retry-After` (the bootstrap app's cold-runtime 503) re-probes on that interval — clamped to a 30 s ceiling so a misconfigured proxy cannot park the gate — other network/5xx failures re-probe on a fixed interval, and the permanent startup-failure discriminator is the envelope's `body.code === 'daemon_runtime_failed'`, NOT the absence of `Retry-After` (front proxies and the rate limiter also emit bare 503/429, which stay transient); its error text is surfaced with manual retry only. Every probe is bounded by a 10s abort (below the SDK's 30s fetch timeout) and cancellable on unmount; the retry button is never permanently disabled. Copy is bilingual (en / zh-CN) following the pre-app `RootErrorFallback` convention. Existing fragment handoff and extension postMessage remain supported; token-bearing URLs are still scrubbed in every build mode.
## Security decisions
The same-origin exception requires a verified bearer, not merely a matching attacker-controlled Host. Loopback exemptions remain unchanged. Local Control uses its existing listener-scoped credentials and origin allowlist, and is excluded from the remote exception. No proxy trust, cookie auth, persistent secrets, new flags, account management, certificate provisioning or TLS changes are introduced. Generated credentials rotate on restart; old tabs can enter the replacement token. Explicit credentials are never printed as plain text. The startup QR deliberately contains the resolved credential in a URL fragment, with an explicit secret warning; normal address lines remain token-free. The QR prints when the credential is the process-generated ephemeral one (its only delivery channel) or when stdout is an interactive terminal — a stable operator token is never re-published into captured stdout (container/systemd logs) on every restart, though a pty-allocated container counts as interactive and its logs stay secret-bearing by design. Reuse `qrcode-terminal`, choose a deterministically enumerated private-LAN address (preferring a non-link-local one, falling back to any remaining non-local entry), print an explicit fallback line when no candidate exists, and omit QR when Web Shell assets are disabled/unavailable. On an explicit non-loopback bind the enumeration is the operator's own address, routable or not. Existing public shell requests must bypass remote same-origin authentication because browser module-script fetches carry Origin without Authorization; API requests still authenticate.
## Files and verification
CLI token resolution, startup output, shared self-origin middleware, the extracted pre-auth predicate module, and both server mounts; standalone token configuration/bootstrap/auth gate and focused regression tests; serving, developer-daemon and help-text documentation. Regression coverage includes option/environment precedence and empty values, entropy/rotation, env-token end-to-end authentication, remote authenticated same-origin success at the real app mount, invalid/missing bearer, cross-origin/null/forwarded header rejection, LAN-population filtering, QR credential gating, startup-block EPIPE resilience, token UI retry/storage/auto-recovery, DEV-mode URL scrubbing, and unchanged loopback/local-control behavior. Baseline and bundle checks are recorded in `.qwen/e2e-tests/serve-remote-quickstart.md`.
## Boundaries and open questions
No changes to session/runtime ownership or API routes. `/capabilities` is the existing process-global bootstrap endpoint and is read only; normal selected-runtime and workspace routes remain unchanged. NIC addresses are suggestions, not a promise of firewall/NAT reachability. HTTP remains explicitly unencrypted. Three deliberate deferrals: the first is named at runtime, by the startup warning that also covers the TLS-front-proxy exclusion, and in the user guide; the probe-response and cold-window deferrals are not warned about and are recorded here and in the PR body: the WebSocket upgrade gate (terminal, voice) keeps its loopback/allowlist/Local-Control-listener-origin CSWSH policy, so remote same-origin WS still needs `--allow-origin` — extending the exception to upgrades requires its own bearer-verified design; the bootstrap probe's `/capabilities` response is discarded rather than seeded into the workspace provider, costing one extra same-endpoint round trip per standalone load; and — observed end-to-end, pre-existing and not introduced here — during a MULTI-workspace cold start the bootstrap app answers uncredentialed shell document/asset requests with its bearer 401 for ~150-200 ms (longer on slower hosts) until the runtime mounts, so a browser navigating inside that window renders raw JSON and needs one manual reload; the single-workspace default has no such window, human QR-scan latency effectively never lands inside it, and serving the pre-auth shell during the bootstrap phase is its own design decision tracked as a follow-up. No open product decisions remain for this change.
+29
View File
@@ -0,0 +1,29 @@
# Subagent detail availability during creation
Web Shell currently opens a detail panel before a subagent session can be resolved. The first running tool update precedes slot allocation, runtime initialization, task registration, and transcript setup. Missing titles are presentation fallbacks, not lifecycle signals.
## Design
Carry an optional `subagentSessionReady` boolean on the existing tool update path. The CLI marks main-session agent launch/preparation frames false and forwards the ready transition from the live execution callback before the tool completes. Core keeps false during regular-agent initialization and emits true only after task registration and transcript/meta setup, for both foreground and background execution. Subsequent progress and result displays retain the value. No new endpoint, SSE event, polling loop, or dependency is needed.
Nested details replay a separate JSONL transcript. Its writer records `agent_session_ready` system records only when readiness changes, starting false after the nested tool call. The existing replay bridge converts these records to tool updates. Resume skips trailing readiness markers when trimming unfinished calls, and finalized nested failures preserve their error status. Legacy inline child streams retain their previous behavior because they do not forward intermediate tool output.
The SDK normalizer reads the explicit metadata/display boolean, and the transcript reducer retains it as typed tool-block state. This also makes it available to safe projections that intentionally discard raw output. Web Shell projects that field into its tool model and preserves it when merging updates. True is latched: later partial updates must not regress an established session back to creation.
A shared presentation helper controls all subagent detail launch buttons. Explicit false on an active call disables activation and supplies the localized hover/focus description “Creating…” / “创建中”. A launch which fails or is cancelled before readiness remains unavailable with its terminal status, rather than claiming it is still creating. Ready agents remain inspectable even if their execution subsequently fails. Missing fields preserve legacy behavior. Completed launches retain their existing opening behavior (including named teammates, which use a different session mechanism). Document-mode inline rendering and approval handling remain intact.
Use `aria-disabled` plus an activation guard and the existing/native tooltip affordance so disabled entries remain discoverable by keyboard and pointer. Guard the shared opening callbacks as well as the visible controls.
## Changes and consumers
- Core: tool display type, regular-agent foreground/background startup, nested transcript readiness records and focused lifecycle tests.
- ACP bridge: replay readiness records on the existing tool-update path.
- CLI: tool-start emitter and its tests, including preparation placeholders; virtual-session streaming and reload regression coverage.
- TypeScript SDK: event/block types, tool normalization and reduction, tests for missing/false/true and partial updates.
- Web Shell: compact event projection, transcript-to-message adapter and merge, tool model, availability helper, single/parallel/plan/workflow buttons, opening callbacks, localized strings and targeted tests.
No daemon route ownership changes. Child details continue resolving inside the existing parent-session owner runtime. No change to spawn scheduling, approvals, or execution status semantics.
## Validation
Verify creation to ready transitions for foreground/background agents, initial placeholders, terminal failures/cancellation, nested launches, legacy transcripts, safe/compact projections, and pointer/keyboard activation. Run build, typecheck, bundle and focused package tests; inspect the complete diff and conduct an independent review.
@@ -0,0 +1,176 @@
# Web Shell 浏览器任务通知:独立实现设计
状态:独立浏览器通知已实现,构建和自动化验证通过。调研及实现日期:2026-09-08。
## 决策与范围
基于当前 main 独立实现,不依赖 #11251。首版只修改 Web Shell client,复用已有 `turn_complete` / `turn_error`,增加通知专用的内部事件适配和一个用户开关。先浏览器通知,再支持 Channel。
产品名称为“浏览器任务通知”,但事件语义是一次 assistant 回合结束,不代表整个项目、多轮目标或所有后台 agent 都完成。首版覆盖页面仍在运行且当前聊天或 Split View 仍被观察的情形,包括浏览器切到后台、窗口失焦。切换到别的聊天后未挂载会话的即时提醒、关闭网页、浏览器冻结或系统休眠不在首版保证内。
## 当前代码依据
源码基线:`1a73f5bff6201473f237c5106367e944ba2d092b`。设计依据为该基线;后续实现及验证记录见文末。
| 当前实现 | 可复用部分与限制 |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `packages/acp-bridge/src/bridge.ts` | 发布权威 turn_complete / turn_error,包含 session、prompt 标识。兼容类型允许缺少 promptId,通知不能猜测标识。 |
| `packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx` | 已有 live 事件、历史恢复、重连及终态前 transcript flush;是通知观察接入点。 |
| `packages/web-shell/client/daemon/session/actions.ts` | 普通及队列提交已有 onPromptAdmitted,确认移除已有 onPromptRemoved,可复用作恢复时的跟踪证据。 |
| `packages/web-shell/client/main.tsx` | StandaloneApp 已管理浏览器本地 theme/language;根节点覆盖主聊天和 Split View。 |
| `packages/web-shell/client/components/messages/SettingsMessage.tsx` | 已有本地 chatWidth 项及共享 Switch、SettingsRow。 |
| `packages/cli/src/serve/routes/workspace-settings.ts` | general.terminalBell、general.notificationMode 被归为 TUI-only,不能复用为浏览器开关。 |
| `packages/sdk-typescript/src/daemon/DaemonSessionClient.ts` | 有 lastEventId/epoch 恢复能力;SSE 结束也会拒绝 pending promise,不能据任意 reject 发任务失败通知。 |
App 的旧 onSessionChange(turn_complete) 和侧边栏 completedUnread 来自 UI/摘要变化,不作为新通知输入。旧原型位于独立的 `codex/browser-turn-notifications` 分支;只参考其权限、文案及设置逻辑,不整体引入已关闭 #10398 的历史。#11251 的宿主回调、最终 assistant 消息提取不属于本功能范围。
## 用户开关
入口:**Settings → UI → 浏览器任务通知**。默认关闭,立即生效,无需刷新或重启 daemon。
```text
浏览器任务通知 [关闭 / 开启]
页面在后台或窗口失焦时,提醒当前聊天和分屏聊天的回合结束或失败。
仅保存在此浏览器站点;关闭网页后不再提醒。
状态:未开启 / 已开启 / 等待授权 / 浏览器已阻止 / 当前环境不可用
```
复用已有 Switch 与 SettingsRow;增加 local 类型设置项,明确标注“此浏览器站点”。Settings 的 workspace/user scope 切换不改变该值,也不向 daemon settings API 写入。
| 配置项 | 决定 |
| ---------------- | ------------------------------------------------------------------------------ |
| 内部名称 | browserNotificationsEnabled: boolean |
| localStorage key | qwen-code-web-shell-browser-notifications |
| 持久化值 | 字符串 true/false;不存在或非法时为 false |
| 范围 | 当前 browser profile + origin;不随 workspace 变化,不同步到其他设备 |
| 默认与启停 | 默认 false,即使权限已授予也不自动开启;关闭停止新通知,开启不补发已处理的回合 |
| 标签页同步 | 监听 storage 事件;写入页面直接更新本地状态 |
| 存储失败 | 本页面内继续可用,提示“设置仅在当前页面有效” |
不写入 `.qwen/settings.json`:浏览器权限是设备/站点状态,daemon 配置不能代替用户授权;同一个 workspace 可以同时被权限不同的浏览器打开。
### 权限与设置状态
开关表示用户偏好,状态文字表示当前是否可用。发送需同时满足偏好开启、权限 granted、环境支持及后台条件。
| 操作或状态 | 行为 |
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
| 关闭→开启,permission=granted | 保存 true,立即生效。 |
| 关闭→开启,permission=default | 在点击的同步调用链中 requestPermission;等待期间禁用重复提交,允许后才保存 true。拒绝或关闭对话框则保持 false。 |
| 关闭→开启,permission=denied | 保持 false,提示去浏览器站点设置允许通知,不重复请求。 |
| 开启→关闭 | 保存 false,不撤销浏览器权限,不补发。 |
| 已保存 true,权限后来被撤回 | 保留偏好,显示“浏览器已阻止,当前不会通知”;可关闭开关。发送前重新检查权限。 |
| 已保存 true,permission 回到 default | 显示“等待授权”和“允许通知”操作;页面加载不自动申请。 |
| 非安全上下文、API 缺失 | 显示不可用原因并禁止开启,不自动改写持久偏好。 |
设置页打开、窗口重新聚焦和实际发送前重新读取权限,不依赖持续权限订阅。迟到的授权结果不得覆盖其他标签页在等待期间作出的关闭操作,使用请求代次和最新偏好检查丢弃失效结果。
首版面向桌面浏览器;Notification 对象存在不代表手机支持构造器。构造失败或 error 事件更新本地可用状态,不能影响聊天或重复弹错误。用户手势、安全上下文和跨源 iframe 限制参见 [MDN Notifications API](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API)。
## 触发与文案
| 输入 | 行为 |
| -------------------------------------- | ----------------------------------------------------------------- |
| turn_complete,stopReason=end_turn | 通知“本轮已完成”。 |
| turn_error | 通知“本轮执行失败,请返回查看”,不显示错误正文。 |
| turn_complete,stopReason=cancelled | 不通知,静默消费终态。 |
| 其他合法 turn_complete stopReason | “本轮已结束,请返回查看”,不把 token 上限等停止原因说成任务成功。 |
| prompt_cancelled | 只是取消请求,不抢先消费最终终态。 |
| 排队 prompt 确认 removed | 清理对应跟踪记录并静默处理,不改变其他回合。 |
| SSE 断开、重试、HTTP/权限错误、UI idle | 不作为任务失败证据。 |
| 缺失或冲突的 sessionId/promptId | 不通知,不用当前会话替代事件来源。 |
展示条件为 `document.visibilityState !== 'visible' || !document.hasFocus()`。前台聚焦时消费终态但不展示,后来失焦不补弹。首版不增加时长阈值、声音选择或自定义正文。
标题固定 Qwen Code,正文仅上述通用文案,不含 prompt、答复、错误、会话标题、路径或 workspace 名。点击尝试 window.focus() 并关闭通知,不自动切换会话;操作系统可能拒绝聚焦,不能保证成功。展示失败不改变回合结果。
## 内部架构
```mermaid
flowchart TD
A[现有 daemon SSE 与恢复快照] --> B[DaemonSessionProvider]
P[已有 admission / removal 回调] --> B
B --> C[内部通知观察接口]
C --> D[Standalone 根部通知协调器]
S[本地开关与浏览器权限] --> D
D --> E[去重与后台判断]
E --> N[Notification API]
```
在 `client/daemon/session/` 增加包内通知观察 Context,默认 undefined,仅传纯数据,不访问 Notification 或 localStorage。实现由 standalone 根协调器提供,主聊天和 Split View 共用。非 standalone 宿主无此祖先时不跟踪、不显示设置、不申请权限;standalone 页面被 iframe 加载时也不挂载通知协调器。
接口最小数据:来源范围、sessionId、promptId、事件类别、stopReason、live/restore 来源,以及 admission/removal 信号。不传正文,不导出公共 SDK API,不修改既有宿主回调。下层 session 只依赖内部类型,上层浏览器模块消费它,避免循环依赖。
来源范围由规范化 daemon base URL、产品 session context 和已解析 workspace 身份组成,取自捕获的 session owner。异步发送不能再读取已经切换的全局 connection。token、URL 查询参数和 fragment 不得进入通知标识或共享记录。
### 终态与恢复顺序
1. 在现有 live 终态完成 normalize、flush 和 assistant.done 投影后发布通知数据;先校验 owner,避免在 active/observer 两个分支重复发布。
2. 复用 onPromptAdmitted,只读 owner/promptId,保留已有 turn navigation 行为。普通及队列提交都登记;确认 removal 后移除。带明确 promptId 的 live 启动证据也可登记,历史用户消息不能建立新 admission。
3. live 流(包括 cursor 增量恢复)的真实终态可以直接进入统一去重。终态先于本地 admission 回调抵达时先处理,晚到 admission 不能重新登记为未结束。
4. 初始历史、历史分页和跳转加载保持静默。恢复快照的终态只有匹配本页面此前跟踪的未结束 prompt 才可补发,且在快照提交后发布。
5. 同会话重连、epoch 重置和 ring eviction 重载保留待跟踪身份;不能只存在可能被清理的 activePromptsRef 中。根协调器保存最小集合。
6. 页面刷新不持久化待跟踪列表;新页面不补发刷新前已完成历史,之后 live 到达的新终态仍可通知。恢复数据不含目标终态时,不依据 hasActivePrompt=false 推测成功。
7. 明确切换会话或关闭 pane 后,不另保留连接;最后一个对应观察者退出时清理其跟踪。连接重试及 React StrictMode 的同身份重建不算用户离开,不能误清理。
当前观察范围的终态在开关关闭/前台时仍被消费,以防重复事件在后来开启时补弹。上述集合属于通知模块,不能反向改变 transcript、输入或 daemon 生命周期。
## 去重保证
键包含来源范围 + sessionId + promptId。通知 tag 和共享记录使用稳定指纹,源数据只留在内存,不存正文/token。已处理记录采用有界近期缓存,候选上限 1024 条,不增加用户配置;历史终态始终先经过跟踪门槛,因此缓存淘汰不应重放历史通知。
- 同页面先认领终态,再判断取消、开关、权限及前后台。主聊天和分屏重复事件只触发一次通知尝试;取消/前台/关闭也算已处理。
- 同源多标签页在 Web Locks 与共享存储可用时,用短锁保护已认领指纹的检查及写入,锁内重新检查开关和权限。只有满足本地展示条件的页面参与跨页发送认领;未开启的页面不能吞掉有效页面的资格。
- 同一键使用稳定 tag,支持时 renotify=false。无锁或无共享存储时退回同页面去重及同 tag 替换,不保证跨标签页严格只提醒一次。
- 跨页记录表示已认领一次尝试,不表示用户收到或看到。写入后崩溃或系统通知失败可能漏提醒;首版不增加持久投递队列与重试,不宣称 exactly-once delivery。
- 可见性以发送页面为准:一个标签页前台、另一个后台时,后者仍可能提醒。首版不增加跨页阅读状态协调。
Web Locks 提供同源互斥,tag 是通知替换而非事务幂等。参见 [MDN Web Locks](https://developer.mozilla.org/en-US/docs/Web/API/Web_Locks_API) 和 [MDN renotify](https://developer.mozilla.org/en-US/docs/Web/API/Notification/renotify)。
## 实际改动
实现涉及以下位置及对应测试。
| 位置(packages/web-shell) | 责任 |
| -------------------------------------------------- | --------------------------------------------------------------------------- |
| client/daemon/session/turn-notification-context.ts | 包内观察接口及类型,不导出公共 barrel。 |
| client/daemon/session/DaemonSessionProvider.tsx | admission/removal、live 终态及恢复快照提交点。 |
| client/browser-turn-notifications.tsx | 根协调器、本地设置 context、权限/展示及有限去重状态;避免提前建设通用框架。 |
| client/main.tsx | 仅顶层 standalone 挂载协调器,传入当前语言。 |
| client/components/messages/SettingsMessage.tsx | UI 本地开关、权限状态及授权操作。 |
| client/i18n.tsx、README、对应测试 | 文案、产品边界和回归验证。 |
不新增 SSE 连接或额外 transcript provider,不改 daemon route、settings schema、CLI 通知服务、Channel worker 或 SDK 公共合同。#11251 若后来合并,可评估复用入口,但不是交付条件。
## 验证与交付
实施前按仓库要求使用全局 qwen 做基线 dry-run;设计阶段不运行。实施后运行 build、typecheck、相关单测与桌面浏览器 E2E。模拟 Notification 的单测不能代替 OS 通知接收证据。
| 测试组 | 验收重点 |
| ---------- | ----------------------------------------------------------------------------------------------- |
| 开关 | 默认关闭、granted 不自动启用、workspace/user scope 不影响、刷新持久化、storage 同步及失败降级。 |
| 权限 | 点击才请求、三个权限状态、撤回、迟到授权、iframe/API/安全上下文、构造及 error 事件失败。 |
| 终态 | 完成/失败/取消/其他 stopReason、冲突 ID、取消请求不抢占终态、断网不是任务失败。 |
| 身份与排序 | 投影先于通知、终态先于 admission、主聊天/分屏重复、workspace 切换和旧 owner 隔离。 |
| 恢复 | 历史静默、跟踪回合的增量/快照补发、epoch/ring 重载、StrictMode、刷新不补旧历史。 |
| 去重 | 前台/关闭消费后不补弹、缓存边界、多页锁竞争、无锁/存储降级、失败不改变聊天。 |
| 手工 | Chrome/Safari 桌面后台完成和失败、OS 权限、点击聚焦;其他浏览器未测需明确标注,手机不宣称支持。 |
E2E 清单见 `.qwen/e2e-tests/web-shell-browser-turn-notifications-independent.md`。实现完成后审阅完整 diff,按仓库要求完成两轮干净自审和独立代码复核;提交 PR 另按用户指示执行。
## 后续能力
切换聊天后仍提醒旧聊天,可另加针对未结束 prompt 的轻量观察或评估 workspace 终态流,不能靠 running-to-idle 猜测,也不为全部历史会话建立连接。
关闭网页后提醒优先由服务端 Channel 承担。已有 prompt delivery 投递正常 end_turn 的最终答复,不等同于状态通知或失败提醒;简短完成/失败提醒需另接服务端终态,并沿用明确授权目标和所属 workspace,不由浏览器收到事件后代发。
网页关闭后的浏览器推送需要另外的 Push 投递链路,单独注册 Service Worker 不够,首版不包含。参见 [MDN Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API)。
## 实现与验证记录
实现仅修改 Web Shell:根组件提供页面级通知状态和本地设置,DaemonSessionProvider 在 live / recovery 已完成 transcript 投影后观察权威终态,既有提交回调用于恢复跟踪。没有新增 daemon 路由、SSE 连接或公开 SDK 回调。页面验证同时发现并修正共享 Switch 的状态选择器:匹配 Radix 的 data-state 属性,以正确显示开关颜色和滑块位置。
单元测试覆盖终态分类、权限与偏好、重复消费、历史静默、存储受限、通知失败隔离,以及实际 Provider 的终态投影顺序和 epoch reset 恢复。页面级验证使用真实 Web Shell、mockDaemon SSE、Chromium 和 Notification stub,覆盖设置入口、无 daemon 设置写入、前后台差异、双页开关同步与真实 Web Locks 竞争。操作系统最终展示、移动端及关闭页面后的推送不属于本次验证。详细命令和结果位于本地 `.qwen/e2e-tests/web-shell-browser-turn-notifications-independent.md`。
最终源码通过全仓 `npm run build`、`npm run typecheck`、`npm run bundle`,6 个相关测试文件合计 365 项通过;修改文件 ESLint、Prettier 和 diff 检查通过。独立复核发现并修复迟到授权覆盖其他标签页关闭操作的竞态,修复后的复核无新增问题。
+25 -4
View File
@@ -39,8 +39,25 @@ owns preference writes and rollback across writable scopes; a failed write
also restores the live overrides. Model reconciliation reuses the same reset
operation. Requested persistence completes before the daemon reports success.
Turning thinking off sends `none`. Turning it on again sends `default`, which
removes `none` and uses the model default instead of restoring the old tier.
Turning thinking off sends `none`. Turning it on sends the advertised default
effort for a tiered model, or `default` for toggle-only models. A GPT raw
request override that blocks configured tiers advertises `enableValue: 'default'`
only when restoring the raw configuration can enable thinking and the model's
configured reasoning default is not disabled. Otherwise, `canEnable: false`
disables ON while retaining the saved preference, including for controls without
a default tier or with only a toggle. OFF remains available while thinking is
on. Explicit `default` commands remain
resets, even for off defaults. Explicit tier choices remain strict and are never
silently treated as reset commands.
On Welcome, an on intent selected by the thinking switch belongs to the
current model. Switching models discards that pending on intent and uses the
target's existing preview and persisted preference. It neither saves the source
model's tier nor resets the target's preference. Explicitly selected compatible
tiers still migrate. Incompatible pending selections are discarded; model
switching never synthesizes a reset from the source preview. Session
reconciliation handles existing preferences. This distinction exists only in
pending UI state; no new persisted setting is introduced.
## Capability reconciliation
@@ -52,8 +69,12 @@ Every model switch reconciles the stored selection against the target model:
- a model without reasoning controls removes every explicit selection;
- mandatory thinking removes `none`.
There is no fallback or tier clamping at this layer. Once an incompatible
selection is removed, switching back does not restore it.
The built-in GPT capability fallback preserves a shared tier preference across
model switches and clamps its effective tier at the request and display
boundaries. A saved `none` also survives switching to a mandatory GPT model,
where it is ignored. Explicit configured capabilities retain the reconciliation
rules above. For other models, an incompatible removed selection does not return
when switching back.
Session-only ACP model changes clear incompatible live overrides without
changing the shared stored preference. Only a persistent model selection may
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,114 @@
# Workspace 注册容量基线:1 / 25 / 256
关联 [#11386](https://github.com/QwenLM/qwen-code/issues/11386)。2026-09-08 在 `1a73f5bff6201473f237c5106367e944ba2d092b` 上完成;这是现有急切构建路径的容量实验,没有实现 LRU,也没有修改生产源码。可核对的逐轮摘要见 [JSON 数据](workspace-capacity-baseline-2026-09-08.json),结果已同步至 [issue 实测记录](https://github.com/QwenLM/qwen-code/issues/11386#issuecomment-5586177947)。
## 结论与对方案的调整
本机空 workspace 的成本不足以支持“必须先实现完整 LRU 才能扩容”的判断。启动注册从 25 增加到 256,daemon 的 GC 后堆中位数增加 **20.6 MiB**,RSS 中位数增加 **25.2 MiB**;256 个 workspace 完整就绪约 **1.38 秒**。固定一个已初始化 ACP child 时,该 child 自身 RSS 约 **192 MiB**,不是每条空注册记录都付出这个成本。
注册也不是纯元数据:256 个启动 workspace 有 256 个 cron 监听资源,查询每个 Git 仓库后共 512 个监听资源、277 个 FD。默认总 session 上限还会随启动数量从 800 放大到 8192。这些是扩容 PR 必须处理或在目标部署验证的独立事项。
建议先交付保持默认 25 的常量解耦,再单独处理注册扩容的兼容性。懒加载/LRU 暂作为后续独立优化,不作为当前扩容的硬前置;若目标主机的监听器预算、真实 workspace 初始化成本或启动延迟成为约束,再按 [生命周期设计](workspace-lru-eviction.md) 实施。真实 child 内存准入仍属于 #8182,本报告没有为 enforcement 提供工作负载校准。
## 环境与方法
- macOS 26.6.2 arm64,Apple M4 Pro,12 个逻辑 CPU,48 GiB 内存;Node v22.22.3,npm 10.9.8。
- 从上述 commit 执行 `npm run build`、`npm run bundle`。本地依赖最初缺少仓库已有 Ink 补丁;执行 `npm run postinstall` 后重新构建成功,未修改源码或锁文件。
- 复制整个 `dist` 为独立实验产物,只把编译后的 `MAX_REGISTERED_WORKSPACES = MAX_DAEMON_WORKSPACES` 改为 `MAX_REGISTERED_WORKSPACES = 256`。逐文件比较确认唯一差异是这一行;共享 `MAX_DAEMON_WORKSPACES = 25` 不变,所以 child model 和既有 channel timeout 常量没有随实验改大。原/实验 chunk SHA-256 分别为 `0298fc2d87007bb73ed663b3a10a698092362afa7f222f5fa0c046c7a4c9a229` / `3318c999515cacc7a004a1557cd3e6a8fa7db415d754444c46c0265660cace11`。
- 每轮新建互不嵌套的临时 workspace,隔离 QWEN_HOME、QWEN_RUNTIME_DIR、系统 settings、trust、缓存及测试 `os.homedir()`。只保留白名单环境变量。绑定 loopback,使用临时 bearer token,`--no-open --no-web`,没有浏览器客户端。
- 使用现有 `VITEST_WORKER_ID` 开关关闭 primary 默认 ACP 预热,得到受控的零 child 基线;这不是默认启动行为。测试 preload 在所有场景隔离 home;仅资源诊断、churn 和边界场景(含 Git 变体)启用 `async_hooks` 计数。
- “完整就绪”从 OS spawn 计时,到 `/health` 成功且完整 `/daemon/status` 报告全部注册 runtime;另核对实际 ACP mounts、trusted 数及 status issues。当前 bootstrap 状态不包含该 runtime 计数,且 registry 在启动 runtime 构建完成后创建。该判断不应直接推广为未来 dormant 模型的 readiness。轮询间隔 100 ms,未清空 OS 文件缓存。
- 主内存矩阵顺序为 `1/25/256`、`256/1/25`、`25/256/1`,每档三轮。就绪后稳定 10 秒,再每 5 秒采样,共 5 次、20 秒窗口。表中 RSS/自然堆先取轮内中位数,再取三轮中位数;GC 后堆在自然采样后强制 GC 单独读取。
- 内存/CPU 使用 loopback Node inspector 读取 `process.memoryUsage()` / `process.cpuUsage()`;所有对照同样带 inspector。CPU 为单逻辑核占比,100% 表示占满一个核。额外 idle 场景每档单轮,稳定 10 秒后连续采 80 秒,涵盖 60 秒 reaper/sweep 和 PR 首次扫描,但未覆盖默认 10 分钟 cron keepalive 周期。CPU 窗口内不强制 GC。
- 文件监听和 timer 用独立诊断轮的 Node `FSEVENTWRAP` / `Timeout` 活资源数,包含 unref 资源;FD 用 `lsof` 数字 descriptor 数。诊断轮开启 hooks 后的 RSS 不混入主内存表。资源计数只覆盖 daemon;child RSS 另用 OS 进程树读取,不代表 child 的 watcher 已被统计。
- 所有性能场景串行,期间没有并行构建或其他本任务压力测试;这是普通开发机,不是独占性能实验室。
## 零 child 的启动注册成本
以下均为启动时传入 N 个 `--workspace`,不是启动后动态添加。所有快照 session=0、active ACP child=0、OS 后代进程=0,完整 ACP mounts=N,status=ok 且 issues 为空。
| 启动 workspace 数 | 完整就绪中位数(范围) | daemon RSS | 自然 heapUsed | GC 后 heapUsed | 80 秒空闲 CPU | FD |
| ----------------- | ---------------------- | ---------- | ------------- | -------------- | ------------- | --- |
| 1 | 1.115 s(1.000–1.159) | 178.7 MiB | 89.2 MiB | 88.28 MiB | 0.67% | 21 |
| 25 | 1.054 s(0.754–1.144) | 188.9 MiB | 99.1 MiB | 98.29 MiB | 0.87% | 21 |
| 256 | 1.380 s(1.349–1.431) | 214.1 MiB | 119.7 MiB | 118.92 MiB | 1.12% | 21 |
25→256 的 GC 后堆增量摊到新增 workspace 约 91 KiB;这是本 fixture 的区间差值,不是适用于任何项目的固定成本。1→25 含多 workspace 路径的额外成本,三个点不能按统一线性函数外推。RSS 会随 V8 和 OS 回收变化;自然采样中的个别值明显低于轮内中位数,完整范围保存在 JSON。CPU 每档只有一次 80 秒窗口,不应把微小差值当作统计显著的性能回归。
未修改的原始 cap=25 bundle 另测 1/25,各一轮,GC 后堆为 88.19 / 98.19 MiB,与实验产物对应档接近。此对照用于确认实验放宽常量没有引入明显额外成本,不用于估计性能方差。
## 监听器和定时器
Git fixture 为每个目录执行隔离 Git 配置的 `git init` 和 empty commit,然后逐个请求 `GET /workspaces/{id}/git?wait=1`;没有工作文件、真实历史或文件修改事件风暴。
| 启动数 | 空目录 FSEVENTWRAP | 空目录 Timeout | 空目录 FD | Git 查询后 FSEVENTWRAP | Git 查询后 Timeout | Git 查询后 FD |
| ------ | ------------------ | -------------- | --------- | ---------------------- | ------------------ | ------------- |
| 1 | 1 | 14 | 21 | 2 | 14 | 22 |
| 25 | 25 | 85 | 21 | 50 | 86 | 46 |
| 256 | 256 | 779 | 21 | 512 | 778 | 277 |
源码确认,当前每个受信任的启动 workspace 会带来三个常驻 interval:session reaper 60 秒、cron keepalive 默认 10 分钟、ACP HTTP connection sweep 60 秒。总 Timeout 还包含全局及暂态 timer,不能把整个计数归因于 workspace。cron keepalive 即使无任务也会创建目录 watcher;Git reflog watcher 在首次 Git 查询时懒创建。FD 数和 watcher 数不是同一指标,空目录 FD 不增长不代表没有监听开销。
相关代码: [session reaper](../../packages/acp-bridge/src/bridge.ts#L2730)、[keepalive interval](../../packages/cli/src/serve/scheduled-task-keepalive.ts#L430)、[cron watcher](../../packages/cli/src/serve/scheduled-task-keepalive.ts#L454)、[keepalive 默认周期](../../packages/cli/src/serve/server.ts#L738)、[ACP connection sweep](../../packages/cli/src/serve/acp-http/connection-registry.ts#L1495)、[启动 ACP mounts](../../packages/cli/src/serve/acp-http/index.ts#L1466)、[Git watcher](../../packages/core/src/utils/gitDirect.ts#L257)。
## 固定一个已初始化 ACP child
在每档独立 daemon 的零 child 观测之后,对 primary 调用 `POST /workspace/runtime/ensure`,再稳定和采样。使用仅指向 loopback 哨兵服务的 dummy provider 配置;全部场景哨兵服务收到的模型请求为 0。ensure 返回 runtimeLive=true,MCP/Skills ready;session 数仍为 0。
| 注册数 | ensure 延迟 | daemon GC 后堆 | 一个 ACP child 的 RSS |
| ------ | ----------- | -------------- | --------------------- |
| 1 | 1.169 s | 88.9 MiB | 193.5 MiB |
| 25 | 1.186 s | 98.9 MiB | 190.9 MiB |
| 256 | 1.128 s | 119.8 MiB | 192.2 MiB |
每档一轮。这是 ACP 初始化延迟和空闲 child 的驻留成本,不是首个 prompt 延迟、正在执行的工作负载成本、7/25 个并发 child 成本或峰值 RSS。没有把 child RSS 与 daemon GC 后堆相加作为进程树 RSS。
## 动态注册边界和回收
全局 qwen 的有效 managed 版本 0.23.0 已独立复现:第 2–25 个动态注册均 HTTP 201,第 26 个 HTTP 409 `workspace_limit_reached`;此前及拒绝后均零 session、零 child,注册数仍为 25。全局入口的基础安装是 0.22.3,隔离 QWEN_HOME 会默认回退该版本,因此又经同一入口显式 pin 到已有 managed 0.23.0 重测;两个版本结果一致。不能将基础版本误写成当前有效版本。
实验产物从 primary=1 动态加到 256,255 次注册耗时合计约 1.245 秒,第 257 个 HTTP 409 `workspace_limit_reached`,拒绝后仍为 256。随后移除 255 个 secondary,回到 1;监听资源 256→1,Timeout 523→14,FD 保持 21,GC 后堆 111.46→89.93 MiB。该轮是诊断场景,没有用于主内存表。
动态注册后的 ACP mounts 实际只有 1;启动直接传入 256 个 workspace 时有 256 个 mounts。动态新增的 ACP mount 会在首次相应 ACP 访问时再创建,所以该动态场景少 255 个连接清理 interval;总 Timeout 差值中的额外 1 个属于全局/暂态波动。两条路径不能混算为“同样的 256 个完整 ACP mounts”。堆差异可能也受此影响,但未用 heap snapshot 定量归因。[动态注册发布](../../packages/cli/src/serve/routes/workspace-management.ts#L1160)、[ACP mount 懒创建](../../packages/cli/src/serve/acp-http/index.ts#L1509)。
另各执行五轮同一组 24 个 secondary 的注册/移除,primary 保留;Git 场景每轮先查询所有仓库 Git 状态,再移除。
| 移除后的轮次 | 空目录 GC 后堆 | 空目录 watcher / FD | Git 场景 GC 后堆 | Git 场景 watcher / FD |
| ------------ | -------------- | ------------------- | ---------------- | --------------------- |
| 1 | 90.11 MiB | 1 / 21 | 90.22 MiB | 2 / 22 |
| 2 | 90.14 MiB | 1 / 21 | 90.38 MiB | 2 / 22 |
| 3 | 90.42 MiB | 1 / 21 | 90.53 MiB | 2 / 22 |
| 4 | 90.31 MiB | 1 / 21 | 90.77 MiB | 2 / 22 |
| 5 | 90.41 MiB | 1 / 21 | 90.72 MiB | 2 / 22 |
移除后 Timeout 均为 13–14,session/child 均为 0。资源能够回落,没有出现按整组 runtime 成本持续累积的趋势;但未完全回到约 88 MiB 的初始堆。五轮小样本及不足 1 MiB 的后续变化不能证明“无内存泄漏”,也未覆盖持续使用不同 cwd 或长时运行。
## 已验证的副作用与未覆盖范围
启动 1/25/256 时,`maxTotalSessions` 分别为 null / 800 / 8192;child heap model 始终为 observe、maxConcurrentChildren=25,已计算 perChildCeilingMb=942 的模型值,但未执行 child 拒绝或将该模型值施加为 V8 heap ceiling。从 1 动态注册到 256 时,总 session 上限仍为 null。这说明 session admission 还依赖启动路径,注册扩容不能顺带默认放大运行容量。[默认推导](../../packages/cli/src/serve/run-qwen-serve.ts#L465)
扩容 PR 仍需处理 channel 事务最大规模与旧 SDK 超时、store 上限与旧版本降级读取、注册配置入口一致性,并在实际部署的 CPU/内存/FD/文件监听预算下验证。保留 25 的旧 timeout 常量只证明本实验没有改大它,不证明该超时足够覆盖未来 256 个 channel worker 的串行事务。
本次没有验证 Linux/Windows、低内存容器、真实大仓库、真实模型 prompt、MCP/extension 负载、enabled cron/channel、SSE/Web Terminal/UI 大列表、持久化 256 条后的重启/降级、完整 10 分钟 keepalive 周期或长期稳定性,也没有实现/验证 LRU 的收益。
27 次本地实验、43 个观测阶段均无异常退出、无模型请求;全部 HTTP 端口已释放。固定 child 场景在停止前记录后代 PID,退出后检查已知 PID 和进程组,均无残留;早期 smoke 的 child PID 另行复查已退出。构建、bundle、typecheck 通过;没有生产代码变更,不适用新增业务单测。
## 本地复现产物
实验脚本和原始完整 status/log 保存在本工作区的 git-ignored `.qwen/`,JSON 摘要保存各原始结果文件的 SHA-256。以下为从仓库根目录执行的入口;`cap256/dist` 须先按上述唯一变更生成,不能直接对生产 source 改共享常量。
```bash
python3 .qwen/scripts/11386-capacity-baseline.py --managed-active
node .qwen/scripts/11386-benchmark.mjs matrix
node .qwen/scripts/11386-benchmark.mjs warm
node .qwen/scripts/11386-benchmark.mjs idle
node .qwen/scripts/11386-benchmark.mjs diagnostic
node .qwen/scripts/11386-benchmark.mjs git-diagnostic
node .qwen/scripts/11386-benchmark.mjs churn
node .qwen/scripts/11386-benchmark.mjs git-churn
node .qwen/scripts/11386-benchmark.mjs boundary
node .qwen/scripts/11386-benchmark.mjs control
python3 .qwen/scripts/11386-summarize.py
```
本地脚本不是新 checkout 自动附带的公共 benchmark 工具。全局版本复现报告位于 `.qwen/issues/11386-capacity-baseline.md`;实验产物、manifest、逐轮 JSON/log 位于 `.qwen/experiments/11386/`。
@@ -0,0 +1,36 @@
# Workspace capacity constants: P0
This is the behavior-preserving first implementation for [#11386](https://github.com/QwenLM/qwen-code/issues/11386). The [capacity measurements](https://github.com/QwenLM/qwen-code/issues/11386#issuecomment-5586177947) support separating registration expansion from optional LRU work. P0 only separates the constants; it does not raise capacity.
## Current coupling
At `1a73f5bff6201473f237c5106367e944ba2d092b`, the ACP bridge exports `MAX_DAEMON_WORKSPACES = 25` from its public `channelControlTimeouts` subpath. The CLI aliases it as the registration cap, the child heap policy uses it as a modeled child-count ceiling, and the channel-control timeout multiplies it by worker startup/stop and rollback budgets. Changing that one value therefore changes three unrelated policies.
## Ownership and compatibility
| Policy | Owner | P0 definition and consumers |
| ---------------------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| User workspace registration | CLI `workspace-inputs.ts` | `MAX_REGISTERED_WORKSPACES = 25`; startup validation/merge, registration-store secondary limit, and management-route admission keep using it. Primary and managed scratch workspaces count; internal live-conversation runtimes retain their existing exemption. |
| Modeled concurrent ACP children | ACP bridge `child-heap-policy.ts` | Private `MAX_MODELED_ACP_CHILDREN = 25`; fixed-partition arithmetic uses this upper bound together with the existing pool/minimum and legacy-ceiling constraints. Small hosts can still model fewer children or zero. |
| Channel-control transaction budget | ACP bridge `channel-control-timeouts.ts` | Private `MAX_CHANNEL_CONTROL_WORKSPACES = 25`; the existing formula and worker deadlines continue to produce 2,130,000 ms. SDK consumers keep importing the existing timeout export. |
| Legacy public constant | Existing ACP bridge subpath | Preserve the exported `MAX_DAEMON_WORKSPACES = 25` as deprecated compatibility data. No internal policy reads it; no new public constants or package exports are required. |
The repeated value 25 is intentional: these values belong to independent policies. They must not be consolidated back into a common default. The compatibility export remains a literal rather than aliasing a new policy, so future changes to any one policy do not silently repurpose the old ambiguous export.
The child policy remains observation-only. P0 preserves modeled refusal accounting, zero/null semantics, fixed partition sizing, process reservations, actual child spawn arguments, and the lack of enforcement. Session admission defaults and channel worker execution are unchanged.
The metrics fan-out comment must describe its actual managed-runtime iteration without claiming that the user registration cap limits every bridge: internal conversation runtimes can exist outside that cap. No polling logic changes are needed.
## Consumers and affected files
Production edits are limited to the three constant owners and the stale metrics comment in `run-qwen-serve.ts`. Registration consumers are `run-qwen-serve.ts`, `workspace-registration-store.ts`, and `routes/workspace-management.ts`. The modeled count is consumed through `createChildHeapPolicy()` and its existing snapshot/observation wiring. All ten `CHANNEL_CONTROL_DEFAULT_TIMEOUT_MS` use sites in the TypeScript SDK retain the same import and value; worker startup/stop/kill timing constants also retain their values.
Tests cover the legacy export, the SDK's channel-operation timeout, existing registration/store boundaries, and child-model behavior. Coupling regression tests substitute a different legacy constant value and require registration and the child model to retain their independent defaults. This catches the original alias/import dependency rather than merely asserting that three constants happen to equal 25.
## Validation and scope
The baseline and implementation must both accept 25 user workspaces and reject the 26th with `workspace_limit_reached`, with zero sessions/children in the controlled fixture. Persisted secondaries remain capped at 24. A modeled large host remains capped at 25 children; constrained-host, off/observe, and refusal-counter tests retain their existing outcomes. Channel-operation defaults remain 2,130,000 ms, and caller overrides continue to win.
The executable test plan and results live in `.qwen/e2e-tests/workspace-capacity-decoupling.md`. Validation includes package-local focused tests, build, typecheck, bundle, and isolated local-daemon verification against a fresh global 0.23.0 baseline.
P1 separately decides registration expansion, session admission defaults, channel transaction/SDK compatibility, and store downgrade behavior. P0 adds no configuration, capability fields, routes, dormant state, LRU, child enforcement, or schema changes. There are no unresolved P0 product decisions.
+177
View File
@@ -0,0 +1,177 @@
# Workspace 注册扩容与运行时休眠调研
## 定位与结论
关联 [#11386](https://github.com/QwenLM/qwen-code/issues/11386)。本稿为 2026-09-08 的方案调研,核查基线为 `1a73f5bff6201473f237c5106367e944ba2d092b`(调研开始时的远端 main)。没有实现业务代码。随后已完成 1/25/256 空 workspace 的容量基线测量,见 [实测报告](workspace-capacity-baseline-2026-09-08.md);这不是 LRU 实现或真实模型负载验证。Issue 提及的同名草稿未在本 checkout 或当前已登记的本地 Git worktree 中找到;本文件是本次新稿。
后续 P0 实现见 [容量常量解耦设计](workspace-capacity-decoupling.md)。它保留默认上限 25,独立管理注册、child 观测模型和 channel 超时预算;下文的源码事实与实测数据仍保留上述调研基线,扩容和 LRU 仍属后续工作。
建议接受“注册与常驻资源解耦”的目标,但调整容量模型和交付顺序:
1. 分别管理注册条目、daemon 内常驻的 WorkspaceRuntime 聚合、实际 ACP 子进程,不能用一个 live 数量同时代表后两者。
2. 保持 child-heap 为独立的观测模型;内存硬准入继续由 #8182 负责。
3. 根据后续实测,25→256 的空宿主增量约为 21 MiB 保留堆,尚不足以把完整 LRU 作为扩容的硬前置。先拆分常量,再处理 session 默认准入、channel 事务超时和持久化兼容,单独验证注册扩容;懒加载/LRU 作为有目标部署数据支持时再实施的独立优化。
4. 若实施 LRU,首版只自动回收已无 session、无子进程、无后台责任和在途操作的宿主对象。更积极地关闭空闲 session、停止仍存活的 ACP 子进程,另行扩展 bridge 的安全回收协议。
5. 若实施懒加载,开启的定时任务和 channel 服务仍需要在启动时发现并恢复;“只有 primary 急切构建”不满足当前后台服务契约。
## 已核实的代码事实
以下引用均相对上述基线;它们是静态源码证据,不是运行或性能验证。
| 事实 | 源码依据 | 对方案的影响 |
| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
| 注册常量已有别名,但值仍来源于 ACP 包的 25 | [workspace-inputs.ts](../../packages/cli/src/serve/workspace-inputs.ts#L8)、[channel-control-timeouts.ts](../../packages/acp-bridge/src/channel-control-timeouts.ts#L7) | P0 是切断耦合,不只是改名 |
| child-heap 仅支持 `off \| observe`,spawn 路径忽略拒绝结果 | [child-heap-policy.ts](../../packages/acp-bridge/src/child-heap-policy.ts#L14)、[spawnChannel.ts](../../packages/acp-bridge/src/spawnChannel.ts#L479) | 当前没有可直接“接线”的 enforce 模式 |
| 512 MB 是 child 堆分区模型的下限;不足时容量为 0 | [child-heap-policy.ts](../../packages/acp-bridge/src/child-heap-policy.ts#L104) | 不能 clamp 到 1;该数字也不是 daemon 宿主对象的实测成本 |
| 一个 bridge 通常一个当前 channel,但旧 child 正在退出时可以有两个存活 child | [bridge.ts](../../packages/acp-bridge/src/bridge.ts#L14843) | runtime 数不能替代真实进程和在途 reservation 数 |
| 实际 child admission 已有同步 reservation 计数基础 | [process-registry.ts](../../packages/acp-bridge/src/process-registry.ts#L144)、[spawnChannel.ts](../../packages/acp-bridge/src/spawnChannel.ts#L469) | 将来 enforcement 应放在真实 spawn 点并保持退出期间的占用 |
| 启动仍遍历所有 secondary 构建 runtime | [run-qwen-serve.ts](../../packages/cli/src/serve/run-qwen-serve.ts#L5971) | 存在 O(注册数) 常驻成本;是否懒加载由测量和目标部署决定 |
| registry 的 entry 与 current generation 已分离,但尚无 dormant | [workspace-registry.ts](../../packages/cli/src/serve/workspace-registry.ts#L60)、[workspace-registry.ts](../../packages/cli/src/serve/workspace-registry.ts#L106) | 可扩展现有 registry,不必再建一套 workspace registry |
| 现有 runtime ensure 会启动 ACP 并准备 Skills/MCP,附带 10 分钟保活 | [workspace-runtime-coordinator.ts](../../packages/cli/src/serve/workspace-runtime-coordinator.ts#L20)、[workspace-runtime-coordinator.ts](../../packages/cli/src/serve/workspace-runtime-coordinator.ts#L174) | 宿主对象激活与 ACP ensure 必须分层,不得覆盖既有语义 |
| Conversation manager 有 single-flight,但没有普通 workspace 的唤醒和驱逐协议 | [conversation-runtime-manager.ts](../../packages/cli/src/serve/conversations/conversation-runtime-manager.ts#L50) | 复用模式,不把该 manager 当成通用生命周期实现 |
| 非 force removal 把任何 resident session 视为 busy;终态删除注册和 registry entry | [workspace-management.ts](../../packages/cli/src/serve/routes/workspace-management.ts#L1257)、[workspace-management.ts](../../packages/cli/src/serve/routes/workspace-management.ts#L1631) | 可借用 drain 顺序;不能直接调用删除路由完成休眠 |
| session reaper 检查订阅者、restore、权限/工作,并可能向 child 请求条件关闭 | [bridge.ts](../../packages/acp-bridge/src/bridge.ts#L3195) | session 恢复不证明整个 workspace teardown 无损 |
| runtime teardown 停止 cron keepalive、channel worker、子会话并释放 Web Terminal | [run-qwen-serve.ts](../../packages/cli/src/serve/run-qwen-serve.ts#L7166)、[run-qwen-serve.ts](../../packages/cli/src/serve/run-qwen-serve.ts#L7254) | issue 的 pinning 表还须覆盖 terminal、管理操作及在途 I/O |
| cron 在 session child 内触发;keepalive 也负责恢复和为 unbound task 建立绑定 | [scheduled-task-keepalive.ts](../../packages/cli/src/serve/scheduled-task-keepalive.ts#L7)、[scheduled-task-keepalive.ts](../../packages/cli/src/serve/scheduled-task-keepalive.ts#L75) | 冷启动只建 primary 会跳过 secondary 的后台任务恢复 |
| metrics refresh 已在无 live channel 时立即返回,且 bridge 内部 single-flight | [bridge.ts](../../packages/acp-bridge/src/bridge.ts#L6588) | 外层预过滤只是小优化,不是从 25 扩容的主要安全条件 |
| 默认全局 session 上限为每 workspace 上限乘启动 workspace 数 | [run-qwen-serve.ts](../../packages/cli/src/serve/run-qwen-serve.ts#L465) | 25→256 可把默认 800 放大到 8192,必须单独核对 |
## 三种容量
以下 resident 容量和休眠协议是后续可选 LRU 的设计约束,不表示本次测量已证明必须新增该准入层。当前可先独立拆分注册、child model 和 channel timeout 常量。
| 容量 | 建议语义 | 首版处理 |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `maxRegisteredWorkspaces` | 用户可见的 workspace identity 数,包含 primary;持久化 secondary、显式启动和动态注册按 canonical identity 去重计入 | 最终默认 256,CLI/env 可配置与否按 #9304/#9316 的诉求单独确定;若支持覆盖,所有入口使用同一个解析结果 |
| `maxResidentWorkspaces` | 已物化宿主对象,以及激活预留和尚未确认释放的宿主对象数 | 先用独立的 25 作为操作容量默认值,不宣称是内存安全值;可采用 issue 中 `--max-live-workspaces` 作为用户参数名,但文档须解释 resident 含义 |
| `maxConcurrentChildren` | 实际存活、正在创建、退出尚未确认完成的 ACP child 数 | 沿用独立 child-heap model;不随注册数增加,不在本需求里提前启用 enforcement |
Primary 和 internal runtime 只豁免自动驱逐,不能从资源统计中消失。它们物化时也应占 resident 配额;内置功能或后台责任导致全部名额不可回收时,返回明确容量错误,或要求运营者扩大配置。已知启动必须恢复的受保护集合超过配额时,应在启动阶段报出具体 workspace 和原因,避免随机丢失后台服务。
Issue 的 `floor(childPoolMb / 512)` 可以是独立 child 固定分区模型的输入,不能证明“这些宿主对象安全常驻”。将来 enforcement 还须检查 fixed ceiling 不低于 floor、不超过 legacy ceiling,并允许计算结果为 0;它约束的是 old generation ceiling,不等于整个进程树的 RSS。代表性 old-generation 峰值和 GC 成本验证仍是 [#8182](https://github.com/QwenLM/qwen-code/issues/8182) 的前置条件。
`maxTotalSessions` 继续是独立准入值。自动推导时应以明确的运行容量为基数,保留已有单 workspace 和显式无限值语义;不能把 256 条元数据默认换成 8192 个运行 session 名额。具体默认变更须在扩容 PR 中作为可见行为单独说明。
## 最小生命周期设计
继续使用 WorkspaceEntry 保存身份、注册关联、trust revision 和 generation;`current` 为空表示没有宿主对象。新增 dormant,激活复用 transitioning 和 generation 发布,不再创造另一个相似的 registry。
```mermaid
stateDiagram-v2
[*] --> dormant: 注册或恢复元数据
dormant --> transitioning: 激活预留成功
transitioning --> active: 校验当前策略并发布 generation
transitioning --> dormant: 可重试构建失败且清理完成
active --> draining: LRU 候选并关闭新操作准入
draining --> active: 最终检查发现工作
draining --> dormant: 确认释放后清空 current
draining --> blocked: 释放无法确认
dormant --> removed: 明确删除注册
```
图仅描述新增休眠路径;保留已有 trust replacement、blocked 恢复和删除流程。容量不足在改变 entry 状态前拒绝,不把正常 dormant 写成 blocked。清理失败保留占用和不可用状态,禁止在同一 cwd 再创建可能与旧资源重叠的 generation。
建议增加 daemon 内部的 `ensureWorkspaceHost(entry)`(内部名称待实现时确定)。它先确保宿主聚合存在,再由原 `WorkspaceRuntimeCoordinator.ensure()` 决定是否启动 ACP;无需新增第二套用户可见 ensure API。
激活算法:
1. 在 await 之前取得 canonical workspace 对应的 single-flight operation;与注册、删除、trust replacement、休眠及 daemon seal 协调。
2. 以同步 reservation 计入 `resident + activating + retiring`,不同 workspace 的并发激活不能各自看见同一个空位。无空位时选一个安全的 LRU 候选;没有候选就返回建议的 `503 workspace_capacity_exhausted` 和可重试标记,不排无限队列。
3. 构建前重新检查路径身份、trust、settings/env、runtime storage 根目录与最新 policy revision,沿用现有 workspace factory,不从 primary 补值。
4. 发布前复查 daemon seal、entry 身份及策略版本。删除、trust 降级或 shutdown 已发生时,销毁候选并释放 reservation,不能让迟到的 Promise 重新发布。
5. generation 单调增加;继续使用已有按 cwd 保存的 runtime epoch source,不能因 bridge 重建把 epoch 重置。
6. 调用方在操作执行期间持有 generation 使用计数,`finally` 释放;不能只在 resolver 返回瞬间更新 LRU,否则 await 中的文件写入、认证或进程执行可能被驱逐。
上述三个占用集合互斥:active 宿主转为 retiring 时移动同一个席位,成功发布把 activating 移到 resident,不能重复计算同一个 generation。请求取消只结束该调用方的等待;共享构建的其他调用方仍需结果,daemon shutdown 则关闭所有新准入并等待候选清理。
LRU 的排序只使用成功的交互或真实后台工作完成时间。capabilities、状态轮询、metrics 和历史浏览不续期、不激活宿主。优先在需要容量时回收一个候选;周期性 reaper 若增加,应复用同一个动作及串行门,不引入第二套驱逐逻辑。首版无需为已冷的空宿主增加复杂的压力分级。
## 保守回收与 pinning
首版自动回收以 `sessionCount === 0` 且 bridge lifecycle 为 cold 为必要条件,同时要求下面的所有责任为空。不是仅检查 `activePromptCount === 0` 或 `isChannelLive() === false`;后者遗漏 starting/stopping 和在途物理工作。
| 责任 | 回收约束 |
| ---------------------------------------------------------- | ------------------------------------------------------------------ |
| primary、已物化 internal | 禁止自动回收,照常计入资源统计 |
| 运行、排队 prompt;permission / question;restore / spawn | 禁止回收;先关闭准入再读最终快照 |
| session / workspace 事件订阅、ACP 连接 | 禁止回收;仅 HTTP 客户端计数不足以覆盖 SSE/WS |
| RuntimeCoordinator 的 Skills/MCP 队列、认证、preheat lease | 等物理工作和现有保活期结束;不缩短已有 10 分钟 ensure 承诺 |
| Web Terminal / PTY,包括断连后仍存在的终端 | 存活则 pin,避免休眠释放 shell 进程 |
| voice、子会话、memory lane、文件/Git/导出操作 | 对真实在途操作持有 generation 使用计数;未知计数不得视为 0 |
| channel worker、webhook 与 delivery | 首版有效 channel 服务 pin;启停/reload 参与同一准入和生命周期协调 |
| enabled cron | 首版 pin,并在冷启动时恢复其宿主;任务元数据读取失败不当成“无任务” |
首版等待现有 session reaper 安全关闭 session、现有 ACP idle reaper 释放 child,然后回收剩余宿主对象。这样能服务“大量从未使用或长期不用的注册 workspace”,但不保证在 25 个尚有 resident session 或后台服务的 workspace 之外立即获得第 26 个运行名额;这是明确的保守边界。
后续若要提前回收仍有空闲 session 的 workspace,应在 bridge 内提供整个 generation 的条件回收操作,复用 session close 的 child 确认、能力覆盖、restore 和订阅检查;不能由外部 reaper 读取几个布尔量后调用强制 shutdown。持久化 transcript、选定模型等恢复路径是可复用基础,但工具进程、连接、交互 ID、未持久化状态和事件订阅不因此自动恢复。
## Cron 与 channel 冷启动
启动分两步:先读取全部合法注册元数据,再用符合现有 trust 规则的轻量检查发现需要常驻的后台责任。Primary、enabled scheduled task 所属 workspace、当前选择的 channel owner 先获得名额并物化;其他 entry 保持 dormant。当前 keepalive 会给 eligible unbound task 建立 session 绑定,因此不能只检查 `task.sessionId` 是否已存在。
任务启用、channel 选择变更时,应先通过目标 workspace 的激活准入,再提交会依赖它运行的配置;现有配置持久化语义若要求先保存,必须明确返回“已保存但尚未运行”的状态,不能静默成功。切勿将 dormant workspace 从 channel 配置发现中直接过滤:当前 channel 分组只遍历 `registry.list()`,改变列表语义后可能遗漏原 owner 或将所有 channel 错归 primary。
不建议首版加入 `eviction-pending` 和 cron 强制逐出确认机制。显示 `pinnedReasons: [scheduled_tasks]` 已能解释名额占用,操作者可禁用任务。若未来保留 `POST /workspaces/:ws/evict`,默认只能回收安全候选;对仍 enabled 的任务,必须定义持久化禁用/暂停以及重启恢复契约。单次确认后让任务显示 enabled 却不再触发,不是透明休眠。
## 路由与客户端兼容
不能把 `resolveWorkspaceRuntimeFromParam()` 统一改成“所有访问自动 ensure”。当前同步 resolver 在非 active 时返回不可用;机械改法会让轮询和浏览 256 个 workspace 反复唤醒、驱逐。
| 所属边界 | 示例 | dormant 时行为 |
| ------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| process-global | capabilities、daemon status、全局 channel 控制 | 读取 entry 或当前实际宿主;不为聚合唤醒全部 workspace |
| legacy-primary | `/workspace/*` | 保持当前 primary scope 与不可用语义,不处理 secondary 唤醒 |
| persisted-workspace | 注册列表、session 列表/搜索、持久化 transcript、可离线读取的配置 | 使用受约束的轻量磁盘读取上下文;不依赖完整 bridge/service,也不启动 ACP |
| selected-runtime | workspace runtime ensure、创建/恢复 session、需要运行能力的管理操作、文件/Git 操作 | 先确保正确 workspace 的宿主,再执行现有行为;纯文件能力不因此预热 ACP |
| live-session-owner | SSE、prompt/cancel、permission 回复、既有 session attach | 精确路由现存 owner;过期 session 按原协议显式 restore 或返回原错误,不能靠 sessionId 猜 workspace |
当前受影响的明确入口包括:
- [workspace-route-runtime.ts](../../packages/cli/src/serve/workspace-route-runtime.ts#L170) 及使用其同步 resolver 的 qualified routes;[session.ts](../../packages/cli/src/serve/routes/session.ts#L1191) 的 catalog target、[live-state](../../packages/cli/src/serve/routes/session.ts#L8468) 和 persisted transcript 路由。
- [capabilities](../../packages/cli/src/serve/routes/capabilities.ts#L127) 当前把非 active entry 投影为 `trusted: false`;[WorkspaceSelector](../../packages/web-shell/client/components/WorkspaceSelector.tsx#L175) 据此禁用选择。必须单独投影最新 trust decision 与宿主 residency;dormant 不等于 untrusted。
- [workspace-management.ts](../../packages/cli/src/serve/routes/workspace-management.ts#L257) 用 `listManaged()` 校验身份和嵌套路径;所有注册准入、更新、删除、forget 和 alias 处理须检查所有 entry,加上在途注册,避免与 dormant cwd 重复注册或发生嵌套。
- [trust reconciler](../../packages/cli/src/serve/workspace-trust-reconciler.ts#L267) 对无 current 的条目目前只推进 configured revision;dormant 的能力投影和下次物化必须重新使用最新策略。
新增可选 capability 字段,例如 workspace 的 `residency`、limits 的 `maxRegisteredWorkspaces` / `maxResidentWorkspaces`,以及状态中的 resident、activating、retiring、dormant 和 pinned 原因。保留 `trusted` 的安全语义与原有 ACP lifecycle 五态,避免用一个 state 字段混合信任、宿主常驻和 child 冷热。
`POST /workspaces` 的成功边界改为注册成功:保留现有 identity、trusted、persisted 等字段,新增可选 residency;resident 席位已满也能返回 201 和 dormant。只有随后真正需要宿主的操作才可能遇到运行容量不足。保留 `persist: false` 的进程内注册语义;forget 只改变持久化意图,delete 移除注册身份,evict 只释放宿主。这是从当前“构建成功后才注册”到懒加载的 API 语义变化,须在 SDK、UI 和发布说明中一起表达,不能把注册成功宣称为运行能力已经 ready。
Dormant 的 live-state 可返回空 live sessions,但必须提供有效且跨宿主重建不冲突的 catalog version;不能固定返回 0。休眠/激活/持久化修改须触发现有 catalog invalidation 协议,使 `live A → full catalog → live B` 握手继续成立。
Channel timeout 应由服务器可执行的最大串行 worker 事务规模推导,并在 capabilities 下发。现有公式是 `2 × N × (12s + 30s) + 30s`:N=25 时为 35.5 分钟,直接换成 256 为约 359 分钟。只在 worker 目标和 rollback 集合都受 resident 容量约束后,才能直接用 resident 上限作为 N;否则需要更保守的事务上界,不能先缩短超时。
SDK 可参考现有 `cachedSessionRestoreTimeoutMs`,增加超时字段校验、缓存与旧服务端 fallback。控制 API 可能在 `capabilities()` 从未调用时直接执行,因此 mutation 前须有可用的已协商超时或保守 fallback。用户显式 timeout 优先。新服务端允许超过旧 25 上限时,旧 SDK 的固定超时不会自动变长,必须记录兼容限制;若需要完全支持旧 SDK,则另做可查询的异步操作协议,不夹带在 P0。
## 持久化与降级
Dormant 是进程内状态,不必写入现有 schemaVersion=1 的注册文件。身份、displayName 和 alias 继续持久化;恢复时重算 residency。轻量磁盘读取仍须得到目标 workspace 正确的 sessionRuntimeBaseDir / env overlay,不能为了省 runtime 就改用 primary 或默认全局路径。
Schema 不变不等于旧版本可读大文件:旧 [store parser](../../packages/cli/src/serve/workspace-registration-store.ts#L224) 拒绝超过 24 个 secondary;旧 [boot catch](../../packages/cli/src/serve/run-qwen-serve.ts#L3884) 会忽略整个持久化列表,只保留显式 workspace。发布文档必须给出备份与缩减到旧上限的降级步骤,或先交付只读兼容的 store reader;禁止截断覆盖用户的大列表。注册数、文件字节上限、displayName/路径最大长度也须一起核查。
## 建议交付顺序
| PR 阶段 | 内容 | 可独立验证的边界 |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| P0:拆分语义 | 断开注册、child model、channel timeout 三者共享常量的关系;默认数值仍为 25;核对所有读取点 | 不增加 runtime 数、不更改 child heap 参数、channel 超时和默认 session admission |
| P1:注册扩容 | 单独处理注册上限与配置入口、默认 session admission、channel 事务上界和 SDK 协商、store/降级兼容,并在目标部署验证 | 可以不引入 dormant/LRU;不能仅凭本机空目录数据宣称 256 已适合所有主机和负载 |
| P2:可选懒加载与冷宿主回收 | 在实际宿主成本或启动时延成为问题时,实施 entry-only 注册、dormant、单飞激活、reservation、保守 LRU、路由/UI 和后台责任恢复 | 同一请求按需激活;列表与状态不会激活;正在使用的宿主不被回收;按实测成本决定 resident 容量 |
| P3:更积极的回收,可选 | bridge 条件关闭空闲 session/child,精确 lease/pin 协议和逐出恢复验证 | 扩大可回收集合,不切断未决工作和连接 |
| 独立 #8182 | 经代表性工作负载校准后启用 fixed partition 与进程准入 | active + in-flight + terminating 计数和 heap/RSS 边界分别验证 |
这调整了最初“先完整 LRU、再扩到 256”的建议。实测目前支持先交付较小的解耦和注册扩容工作,但没有验证默认 256 的全部兼容性,也没有证明 LRU 无价值。若后续实施 LRU,仍应按本稿的生命周期约束拆成可审查的增量,避免保留两套 runtime registry。
## 验证计划与未决项
以下是未来 LRU/扩容实现的验收条件;本次仅执行了另附报告中的空目录、单 child 和资源回收基线,不等于下列行为已验证:
1. 注册 256 个目录后重启,核对数量/alias/displayName、不活跃 workspace 的列表和历史;第 257 个在所有入口一致拒绝。验证超大 store 不被旧版本覆盖。
2. 同 workspace 并发首次访问只构建一次;多个 workspace 竞争最后一席只允许一个 reservation 成功;旧资源退出未确认时不能释放席位。
3. 激活与删除、forget、trust 降级、配置更新、路径替换、daemon shutdown 交错,迟到构建不复活,generation 和 epoch 不倒退。
4. 保持一个 SSE、Web Terminal、MCP 刷新、voice、memory job、子会话或文件写入,分别确认不会休眠;unknown activity 保守阻止回收。
5. enabled cron 和 channel 的 secondary 在重启后无需打开 UI 也能恢复服务;任务禁用后才释放 pin;后台集合超过容量时得到明确诊断。
6. Web Shell 浏览/轮询 256 个 workspace 不触发一轮激活风暴;dormant 仍可选择,历史/搜索可用,激活后 catalog 正确刷新。
7. 运行 session 正常结束且无订阅后,沿用既有 reaper 等到 cold,再执行宿主休眠;restore 验证 transcript、模型选择、工作区环境及新 epoch。
8. SDK 显式超时、未先读取 capabilities、新 SDK/旧 daemon、旧 SDK/新 daemon,以及 channel 替换失败 rollback 分别验证。
9. 对比 1/25/256 注册数量的启动耗时、root heap/RSS、watcher/FD/timer 数、空闲 CPU、冷激活延迟与重复 churn 后的资源回落;同时记录固定 1/7/25 个活动 child,避免把“注册成本”与“运行成本”混在一起。
仍需数据才能决定的项目:25 个 resident 是否适合作为跨环境默认值、冷激活延迟能否接受、UI 大列表/历史扫描是否需要单独分页优化、真实 workloads 在 child 固定分区下的 fit margin/GC 成本。本稿不把这些未知项作为已证实的性能收益或内存安全保证。
+1 -3
View File
@@ -23,7 +23,5 @@ export default {
'qwen-serve-protocol': 'qwen serve HTTP protocol',
daemon: 'Daemon Mode (Developer Deep Dive)',
examples: {
display: 'hidden',
},
examples: 'Examples',
};
-2
View File
@@ -201,8 +201,6 @@ protected override onPromptEnd(chatId: string, sessionId: string, messageId?: st
**Streaming hooks** — override `onResponseChunk(chatId, chunk, sessionId, segment)` for per-chunk progressive display (e.g., editing a message in-place). Override `onResponseComplete(chatId, fullText, sessionId, segment)` to customize final delivery. In daemon-managed named-task mode, `segment.sourceLabel` is immutable delivery metadata for that segment. Render it once on each independently visible message or card, including a separately visible final response, but do not add it to raw buffers or model text. Clear adapter-owned segment state from `onOutputSegmentEnd()`.
**Block streaming** — set `blockStreaming: "on"` in the channel config. The base class automatically splits responses into multiple messages at paragraph boundaries. No plugin code needed — it works alongside `onResponseChunk`.
**Named-task attribution** — `sendThreadMessage(chatId, threadId, text, sourceLabel)` receives the same optional plain-text label for one-shot and proactive delivery boundaries. The default implementation handles plain messages. Adapters that override delivery, split messages, emit cards, or provide fallback sends must repeat the label at every independently visible boundary, escape only the label for the target markup dialect, and include its rendered size in platform limits. Run no-reply checks, media-marker projection, audit hashing, transcript persistence, and retry-body capture against the raw response before presentation; if delivery is persisted for restart-safe retry, persist the captured label separately.
Interactive `ChannelUserInputRequestContext` also carries `sourceLabel`. Cards, terminal replacements, and plain fallbacks must retain it without weakening the existing request, session, run, owner, and target checks.
+7 -7
View File
@@ -58,7 +58,7 @@ Pick the path that matches your goal:
- [`17-configuration.md`](./17-configuration.md) - env vars, CLI flags, `settings.json` keys that affect the daemon.
- [`18-error-taxonomy.md`](./18-error-taxonomy.md) - typed errors per layer with remediation.
- [`19-observability.md`](./19-observability.md) - `QWEN_SERVE_DEBUG`, debugging recipes, telemetry gaps.
- [`20-quickstart-operations.md`](./20-quickstart-operations.md) - shortest startup path, curl checks, route map, and embedded invocation recipes.
- [`20-quickstart-operations.md`](./20-quickstart-operations.md) - shortest startup path, curl checks, route map, and the embedding boundary (in-process hosting is internal; external integrations use `qwen serve --no-web` plus the HTTP/SSE protocol). The internal `createServeApp` lifecycle recipe lives in [`02-serve-runtime.md`](./02-serve-runtime.md).
## Glossary
@@ -141,12 +141,12 @@ Use these anchors when moving from the docs into the latest `main` code:
### Reference and operations
| Area | Current state | Primary docs |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
| Configuration | Full `qwen serve` flags, env vars, `settings.json`, `ServeOptions`, `BridgeOptions`, and important constants are collected in one page. | [`17`](./17-configuration.md) |
| Quickstart / operations | Shortest startup path, launch recipes, curl checks, Web Shell auth behavior, route split, shutdown behavior, and embedded invocation recipes are covered. | [`20`](./20-quickstart-operations.md) |
| Errors | Boot-time explicit failures, route errors, bridge errors, EventBus errors, filesystem errors, and mediator errors are summarized with remediation. | [`18`](./18-error-taxonomy.md) |
| Observability | `QWEN_SERVE_DEBUG`, curl recipes, useful events, telemetry gaps, and investigation checklists are documented. | [`19`](./19-observability.md) |
| Area | Current state | Primary docs |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
| Configuration | Full `qwen serve` flags, env vars, `settings.json`, `ServeOptions`, `BridgeOptions`, and important constants are collected in one page. | [`17`](./17-configuration.md) |
| Quickstart / operations | Shortest startup path, launch recipes, curl checks, Web Shell auth behavior, route split, shutdown behavior, and the embedding boundary are covered. The internal `createServeApp` lifecycle recipe lives in [`02`](./02-serve-runtime.md). | [`20`](./20-quickstart-operations.md) |
| Errors | Boot-time explicit failures, route errors, bridge errors, EventBus errors, filesystem errors, and mediator errors are summarized with remediation. | [`18`](./18-error-taxonomy.md) |
| Observability | `QWEN_SERVE_DEBUG`, curl recipes, useful events, telemetry gaps, and investigation checklists are documented. | [`19`](./19-observability.md) |
### Historical or deprecated surfaces
+6 -3
View File
@@ -147,16 +147,19 @@ Three trust boundaries matter: the HTTP edge (`serve/auth.ts` middleware chain),
sequenceDiagram
autonumber
participant C as Client (SDK)
participant MW as Middleware<br/>(CORS→host→log→bearer→rate-limit→JSON→telemetry→mutationGate)
participant MW as Middleware<br/>(origin-strip→log→trace-id→host→same-origin→CORS→bearer→rate-limit→JSON→telemetry→mutationGate)
participant R as Route handler
participant BR as AcpBridge
participant BC as BridgeClient
participant CH as ACP child
C->>MW: POST /session/:id/prompt<br/>Authorization: Bearer …<br/>X-Qwen-Client-Id: …
MW->>MW: allowOriginCors (mutable allowlist; unmatched Origin -> 403)
MW->>MW: loopback same-origin Origin strip
MW->>MW: access-log hook (skips GET /health and POST */heartbeat)
MW->>MW: inbound trace-id capture
MW->>MW: hostAllowlist (DNS rebinding guard)
MW->>MW: access-log hook
MW->>MW: remote same-origin Origin strip + bearer check (non-loopback with a token)
MW->>MW: allowOriginCors (mutable allowlist; unmatched Origin -> 403)
MW->>MW: bearerAuth (constant-time compare)
MW->>MW: rateLimit (when enabled)
MW->>MW: express.json body parser
+41 -20
View File
@@ -8,9 +8,10 @@
- Parse and validate `ServeOptions`: listen address, auth, workspace, session / connection caps, MCP budget / pool, CORS, prompt / SSE / session idle timeouts, rate limit, and related toggles.
- **Canonicalize** the primary workspace exactly once, and canonicalize every repeated `--workspace` before registering session runtimes. The primary canonical form is shared by `/capabilities.workspaceCwd`, the `POST /session` fallback, and the primary bridge.
- Reject unsafe or invalid startup configurations: non-loopback bind without token, `--require-auth` without token, wildcard or non-loopback HTTP(S) `--allow-origin` without token, `mcpBudgetMode='enforce'` without a positive `mcpClientBudget`, a nonexistent or non-directory `--workspace`, and invalid timeout or rate-limit values.
- Resolve the bearer: `--token`, then `QWEN_SERVER_TOKEN`, then — where neither source is present and the requested `--hostname` is non-loopback (the literal `localhost` resolved once first) — a generated ephemeral 128-bit base64url bearer (22 characters) printed once at startup. Loopback spellings never generate and keep the trusted token-less mode unless `--require-auth` is set. Generation keys on the spelling while the boot refusal reads the resolved address, which leaves two carve-outs: a `localhost` that resolves off-loopback never generates, and boots only when a token source resolved (`Refusing to bind …` otherwise); and a non-literal name that resolves to loopback does generate, losing trusted token-less mode so its bearer prints token-only.
- Reject unsafe or invalid startup configurations: a non-loopback bind whose token source is explicitly empty, `--require-auth` on a token-less loopback bind, wildcard or non-loopback HTTP(S) `--allow-origin` on a token-less loopback bind, `mcpBudgetMode='enforce'` without a positive `mcpClientBudget`, a nonexistent or non-directory `--workspace`, and invalid timeout or rate-limit values.
- Construct the `WorkspaceFileSystem` factory, permission audit publisher, `DaemonStatusProvider`, and `acp-bridge`.
- Build the Express app, wire middleware (`allowOriginCors` over the mutable origin allowlist -> `hostAllowlist` -> access log -> `bearerAuth` -> rate limit -> JSON parser -> telemetry -> per-route `mutationGate`), and mount session, workspace CRUD, file, device-flow auth, permission vote, and ACP HTTP routes. (The unconditional `denyBrowserOriginCors` wall remains only in the bootstrap app, `run-qwen-serve.ts`.)
- Build the Express app, wire middleware (loopback `Origin` strip -> access log -> inbound trace-id capture -> `hostAllowlist` -> remote same-origin `Origin` strip -> `allowOriginCors` over the mutable origin allowlist -> pre-auth `/health` -> pre-auth Web Shell assets -> channel webhooks -> `bearerAuth` -> rate limit -> JSON parser -> telemetry -> per-route `mutationGate`), and mount session, workspace CRUD, file, device-flow auth, permission vote, and ACP HTTP routes. (The unconditional `denyBrowserOriginCors` wall remains only in the bootstrap app, `run-qwen-serve.ts`.)
- Bind the listening port and register signal handlers.
- Run two-phase shutdown on SIGINT/SIGTERM; force-exit on a second signal.
@@ -22,18 +23,26 @@
**Capability registry**: `SERVE_CAPABILITY_REGISTRY` in `packages/cli/src/serve/capabilities.ts`. Each tag has a `since` version and optional `modes`. Conditional tags are omitted when their deployment or runtime predicate is false; the registry and predicate map are the source of truth. See [`11-capabilities-versioning.md`](./11-capabilities-versioning.md).
**Middleware** (`packages/cli/src/serve/auth.ts` and `server.ts`):
**Middleware** (`packages/cli/src/serve/auth.ts`, `server.ts`, `server/self-origin.ts`, and `server/access-log.ts`):
| Middleware, in registration order | Purpose | Notes |
| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `allowOriginCors` | Always installed on the runtime app over a `MutableOriginAllowlist`: `--allow-origin <pattern>` entries seed it, Local Control adds the LAN origin while enabled; unmatched origins get the 403 deny envelope. | See [`12-auth-security.md`](./12-auth-security.md). |
| `hostAllowlist(bind, getPort)` | On loopback, validate `Host` belongs to `localhost`, `127.0.0.1`, `[::1]`, `host.docker.internal`, or the exact bound loopback address, plus the actual port; port-less forms are accepted on ports 80 and 443. | Defense against DNS rebinding. Comparison is case-insensitive and cached per port. The Local Control LAN listener always enforces its advertised-authority Host check, whatever the primary bind is. |
| Access-log middleware | Records method, path, status, durationMs, sessionId, and clientId to `DaemonLogger` when a request finishes. | Registered **before** `bearerAuth`, so 401 denials are logged too. Skips `/health` and heartbeat. |
| `bearerAuth(token)` | SHA-256 plus `timingSafeEqual` constant-time bearer comparison. | Open passthrough when no token is configured (loopback dev default). `Bearer` scheme is case-insensitive. |
| Rate-limit middleware | Optional per-tier token bucket for prompt, mutation, and read routes. | Registered after `bearerAuth` and before JSON parsing; returns 429 before parsing when a bucket is exhausted. |
| `express.json({ limit: '10mb' })` | JSON body parsing. | Parse errors return 400. |
| `daemonTelemetryMiddleware` | Wraps classified daemon API requests that reach this point in an OpenTelemetry span through `withDaemonRequestSpan`. | Attributes include canonical route, resolved workspace hash, sessionId, clientId, and status code. Earlier auth, rate-limit, and body-parser rejections are outside this span boundary. |
| `createMutationGate` (per-route) | Route-level opt-in gate for mutations that require operator authority. Trusted primary-listener requests, bearer-authenticated requests, and paired Local Control requests qualify. | A token-less primary request that reaches the strict gate without trusted-loopback authority returns `401 { code: 'token_required' }`. Missing or invalid configured credentials are rejected earlier by bearer middleware with plain `401 Unauthorized`. Not global `app.use`; routes call `mutate({ strict: true })` as needed. |
| Middleware, in registration order | Purpose | Notes |
| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `installSelfOriginStripMiddleware` | Deletes an `Origin` header that matches a loopback same-origin value for the bound port, so the loopback Web Shell's own POST/fetch calls are never treated as cross-origin. | First middleware on the runtime app. Matches both schemes and the bound loopback host, and omits scheme-default ports per RFC 7230 §5.4. |
| Access-log middleware | Records method, path, status, durationMs, sessionId, and clientId to `DaemonLogger` when a request finishes. | Registered ahead of every gate, so `401` / `403` / `429` short-circuits are logged too. Exempt **by exact path**: `GET /health` and `POST */heartbeat`, so those liveness probes are never logged — including when a gate below rejects them (`HEAD /health` and `GET /health/` are logged like any request). Successful `GET */events` streams are dropped as well. Burst-limited to 60 lines refilling at 2/s; overflow coalesces into an `access logs suppressed` warning. Pre-authentication gate rejects (Host allowlist, the CORS wall, the remote same-origin credential check) draw from a separate 30/1-s budget, so a credential-less flood of those rejects cannot starve the operator's own lines behind that warning; `bearerAuth` 401s (a no-Origin flood) are not marked and still charge the operator budget — unchanged from before the reorder. |
| Inbound trace-id capture | Captures the caller's `traceparent` trace id before any gate can short-circuit. | Lets the access log join a `401` / `429` / `400` / `404` line to the caller's trace in telemetry-off deployments, where it is the only such link. |
| `hostAllowlist(bind, getPort)` | On loopback, validate `Host` belongs to `localhost`, `127.0.0.1`, `[::1]`, `host.docker.internal`, or the exact bound loopback address, plus the actual port; port-less forms are accepted on ports 80 and 443. | Defense against DNS rebinding; it therefore also covers the pre-auth `/health` routes below it. Comparison is case-insensitive and cached per port. A deliberate no-op on non-loopback binds, where the bearer is the authentication layer. The Local Control LAN listener always enforces its advertised-authority Host check, whatever the primary bind is. |
| `installRemoteSelfOriginMiddleware` | On a non-loopback primary listener that has a token, bearer-authenticates a request whose `Origin` equals the direct socket scheme plus the normalized `Host`, then deletes that `Origin`. | This is what lets the built-in Web Shell's same-origin HTTP mutations through without `--allow-origin`. No-op on loopback binds and when no token is configured. Pre-auth Web Shell routes (`/`, `//`, `/assets*`, `/mcp-app-sandbox`, exact `/session/:id` document navigations) skip the credential check. Forwarded headers are never consulted. |
| `allowOriginCors` | Always installed on the runtime app over a `MutableOriginAllowlist`: `--allow-origin <pattern>` entries seed it, Local Control adds the LAN origin while enabled; unmatched origins get the 403 deny envelope. | See [`12-auth-security.md`](./12-auth-security.md). Its rejects are logged by the access log above, except on the health/heartbeat exemptions. |
| Pre-auth `/health` | Liveness route registered ahead of `bearerAuth` on an ordinary loopback bind. | Dropped under `--require-auth`, and never registered pre-auth on a non-loopback bind; those cases register `/health` after `bearerAuth` instead. A Local Control listener authenticates its own `/health` even in the pre-auth position. |
| Web Shell static assets and MCP App sandbox | `/`, `/assets*`, `/mcp-app-sandbox`, and exact `/session/:id` document navigations, mounted ahead of `bearerAuth`. | A browser cannot attach `Authorization` to a navigation or a `<script src>` subresource, and the static shell carries no secrets. The SPA deep-link fallback is registered after all API routes instead. `--no-web` opts out. |
| Channel webhook routes | `POST /channels/:channelName/webhooks/:source`, registered ahead of `bearerAuth`. | Authenticates with its own `x-qwen-webhook-secret`; rotating the daemon bearer does not rotate webhook secrets. |
| `bearerAuth(token)` | SHA-256 plus `timingSafeEqual` constant-time bearer comparison. | Open passthrough when no token is configured (loopback dev default). `Bearer` scheme is case-insensitive. |
| Rate-limit middleware | Optional per-tier token bucket for prompt, mutation, and read routes. | Registered after `bearerAuth` and before JSON parsing, so only authenticated requests are counted; returns 429 before parsing when a bucket is exhausted. Webhook routes use their own shared-secret gate instead. |
| `express.json({ limit: '10mb' })` | JSON body parsing. | Parse errors return 400. |
| `daemonTelemetryMiddleware` | Wraps classified daemon API requests that reach this point in an OpenTelemetry span through `withDaemonRequestSpan`. | Attributes include canonical route, resolved workspace hash, sessionId, clientId, and status code. Earlier auth, rate-limit, and body-parser rejections are outside this span boundary. |
| `createMutationGate` (per-route) | Route-level opt-in gate for mutations that require operator authority. Trusted primary-listener requests, bearer-authenticated requests, and paired Local Control requests qualify. | A token-less primary request that reaches the strict gate without trusted-loopback authority returns `401 { code: 'token_required' }`. Missing or invalid configured credentials are rejected earlier by bearer middleware with plain `401 Unauthorized`. Not global `app.use`; routes call `mutate({ strict: true })` as needed. |
The **bootstrap app** that answers requests during the cold window (`createBootstrapServeApp` in `run-qwen-serve.ts`) runs a shorter chain in this order: loopback `Origin` strip -> `hostAllowlist` -> remote same-origin `Origin` strip -> the CORS wall (`allowOriginCors` when `--allow-origin` is set, otherwise the unconditional `denyBrowserOriginCors`) -> pre-auth `/health` on an ordinary loopback bind -> `bearerAuth` -> the gated `/health`, `/capabilities`, and `/daemon/status` routes. It installs no access log, so only the requests it answers itself — `/health`, `/capabilities`, and `/daemon/status` — are not logged. The delegating wrapper (`createDelegatingServeApp`) sits in front with its own bearer gate: any other cold-window path starts the runtime and is dispatched to the runtime app, which does log it, with `durationMs` measured from that hand-off rather than the client-visible start. With `--open` the runtime app answers directly, so there is no cold window at all.
**Subsystems**:
@@ -61,18 +70,30 @@
### Boot sequence
Before `runQwenServe()` starts this sequence, the CLI-only `--open-with-auth` mode validates loopback/Web Shell eligibility and fills `ServeOptions.token` with the selected configured token, or with 32 random bytes encoded as base64url when that selection is empty. Direct embedders and invocations without that default-off flag do not generate a token.
Before `runQwenServe()` starts this sequence, the CLI-only `--open-with-auth` mode validates loopback/Web Shell eligibility and fills `ServeOptions.token` with the selected configured token, or with 32 random bytes (a 256-bit bearer) encoded as base64url when that selection is empty. That generated value is an ordinary configured token as far as every step below is concerned — which is why `--require-auth --open-with-auth` boots — and it is a separate generator from the non-loopback ephemeral bearer in step 1. Direct embedders that call `createServeApp` themselves never generate a token.
1. **Resolve and trim token** from `opts.token` or `QWEN_SERVER_TOKEN`; this
avoids a trailing newline from `cat token.txt` silently breaking bearer
comparison.
1. **Resolve the token** from `opts.token` or `QWEN_SERVER_TOKEN`, trimmed so a
trailing newline from `cat token.txt` cannot silently break bearer
comparison. When the requested `--hostname` is non-loopback (the literal
`localhost` resolved once first) and **neither** source is present,
generate an ephemeral 128-bit (16-byte) bearer as 22 base64url characters
instead of refusing; it is printed once by the remote quickstart after
`listen()` and rotates on every restart. Loopback spellings never
generate, so they keep the trusted token-less mode. An **explicitly blank** source
(`--token ''`, or `QWEN_SERVER_TOKEN` set to an empty or whitespace-only
value) is not "absent", so it always suppresses generation — but blankness
decides the resolved token in one direction only: a blank `--token`
shadows a set env value and resolves to no token, whereas a blank env
resolves to no token only when `--token` is not passed (a non-blank
`--token` still wins); in both shapes a non-loopback bind with no
resolved token still fails the guards below.
2. **Hostname typo guard**: `--hostname localhost:4170` errors and suggests `--port`.
3. **Auth preflight**: non-loopback without token refuses; `--require-auth` without token refuses.
3. **Auth preflight**: a non-loopback bind with no _resolved_ token refuses — reachable through an explicitly empty source, or through a `localhost` bind whose one-time resolution lands off-loopback (generation keys on the spelling, so nothing was generated there); `--require-auth` refuses on a token-less bind, which after step 1 means a loopback bind with no configured source. Wildcard and non-loopback HTTP(S) `--allow-origin` guards read the same resolved token, so on a non-loopback bind the generated bearer satisfies them and those refusals are loopback-only too.
4. **Workspace validation**: absolute path, exists, directory. `EACCES` / `EPERM` are wrapped to point at the flag.
5. **Canonicalize workspace**: `canonicalizeWorkspace(rawWorkspace)` runs `realpathSync.native` once and feeds `/capabilities`, the `POST /session` fallback, and the bridge.
6. **MCP budget validation**: positive integer; `enforce` requires a budget.
7. **MCP pool toggle inference**: parent env `QWEN_SERVE_NO_MCP_POOL=1` makes `mcpPoolActive=false`, so capabilities honestly omit `mcp_workspace_pool` and `mcp_pool_restart`.
8. **CORS / timeout / rate-limit validation**: wildcard and non-loopback HTTP(S) `--allow-origin` values require a token; prompt, writer, channel idle, session idle, reaper, and rate-limit window values fail fast when invalid.
8. **CORS / timeout / rate-limit validation**: wildcard and non-loopback HTTP(S) `--allow-origin` values require a resolved token (see step 3 for why those refusals are loopback-only); prompt, writer, channel idle, session idle, reaper, and rate-limit window values fail fast when invalid.
9. **Per-handle `childEnvOverrides`**: pass `QWEN_SERVE_MCP_CLIENT_BUDGET` and `QWEN_SERVE_MCP_BUDGET_MODE` to the ACP child through `BridgeOptions.childEnvOverrides` instead of mutating `process.env`.
10. **Load `settings.json` once**: read `context.fileName`, `policy.permissionStrategy`, and `policy.consensusQuorum`. Corrupt files fall back to defaults. `validatePolicyConfig()` checks `policy.*` against `SERVE_CAPABILITY_REGISTRY.permission_mediation.modes`; unknown strategies or non-positive `consensusQuorum` throw `InvalidPolicyConfigError`. A quorum set under a non-`consensus` strategy logs a stderr warning.
11. **Allocate `PermissionAuditRing`** (512 entries).
@@ -143,7 +164,7 @@ See [`17-configuration.md`](./17-configuration.md) for the merged reference.
## Caveats and known limits
- Direct `createServeApp` without `deps.fsFactory` or `deps.bridge` defaults to `trusted: false`; agent-side ACP `writeTextFile` rejects as `untrusted_workspace`. The warning is printed once.
- The runtime app runs `allowOriginCors` over the mutable allowlist; unmatched `Origin` values get the 403 deny envelope (the unconditional `denyBrowserOriginCors` wall survives only in the bootstrap app). The **loopback** Web Shell works because another middleware strips matching loopback same-origin values first — non-loopback binds require `--allow-origin` for the shell's XHRs.
- The runtime app runs `allowOriginCors` over the mutable allowlist; unmatched `Origin` values get the 403 deny envelope (the unconditional `denyBrowserOriginCors` wall survives only in the bootstrap app). The **loopback** Web Shell works because another middleware strips matching loopback same-origin values first; on a non-loopback bind with a token the shell's same-origin XHRs are bearer-authenticated and their `Origin` stripped ahead of the wall, so they need no `--allow-origin`. Three cases still require an allowlist entry: WebSocket upgrades (terminal, voice), a TLS-terminating front proxy whose `https` origin never matches the plain socket, and any plain-HTTP intermediary that rewrites the `Host` header — nginx's default `proxy_set_header Host $proxy_host` and k8s Ingress both do. Port translation alone needs nothing **on a non-loopback bind** (`docker -p 8080:4170`): the check compares `Origin` against the normalized forwarded `Host` only and never consults the listening port (just the scheme-default `:80`/`:443` are stripped; a non-default port must survive verbatim in it). On the default **loopback** bind it does not: the DNS-rebinding Host allowlist only accepts the daemon's own port, so a port-translating tunnel (`ssh -L 8080:localhost:4170`) is rejected with `403 Invalid Host header` for every request including the shell document, and `--allow-origin` cannot override it — forward the same port or bind non-loopback. The remedy for the WebSocket and TLS-terminating cases is `--allow-origin <origin>`; a Host-rewriting intermediary can instead be configured to forward `Host` verbatim — which cannot help once TLS terminates at the proxy, because the scheme is read from the daemon's own socket.
- Body-parser ordering: routes using `mutate({ strict: true })` return 401 only after `express.json()`. The worst case is `--max-connections × express.json({limit: '10mb'})`, up to about 2.5 GB of transient memory on a saturated loopback listener; this tradeoff is intentional.
- Multiple daemons in one process must use per-handle `childEnvOverrides`; mutating `process.env` races because `defaultSpawnChannelFactory` snapshots env at spawn time.
+58 -19
View File
@@ -4,7 +4,7 @@
`qwen serve` is a local daemon by default and an exposed surface in the wrong configuration. Its security model is **layered** so that misconfiguration fails closed:
1. **Bind** — non-loopback bind without a bearer token **refuses to start**.
1. **Bind** — a non-loopback bind always carries a bearer: the operator's, or an ephemeral 128-bit one generated and printed once at startup. Boot refuses only when a supplied token source is explicitly empty/whitespace, or when a requested `localhost` resolves off-loopback with no token source resolved (which never generates).
2. **Bearer auth** — `bearerAuth` middleware with constant-time SHA-256 compare protects normal API routes except `/health` on an ordinary loopback bind (`require_auth` moves that endpoint behind the bearer too). Channel webhook ingress is a separate pre-bearer route authenticated by `x-qwen-webhook-secret`. Web Shell document and asset routes remain pre-auth in every mode.
3. **Host header allowlist** — on loopback, only `localhost`, `127.0.0.1`, `[::1]`, `host.docker.internal`, or the exact bound loopback address (plus port) are accepted; the corresponding port-less forms are also accepted when listening on 80 or 443. The allowlist defends against DNS rebinding. The Local Control LAN listener is the exception that always enforces its advertised-authority Host check, whatever the primary bind is.
4. **Origin control** — the runtime app always installs `allowOriginCors` over a mutable allowlist (`MutableOriginAllowlist`): the `--allow-origin <pattern>` entries seed it, and Local Control adds the LAN origin while enabled. Non-matching origins receive the 403 deny envelope. The unconditional deny wall (`denyBrowserOriginCors`) survives only in the bootstrap app that answers before the runtime starts.
@@ -27,6 +27,15 @@ This doc walks through each layer and the explicit invariants the boot path enfo
In `run-qwen-serve.ts`:
```ts
// A non-loopback bind with neither --token nor QWEN_SERVER_TOKEN first
// generates an ephemeral 128-bit base64url bearer (16 random bytes, 22
// URL-safe characters; printed once at startup, rotated per process). The
// first refusal below therefore fires only when a token source was supplied
// but is explicitly empty/whitespace, or when the requested hostname resolves
// off-loopback (localhost pinned to a non-loopback address never generates).
// Generation is loopback-suppressed, so the second refusal is a loopback-only
// fail-fast: on a non-loopback bind the generated token already satisfies
// --require-auth.
if (!isLoopbackBind(opts.hostname) && !token) {
throw new Error('Refusing to bind <host>:<port> without a bearer token. ...');
}
@@ -64,11 +73,14 @@ daemon bind beyond loopback in the open.
```mermaid
flowchart LR
REQ[Request] --> SO["strip same-origin Origin<br/>(Web Shell support)"]
REQ[Request] --> LS["loopback self-origin strip<br/>(server/self-origin.ts)"]
LS --> LOG["access-log middleware<br/>(DaemonLogger)"]
LOG --> TID["inbound trace-id capture"]
TID --> HA["hostAllowlist<br/>(loopback DNS-rebinding defense;<br/>primary gate passes through on<br/>non-loopback binds, Local Control<br/>keeps its own Host gate)"]
HA --> SO["remote same-origin check<br/>(credential check + Origin strip)"]
SO --> AO["allowOriginCors<br/>(mutable allowlist: --allow-origin<br/>patterns + Local Control LAN origin)"]
AO --> HA["hostAllowlist"]
HA --> LOG["access-log middleware<br/>(DaemonLogger)"]
LOG --> WH{"Channel webhook?"}
AO --> H["pre-auth /health<br/>(loopback, unless --require-auth)"]
H --> WH{"Channel webhook?"}
WH -->|yes| WS["x-qwen-webhook-secret<br/>+ webhook rate/body limits"]
WH -->|no| BA["bearerAuth"]
BA --> RL["rate-limit middleware<br/>(when enabled)"]
@@ -80,13 +92,40 @@ flowchart LR
`mutationGate` is a per-route middleware factory (`createMutationGate` returns
`mutate()`); routes call `mutate()` or `mutate({strict: true})` at registration
time. It is not a global `app.use()` middleware. Access logging is registered
before `bearerAuth` so 401 rejects are still logged. Normal API rate limiting
time. It is not a global `app.use()` middleware. Access logging and inbound
trace-id capture are registered ahead of the origin wall and the same-origin
credential check, so those 403/401 short-circuits are logged like every other
reject and the log line still joins the caller's trace id; both also precede
`bearerAuth`, so 401 rejects are still logged. The loopback Host allowlist is registered ahead of the pre-auth health
route so the DNS-rebinding defense covers it. Pre-auth `/health` sits below
the origin wall (matched cross-origin probes carry CORS headers); the access
log exempts `GET /health` and `POST */heartbeat` by path before attaching its
finish logger, so exact-path `GET /health` and `POST */heartbeat` probes stay
unlogged at any mount position and wall rejects on those exempt paths are
likewise unlogged (`HEAD /health` and `GET /health/` are logged like any
request). Normal API rate limiting
runs after `bearerAuth` and before `express.json()`, so only authenticated
requests count and large bodies are rejected before parsing when a limit is
exceeded. Channel webhook ingress branches before bearer auth and applies its
own shared-secret check, mutation-tier rate check, and 1 MiB parser.
The same-origin surface is two middlewares in `server/self-origin.ts`, mounted
apart: the loopback-only `Origin` strip installs ahead of the access log (it
only deletes a matching `Origin`, never rejects), and
`installRemoteSelfOriginMiddleware` runs immediately before
`allowOriginCors` on a non-loopback primary listener with a configured token.
The remote one matches a canonical `Origin` against the direct socket scheme
plus the normalized `Host` authority — forwarded headers are never consulted —
and bearer-authenticates the match before deleting `Origin`, so the built-in Web
Shell's same-origin HTTP mutations need no `--allow-origin`. Its pre-auth
predicate (`web-shell-preauth.ts`) exempts the shell entry points (`/`, `//`,
`/assets*`, `/mcp-app-sandbox`, exact `/session/:id` document navigations) from
the credential check because browser module-script fetches carry `Origin`
without `Authorization`. WebSocket upgrades and TLS-front-proxy `https` origins
are not covered: the upgrade gate keeps its own CSWSH policy (loopback origin,
`--allow-origin` entry, or the Local Control listener's own origin), and a
proxy's `https` origin can never match the plain socket's scheme.
### `bearerAuth`
- **No token configured** → middleware is a no-op (loopback developer default). Exception: the Local Control **LAN listener** is listener-scoped and always requires its pairing credential (`CredentialStore.isOpen` is never true for `local-control`), so it is never open even on a token-less daemon.
@@ -109,7 +148,7 @@ Non-loopback binds bypass the primary gate (operator chose the surface area; bea
Reject any request with an `Origin` header. CLI/SDK never set Origin; only browsers do. Returns deterministic `403 { error: 'Request denied by CORS policy' }` rather than the 500 HTML the `cors` package's error-callback would produce. The runtime app no longer installs this wall — it runs `allowOriginCors` over the mutable allowlist (below); the deny behavior survives there as the unmatched-origin branch. The wall remains in the bootstrap app (run-qwen-serve.ts) that serves requests before the runtime starts.
Exception: the Web Shell's same-origin XHRs on a **loopback** bind are handled by a separate middleware (in `server/self-origin.ts`) that strips `Origin` when it matches one of the canonical loopback self-origins (`127.0.0.1`, `localhost`, `[::1]`, `host.docker.internal`) or the exact bound loopback address. Scheme-matched port-less origins are accepted only for their default port (`http` on 80, `https` on 443). On non-loopback binds the shell's XHRs carry an unmatched `Origin` and need `--allow-origin` for the daemon origin.
Exception: the Web Shell's same-origin XHRs on a **loopback** bind are handled by a separate middleware (in `server/self-origin.ts`) that strips `Origin` when it matches one of the canonical loopback self-origins (`127.0.0.1`, `localhost`, `[::1]`, `host.docker.internal`) or the exact bound loopback address. Scheme-matched port-less origins are accepted only for their default port (`http` on 80, `https` on 443). On non-loopback binds a second middleware (`installRemoteSelfOriginMiddleware`, in the same file) covers the shell's XHRs instead: it bearer-authenticates a request whose canonical `Origin` equals the direct socket scheme plus the normalized `Host` authority — forwarded headers are never trusted — and strips that `Origin` before the wall, so those requests need no `--allow-origin` entry. Cross-origin and `null` origins remain rejected, and the WebSocket upgrade routes plus a TLS-front-proxy `https` origin still require one (see [Middleware chain](#middleware-chain-http-request-order)).
### `allowOriginCors` (runtime app, always installed)
@@ -123,8 +162,8 @@ Control is enabled (the LAN origin is added/removed with the listener):
preflight returns `204`.
- Non-matching `Origin` values receive the same deterministic
`403 { error: 'Request denied by CORS policy' }` as deny mode.
- `--allow-origin '*'` requires `--token`; otherwise boot refuses.
- Without a token, HTTP(S) `--allow-origin` values are limited to loopback hosts. A non-loopback browser origin requires a token because it could otherwise exercise the full operator API, including code execution as the daemon user.
- `--allow-origin '*'` requires a bearer token; on loopback binds boot refuses when none is configured, while on non-loopback binds the generated ephemeral token satisfies the guard (the refusal is loopback-only).
- Without a token, HTTP(S) `--allow-origin` values are limited to loopback hosts on loopback binds; on non-loopback binds the generated token satisfies the same guard. A non-loopback browser origin authenticates with the bearer on every API route because it could otherwise exercise the full operator API, including code execution as the daemon user; the pre-auth exceptions are the Web Shell document/asset routes and the MCP App sandbox (`/`, `/assets*`, `/mcp-app-sandbox`, exact `/session/:id` navigations), plus the channel webhook ingress, which branches ahead of `bearerAuth` and authenticates with its own `x-qwen-webhook-secret` instead of the bearer, and — on loopback binds only — `/health` (bearer-gated elsewhere).
- Explicit browser-extension origins retain their tokenless local-automation path. Startup logs that any tokenless allowed browser origin receives full operator authority.
- `parseAllowOriginPatterns()` validates pattern syntax at boot.
- The `allow_origin` capability tag is advertised only when this mode is
@@ -303,15 +342,15 @@ sequenceDiagram
## Configuration
| Source | Knob | Effect |
| --------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| Env | `QWEN_SERVER_TOKEN` | Bearer token (trimmed). |
| Flag | `--token` | Bearer token (overrides env). |
| CLI flags | `--open-with-auth` | Reuse or generate a loopback Web Shell bearer before daemon boot. |
| Flag | `--require-auth` | Extends bearer to loopback + `/health`. Boots only with a token. |
| Flag | `--hostname` | Non-loopback bind requires `--token` (or env). |
| Flag | `--allow-origin <pattern>` | Switch to CORS allowlist mode. Wildcard and non-loopback HTTP(S) origins require a token. |
| Capability tags | `require_auth` (conditional), `auth_device_flow` (always), `allow_origin` (conditional) | See [`11-capabilities-versioning.md`](./11-capabilities-versioning.md). |
| Source | Knob | Effect |
| --------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| Env | `QWEN_SERVER_TOKEN` | Bearer token (trimmed). |
| Flag | `--token` | Bearer token (overrides env). |
| CLI flags | `--open-with-auth` | Reuse or generate a loopback Web Shell bearer before daemon boot. |
| Flag | `--require-auth` | Extends bearer to loopback + `/health`. Boots only with a token. |
| Flag | `--hostname` | Non-loopback bind always carries a bearer — `--token`, `QWEN_SERVER_TOKEN`, or a generated ephemeral one; an explicitly empty source refuses. |
| Flag | `--allow-origin <pattern>` | Switch to CORS allowlist mode. Wildcard and non-loopback HTTP(S) origins require a token. |
| Capability tags | `require_auth` (conditional), `auth_device_flow` (always), `allow_origin` (conditional) | See [`11-capabilities-versioning.md`](./11-capabilities-versioning.md). |
## Caveats & Known Limits
@@ -69,7 +69,7 @@ abstract class ChannelBase {
All internal message delivery routes through `sendThreadMessage(chatId, threadId, text, sourceLabel)`. The default implementation falls through to `sendMessage(chatId, attributedText)`, ignoring `threadId`. Polling, rich-card, media, streaming, and platform-splitting adapters override the boundary so the optional plain-text source label is escaped for the platform and repeated on every independently visible object without mutating raw response state.
Handles common cross-cutting concerns: sender gating (allowlist / denylist), group gating, message block streaming (chunk size, throttling), inbound debounce.
Handles common cross-cutting concerns: sender gating (allowlist / denylist), group gating, inbound debounce.
### Per-channel adapters
@@ -141,9 +141,9 @@ sequenceDiagram
D-->>SC: SSE: session_update (agent_message_chunk)
SC-->>BR: DaemonEvent
BR-->>CB: emit 'textChunk'
CB->>CB: assemble response / block streaming
CB->>AD: sendMessage(chatId, chunk or full response)
BR-->>CB: emit 'textChunk' -> onResponseChunk (default no-op)
BR-->>CB: prompt() resolves with the full response
CB->>AD: sendThreadMessage(chatId, threadId, full response, sourceLabel)
AD->>CH: sendText / sendMessage / sendChunk
```
@@ -201,7 +201,6 @@ Adapter `connect()` failures are reported separately from worker lifecycle error
| `approvalMode` | `'auto'` (auto-respond) / `'prompt'` (render UI). |
| `allowlist?: string[]` | Sender ids allowed; missing = open. |
| `denylist?: string[]` | Sender ids denied. |
| `chunkSize`, `chunkIntervalMs` | Outbound block streaming settings. |
| `daemon: { baseUrl, token?, clientId? }` | Forwarded to `DaemonChannelSessionFactory`. |
Channel-specific keys layer on top (DingTalk: `streamCredentials`; WeChat: `ilinkUrl`, `botId`; Telegram: `botToken`; Feishu: `clientId` (appId), `clientSecret` (appSecret), `verificationToken`, `encryptKey` (webhook mode)).
+47 -47
View File
@@ -6,52 +6,52 @@ This page collects every setting that affects the `qwen serve` daemon and its ad
## CLI flags (`qwen serve`)
| Flag | Type | Default | Effect |
| --------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--hostname <host>` | string | `127.0.0.1` | Bind address. Loopback values: any `127.0.0.0/8` address, `localhost`, `::1`, `[::1]`; `localhost` is resolved once and pinned, and trusted mode also verifies the actual listener address before startup completes. A non-loopback result requires a bearer token. `host:port` input is rejected with guidance to use `--port`. |
| `--port <n>` | number | `4170` | Listen port; `0` means ephemeral. |
| `--token <s>` | string | env | Bearer token. Overrides `QWEN_SERVER_TOKEN` and is trimmed at boot. It appears in the process command line, so prefer env in deployments. |
| `--open` | boolean | `false` | Open the mounted Web Shell after runtime readiness. A configured token is delivered as a URL fragment. Bare `--open` remains a silent no-op when browser launch is ineligible. |
| `--open-with-auth` | boolean | `false` | Open the Web Shell with bearer authentication on loopback. Requires an enabled Web Shell and built assets. Reuses a selected `--token` / `QWEN_SERVER_TOKEN`, or generates a process-lifetime 256-bit bearer before listen. In a browser-ineligible environment, starts and prints the secret-bearing fragment URL. Not a `ServeOptions` or SDK setting. |
| `--require-auth` | boolean | `false` | Extends bearer auth to loopback and `/health`; boot refuses to start without a token. |
| `--workspace <dir>` | absolute path / repeatable | `process.cwd()` | Startup workspace runtime; repeat to register additional isolated runtimes. The first is primary. Every value must be absolute and a directory; canonicalized at boot. |
| `--memory-project-scope <mode>` | `git-root` / `workspace` | `workspace` | Project-memory partitioning. `workspace` isolates by exact workspace directory; `git-root` is the legacy compatibility scope shared by workspaces at the same Git root. Overrides `QWEN_CODE_MEMORY_PROJECT_SCOPE`. |
| `--max-sessions <n>` | number | `32` | Per-workspace active session cap. `0` / `Infinity` means unlimited; `NaN` / negative values throw. |
| `--max-total-sessions <n>` | number | derived for multiple startup/restored workspaces | Daemon-wide active session cap. When omitted, a finite default is derived once from the per-workspace cap and startup/restored workspace count. `0` / `Infinity` means unlimited. |
| `--max-pending-prompts-per-session <n>` | number | `5` | Accepted but pending/running prompt cap per session. Excess prompt returns 503. `0` / `Infinity` means unlimited; negative or non-integer values throw. |
| `--max-connections <n>` | number | `256` | HTTP listener `server.maxConnections`; `0` / `Infinity` means unlimited. |
| `--enable-session-shell` | boolean | `false` | Enables direct `POST /session/:id/shell` execution. Effective with bearer auth or trusted-loopback authority; every call must carry a session-bound `X-Qwen-Client-Id`. |
| `--event-ring-size <n>` | number | `8000` | Per-session SSE replay ring; soft cap is `1_000_000`. |
| `--session-prompt-settled-close-grace-ms <n>` | non-negative integer | `0` | Grace period after a prompt settles before an otherwise-idle session may be auto-closed (ms). Poll-based SSE clients use this window to reconnect without triggering a session rebuild. `0` = immediate close. |
| `--compacted-replay-max-bytes <n>` | positive integer | `4194304` | Byte cap for the bounded in-memory replay snapshot returned by `POST /session/:id/load`; hard cap is `268435456`. |
| `--max-journal-events <n>` | positive safe integer | `10000` | Per-session baseline cap on in-flight `liveJournal` replay entries for the unfinished turn. Adaptive growth can raise it (see `--max-journal-bytes`); pinning either journal flag disables growth. |
| `--max-journal-bytes <n>` | positive safe integer | `8388608` (8 MiB) | Per-session baseline byte cap on the in-flight `liveJournal`. When a turn breaches it, adaptive growth raises the session's caps on demand, toward double but limited by the remaining pool headroom and never past a 256 MiB per-session hard cap — within one daemon-wide pool of 5% of the effective `--memory-budget-mb` (capped at `1024` MB; 0 — growth disabled — when the effective budget is below the 1024 MB minimum), shared by every workspace bridge; without headroom the oldest entries are dropped with a `history_truncated` marker. Pinning either journal flag disables growth. |
| `--memory-budget-mb <n>` | integer in `[1024, 1048576]` | 50% of cgroup-constrained or host memory, capped at the flag maximum (1048576 MB) | Total memory budget for the daemon process tree, capped at resolved available memory. Reported under `limits.memory` in daemon status; it does not size any child process — the one consumer today is adaptive live-journal growth (see `--max-journal-bytes`). Boot rejects out-of-range values. |
| `--memory-pressure-mode <mode>` | `off` \| `observe` | `observe` | Whether the daemon derives a memory-pressure level from its own RSS and V8 heap. Both modes report `runtime.memory.pressure`; only `observe` raises `daemon_memory_pressure`. Root process only; no remediation. |
| `--child-heap-mode <mode>` | `off` \| `observe` | `observe` | Whether the daemon models a per-child heap partition of the budget. `observe` reports it and counts spawns past it; nothing is applied. `off` publishes no partition at all — `maxConcurrentChildren` and `perChildCeilingMb` are both `null`. |
| `--http-bridge` | boolean | `true` | Stage 1 bridge mode. `--no-http-bridge` still falls back to http-bridge and prints to stderr. |
| `--mcp-client-budget <n>` | positive integer | unset | Sets `WorkspaceMcpBudget.clientBudget` and forwards it to the ACP child through `childEnvOverrides`. |
| `--mcp-budget-mode <m>` | `off` / `warn` / `enforce` | `warn` when budget is set, otherwise `off` | Sets `WorkspaceMcpBudget.mode`; `enforce` requires `--mcp-client-budget`. |
| `--external-tool-guard-mode <m>` | `off` / `required` | `off` | Enables the managed ACP external pre-execution Guard. `required` fails startup unless its loopback provider completes the v1 handshake. |
| `--external-tool-guard-endpoint <url>` | loopback HTTP(S) origin | unset | Provider origin used only in `required` mode. It must be origin-only and use `127.0.0.1`, `localhost`, or `::1`; paths, credentials, redirects, and proxy routing are rejected. |
| `--external-tool-guard-timeout-ms <n>` | integer `100..30000` | `3000` | Per-handshake and per-prepare deadline. A timeout fails startup during the handshake or fails the invocation closed during a turn. |
| `--allow-origin <pattern>` | repeatable string | unset | Cross-origin allowlist that replaces the default CORS denial. Wildcard and non-loopback HTTP(S) origins require a token; tokenless HTTP(S) origins must be loopback. |
| `--allow-private-auth-base-url` | boolean | `false` | Allows `/workspace/auth/provider` to install localhost / private-network auth provider `baseUrl`; use only in trusted local development. |
| `--web` / `--no-web` | boolean | `true` | Serve the built Web Shell SPA at the daemon root (`GET /`, `/assets/*`, and `/session/:id` document navigations). These entry points are mounted before `bearerAuth`; API calls still follow configured bearer auth or trusted-loopback authority. `--no-web` leaves the daemon API-only. |
| `--prompt-deadline-ms <n>` | positive integer | unset | Server-side prompt wallclock limit in ms. Timeout aborts and returns an error. |
| `--writer-idle-timeout-ms <n>` | positive integer | unset | Per-SSE-connection idle timeout in ms. The daemon closes the SSE connection when no event is sent for this duration. |
| `--channel-idle-timeout-ms <n>` | non-negative integer | `0` | ACP child auto-reap delay after runtime work drains. Plain preheat is preserved for first use. An explicit keepalive can extend the configured delay; the longer remaining delay wins. |
| `--initialize-timeout-ms <n>` | positive integer | `10000` | ACP child startup deadline (channel factory + initialize handshake) and default request timeout (ms). |
| `--session-restore-timeout-ms <n>` | positive integer | `60000` | ACP session load/resume timeout (ms). When this flag is omitted, an explicitly supplied initialize timeout raises the budget but never lowers it below the default. |
| `--permission-response-timeout-ms <n>` | non-negative integer | `0` | Shared wall-clock timeout for ordinary permission and `ask_user_question` responses. `0` or an omitted flag waits indefinitely; a positive value enables the timer. |
| `--session-reap-interval-ms <n>` | non-negative integer | `60000` | Session reaper scan interval; `0` disables it. |
| `--session-idle-timeout-ms <n>` | non-negative integer | `1800000` | Disconnected-session idle reaping time; `0` disables it. |
| `--rate-limit` / `--no-rate-limit` | boolean | env / off | Enables per-tier HTTP rate limiting for prompt, mutation, and read routes. |
| `--rate-limit-prompt <n>` | positive integer | `10` | Prompt request limit per window; requires rate limiting to be enabled. |
| `--rate-limit-mutation <n>` | positive integer | `30` | Mutation request limit per window; requires rate limiting to be enabled. |
| `--rate-limit-read <n>` | positive integer | `120` | Read request limit per window; requires rate limiting to be enabled. |
| `--rate-limit-window-ms <n>` | integer `>= 1000` | `60000` | Rate limit window length; requires rate limiting to be enabled. |
| no flag | - | - | `QWEN_SERVE_NO_MCP_POOL=1` fully disables the pool. |
| Flag | Type | Default | Effect |
| --------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `--hostname <host>` | string | `127.0.0.1` | Bind address. Loopback values: any `127.0.0.0/8` address, `localhost`, `::1`, `[::1]`; `localhost` is resolved once and pinned, and trusted mode also verifies the actual listener address before startup completes. A non-loopback result always carries a bearer token: operator-supplied, or generated at startup when neither `--token` nor `QWEN_SERVER_TOKEN` is present — generation keys on the requested spelling, so a `localhost` that resolves off-loopback never generates, and boots only when a token source resolved (the refusal reads the resolved address), while a non-literal name that resolves to loopback generates and prints its bearer token-only. `host:port` input is rejected with guidance to use `--port`. |
| `--port <n>` | number | `4170` | Listen port; `0` means ephemeral. |
| `--token <s>` | string | env | Bearer token. Overrides `QWEN_SERVER_TOKEN` and is trimmed at boot. It appears in the process command line, so prefer env in deployments. When neither this flag nor the env var is present, a non-loopback bind generates an ephemeral 128-bit base64url bearer (22 characters) and prints it once at startup; loopback spellings generate nothing. An explicitly empty value is a supplied source, not an absent one: it shadows the env var, so a non-loopback bind refuses to boot, and on loopback it selects the trusted token-less mode (or refuses under `--require-auth`). |
| `--open` | boolean | `false` | Open the mounted Web Shell after runtime readiness. A resolved token (configured or generated) is delivered as a URL fragment. Bare `--open` remains a silent no-op when browser launch is ineligible. |
| `--open-with-auth` | boolean | `false` | Open the Web Shell with bearer authentication on loopback. Requires an enabled Web Shell and built assets. Reuses a selected `--token` / `QWEN_SERVER_TOKEN`, or generates a process-lifetime 256-bit bearer before listen — that generated value is installed on the options token before boot, which makes this flag a third token source that satisfies `--require-auth` on loopback. In a browser-ineligible environment, starts and prints the secret-bearing fragment URL. Not a `ServeOptions` or SDK setting. |
| `--require-auth` | boolean | `false` | Extends bearer auth to loopback and `/health`. Boot refuses to start when no token source resolves, which after the non-loopback generation step means a **loopback** bind: on a non-loopback bind the generated ephemeral token satisfies the flag, so its "no configured secret, no daemon" fail-fast is loopback-only. Loopback token sources are `--token`, `QWEN_SERVER_TOKEN`, and `--open-with-auth`. |
| `--workspace <dir>` | absolute path / repeatable | `process.cwd()` | Startup workspace runtime; repeat to register additional isolated runtimes. The first is primary. Every value must be absolute and a directory; canonicalized at boot. |
| `--memory-project-scope <mode>` | `git-root` / `workspace` | `workspace` | Project-memory partitioning. `workspace` isolates by exact workspace directory; `git-root` is the legacy compatibility scope shared by workspaces at the same Git root. Overrides `QWEN_CODE_MEMORY_PROJECT_SCOPE`. |
| `--max-sessions <n>` | number | `32` | Per-workspace active session cap. `0` / `Infinity` means unlimited; `NaN` / negative values throw. |
| `--max-total-sessions <n>` | number | derived for multiple startup/restored workspaces | Daemon-wide active session cap. When omitted, a finite default is derived once from the per-workspace cap and startup/restored workspace count. `0` / `Infinity` means unlimited. |
| `--max-pending-prompts-per-session <n>` | number | `5` | Accepted but pending/running prompt cap per session. Excess prompt returns 503. `0` / `Infinity` means unlimited; negative or non-integer values throw. |
| `--max-connections <n>` | number | `256` | HTTP listener `server.maxConnections`; `0` / `Infinity` means unlimited. |
| `--enable-session-shell` | boolean | `false` | Enables direct `POST /session/:id/shell` execution. Effective with bearer auth or trusted-loopback authority; every call must carry a session-bound `X-Qwen-Client-Id`. |
| `--event-ring-size <n>` | number | `8000` | Per-session SSE replay ring; soft cap is `1_000_000`. |
| `--session-prompt-settled-close-grace-ms <n>` | non-negative integer | `0` | Grace period after a prompt settles before an otherwise-idle session may be auto-closed (ms). Poll-based SSE clients use this window to reconnect without triggering a session rebuild. `0` = immediate close. |
| `--compacted-replay-max-bytes <n>` | positive integer | `4194304` | Byte cap for the bounded in-memory replay snapshot returned by `POST /session/:id/load`; hard cap is `268435456`. |
| `--max-journal-events <n>` | positive safe integer | `10000` | Per-session baseline cap on in-flight `liveJournal` replay entries for the unfinished turn. Adaptive growth can raise it (see `--max-journal-bytes`); pinning either journal flag disables growth. |
| `--max-journal-bytes <n>` | positive safe integer | `8388608` (8 MiB) | Per-session baseline byte cap on the in-flight `liveJournal`. When a turn breaches it, adaptive growth raises the session's caps on demand, toward double but limited by the remaining pool headroom and never past a 256 MiB per-session hard cap — within one daemon-wide pool of 5% of the effective `--memory-budget-mb` (capped at `1024` MB; 0 — growth disabled — when the effective budget is below the 1024 MB minimum), shared by every workspace bridge; without headroom the oldest entries are dropped with a `history_truncated` marker. Pinning either journal flag disables growth. |
| `--memory-budget-mb <n>` | integer in `[1024, 1048576]` | 50% of cgroup-constrained or host memory, capped at the flag maximum (1048576 MB) | Total memory budget for the daemon process tree, capped at resolved available memory. Reported under `limits.memory` in daemon status; it does not size any child process — the one consumer today is adaptive live-journal growth (see `--max-journal-bytes`). Boot rejects out-of-range values. |
| `--memory-pressure-mode <mode>` | `off` \| `observe` | `observe` | Whether the daemon derives a memory-pressure level from its own RSS and V8 heap. Both modes report `runtime.memory.pressure`; only `observe` raises `daemon_memory_pressure`. Root process only; no remediation. |
| `--child-heap-mode <mode>` | `off` \| `observe` | `observe` | Whether the daemon models a per-child heap partition of the budget. `observe` reports it and counts spawns past it; nothing is applied. `off` publishes no partition at all — `maxConcurrentChildren` and `perChildCeilingMb` are both `null`. |
| `--http-bridge` | boolean | `true` | Stage 1 bridge mode. `--no-http-bridge` still falls back to http-bridge and prints to stderr. |
| `--mcp-client-budget <n>` | positive integer | unset | Sets `WorkspaceMcpBudget.clientBudget` and forwards it to the ACP child through `childEnvOverrides`. |
| `--mcp-budget-mode <m>` | `off` / `warn` / `enforce` | `warn` when budget is set, otherwise `off` | Sets `WorkspaceMcpBudget.mode`; `enforce` requires `--mcp-client-budget`. |
| `--external-tool-guard-mode <m>` | `off` / `required` | `off` | Enables the managed ACP external pre-execution Guard. `required` fails startup unless its loopback provider completes the v1 handshake. |
| `--external-tool-guard-endpoint <url>` | loopback HTTP(S) origin | unset | Provider origin used only in `required` mode. It must be origin-only and use `127.0.0.1`, `localhost`, or `::1`; paths, credentials, redirects, and proxy routing are rejected. |
| `--external-tool-guard-timeout-ms <n>` | integer `100..30000` | `3000` | Per-handshake and per-prepare deadline. A timeout fails startup during the handshake or fails the invocation closed during a turn. |
| `--allow-origin <pattern>` | repeatable string | unset | Cross-origin allowlist that replaces the default CORS denial. Wildcard and non-loopback HTTP(S) origins require a resolved token, so on a non-loopback bind the generated ephemeral bearer satisfies both guards and the refusals are loopback-only; tokenless HTTP(S) origins must be loopback. |
| `--allow-private-auth-base-url` | boolean | `false` | Allows `/workspace/auth/provider` to install localhost / private-network auth provider `baseUrl`; use only in trusted local development. |
| `--web` / `--no-web` | boolean | `true` | Serve the built Web Shell SPA at the daemon root (`GET /`, `/assets/*`, and `/session/:id` document navigations). These entry points are mounted before `bearerAuth`; API calls still follow configured bearer auth or trusted-loopback authority. `--no-web` leaves the daemon API-only. |
| `--prompt-deadline-ms <n>` | positive integer | unset | Server-side prompt wallclock limit in ms. Timeout aborts and returns an error. |
| `--writer-idle-timeout-ms <n>` | positive integer | unset | Per-SSE-connection idle timeout in ms. The daemon closes the SSE connection when no event is sent for this duration. |
| `--channel-idle-timeout-ms <n>` | non-negative integer | `0` | ACP child auto-reap delay after runtime work drains. Plain preheat is preserved for first use. An explicit keepalive can extend the configured delay; the longer remaining delay wins. |
| `--initialize-timeout-ms <n>` | positive integer | `10000` | ACP child startup deadline (channel factory + initialize handshake) and default request timeout (ms). |
| `--session-restore-timeout-ms <n>` | positive integer | `60000` | ACP session load/resume timeout (ms). When this flag is omitted, an explicitly supplied initialize timeout raises the budget but never lowers it below the default. |
| `--permission-response-timeout-ms <n>` | non-negative integer | `0` | Shared wall-clock timeout for ordinary permission and `ask_user_question` responses. `0` or an omitted flag waits indefinitely; a positive value enables the timer. |
| `--session-reap-interval-ms <n>` | non-negative integer | `60000` | Session reaper scan interval; `0` disables it. |
| `--session-idle-timeout-ms <n>` | non-negative integer | `1800000` | Disconnected-session idle reaping time; `0` disables it. |
| `--rate-limit` / `--no-rate-limit` | boolean | env / off | Enables per-tier HTTP rate limiting for prompt, mutation, and read routes. |
| `--rate-limit-prompt <n>` | positive integer | `10` | Prompt request limit per window; requires rate limiting to be enabled. |
| `--rate-limit-mutation <n>` | positive integer | `30` | Mutation request limit per window; requires rate limiting to be enabled. |
| `--rate-limit-read <n>` | positive integer | `120` | Read request limit per window; requires rate limiting to be enabled. |
| `--rate-limit-window-ms <n>` | integer `>= 1000` | `60000` | Rate limit window length; requires rate limiting to be enabled. |
| no flag | - | - | `QWEN_SERVE_NO_MCP_POOL=1` fully disables the pool. |
## Environment variables
@@ -59,7 +59,7 @@ This page collects every setting that affects the `qwen serve` daemon and its ad
| Env | Effect |
| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `QWEN_SERVER_TOKEN` | Bearer token; trimmed at boot. |
| `QWEN_SERVER_TOKEN` | Bearer token; trimmed at boot. `--token` wins. An explicitly empty value counts as supplied rather than absent, so it resolves to no token — and `--token ''` likewise shadows a set value here. When neither this variable nor `--token` is set, a non-loopback bind generates an ephemeral bearer and prints it once; the daemon never writes this variable back into the environment. |
| `QWEN_SERVE_DEBUG` | `1` / `true` / `on` / `yes` (case-insensitive) enables verbose stderr logs. See [`19-observability.md`](./19-observability.md). |
| `QWEN_SERVE_NO_MCP_POOL` | `1` disables the workspace MCP transport pool and falls back to per-session `McpClientManager`; capabilities stop advertising `mcp_workspace_pool` / `mcp_pool_restart`. |
| `QWEN_SERVE_PROMPT_DEADLINE_MS` | Env fallback for `--prompt-deadline-ms`. |
@@ -30,10 +30,14 @@ qwen serve --workspace /path/to/repo --port 0
# 3. Hardened loopback development (force bearer even on loopback)
QWEN_SERVER_TOKEN=$(openssl rand -hex 32) qwen serve --require-auth
# 4. Expose to LAN (non-loopback requires a token)
# 4. Expose to LAN (a stable token; without one the daemon generates
# an ephemeral bearer and prints it once)
QWEN_SERVER_TOKEN=$(openssl rand -hex 32) \
qwen serve --hostname 0.0.0.0 --port 4170
# 4b. Expose to LAN with the generated ephemeral bearer
qwen serve --hostname 0.0.0.0 --port 4170
# 5. Tune for many sessions and a larger replay ring
qwen serve --max-sessions 0 --event-ring-size 32000
@@ -73,59 +77,59 @@ With the hardened loopback recipe (3), `/health` is registered after `bearerAuth
The CLI is defined in **`packages/cli/src/commands/serve.ts`**:
| Flag | Type | Default | Required when | Effect |
| --------------------------------------- | ------------------------------ | ------------------------------------------------ | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--port <n>` | number | `4170` | - | TCP port; `0` means OS-assigned ephemeral port. |
| `--hostname <host>` | string | `127.0.0.1` | Non-loopback requires token | Bind address. Loopback values: any `127.0.0.0/8` address, `localhost`, `::1`, `[::1]`; `localhost` is resolved once and pinned, and trusted mode verifies the actual listener address before startup completes. `[::1]` brackets are stripped automatically; `host:port` input is rejected with guidance to use `--port`. |
| `--token <s>` | string | env / none | Non-loopback and `--require-auth` | Bearer token; trimmed once. **It appears in `/proc/<pid>/cmdline`, so prefer `QWEN_SERVER_TOKEN`**. Boot stderr also warns about this. |
| `--max-sessions <n>` | number | `32` | - | Per-workspace active session cap. Excess spawn returns 503. `0` means unlimited. `NaN` / negative values throw. |
| `--max-total-sessions <n>` | number | derived for multiple startup/restored workspaces | - | Daemon-wide active session cap. When omitted, a finite default is derived once from the per-workspace cap and startup/restored workspace count; dynamic registration does not recompute it. `0` means unlimited. |
| `--memory-budget-mb <n>` | integer in `[1024, 1048576]` | 50% of cgroup/host memory | - | Total memory budget for the daemon process tree, capped at resolved available memory. No child is sized from it; the one consumer today is the adaptive live-journal growth pool (see `--max-journal-bytes`). Reported under `limits.memory`, including a modeled per-child partition. |
| `--max-journal-events <n>` | positive safe integer | `10000` | - | Per-session baseline cap on in-flight `liveJournal` replay entries. Adaptive growth can raise it (see `--max-journal-bytes`); pinning either journal flag disables growth. |
| `--max-journal-bytes <n>` | positive safe integer | `8388608` | - | Per-session baseline byte cap on the in-flight `liveJournal`. Breaching turns grow the caps on demand (toward double, limited by remaining pool headroom) within one daemon-wide pool of 5% of the effective `--memory-budget-mb` (capped at `1024` MB; 0 — growth disabled — when the effective budget falls below the 1024 MB minimum), never past a 256 MiB per-session hard cap; pinning either journal flag disables growth. |
| `--memory-pressure-mode <mode>` | `off` \| `observe` | `observe` | Observation only | Reports `runtime.memory.pressure` in both modes; only `observe` raises the `daemon_memory_pressure` issue. Root process only. |
| `--child-heap-mode <mode>` | `off` \| `observe` | `observe` | Observation only | Under `observe`, reports the modeled partition under `limits.memory.childHeap`; applies nothing and refuses nothing. Under `off`, that block's two figures are `null`. |
| `--max-pending-prompts-per-session <n>` | number | `5` | - | Accepted but pending/running prompt cap per session. Excess prompt returns 503. `0` / `Infinity` means unlimited. Negative or non-integer values throw. |
| `--workspace <dir>` | string / repeatable | `process.cwd()` | - | Startup workspace runtime; repeat to register additional isolated runtimes. The first is primary. Each value **must be an absolute path, must exist, and must be a directory**. Boot canonicalizes every value via `canonicalizeWorkspace`. `POST /session` with a mismatched `cwd` returns `400 workspace_mismatch`. |
| `--max-connections <n>` | number | `256` | - | Listener-level `server.maxConnections`. `0` / `Infinity` means unlimited. `NaN` / negative values fail boot to avoid fail-open behavior. |
| `--require-auth` | boolean | `false` | Token required | Extends bearer auth to loopback **and** `/health`. Boot refuses to start without a token. |
| `--enable-session-shell` | boolean | `false` | Bearer or trusted loopback | Enables direct `POST /session/:id/shell` execution. Callers must also send a session-bound `X-Qwen-Client-Id`. |
| `--event-ring-size <n>` | number | `8000` | - | Per-session SSE replay ring depth. Soft cap is `MAX_EVENT_RING_SIZE = 1_000_000`; out-of-range values throw during bridge construction. |
| `--http-bridge` | boolean | `true` | - | Bridge mode: production attempts to preheat one primary `qwen --acp` child and retries on first use after failure; trusted secondaries start one on demand, while untrusted secondaries cannot start ACP. Stage 2 in-process mode is not implemented yet; `--no-http-bridge` falls back and prints to stderr. |
| `--mcp-client-budget <n>` | number | none | Required for `mcp-budget-mode=enforce` | Workspace MCP client cap. Must be a positive integer. |
| `--mcp-budget-mode <m>` | `'enforce' \| 'warn' \| 'off'` | `warn` when a budget is set, otherwise `off` | `enforce` requires `--mcp-client-budget` | `enforce` refuses, `warn` only warns at 75%, `off` is observation only. |
| `--allow-origin <pattern>` | repeatable string | none | - | CORS allowlist that replaces the default Origin denial. Wildcard and non-loopback HTTP(S) origins require a token. |
| `--allow-private-auth-base-url` | boolean | `false` | - | Allows localhost / private-network auth provider `baseUrl` installation. Use only for trusted local development. |
| `--prompt-deadline-ms <n>` | number | none | - | Server-side prompt wallclock limit in ms; timeout aborts the prompt. |
| `--writer-idle-timeout-ms <n>` | number | none | - | Per-SSE-connection idle timeout in ms. |
| `--channel-idle-timeout-ms <n>` | non-negative integer | `0` | - | ACP child auto-reap delay after runtime work drains. Plain preheat is preserved for first use. Active keepalive windows may extend the configured delay; the longer remaining delay wins. |
| `--initialize-timeout-ms <n>` | number | `10000` | - | ACP child startup deadline (channel factory + initialize handshake) and default request timeout (ms). |
| `--session-reap-interval-ms <n>` | number | `60000` | - | Session reaper scan interval. `0` disables it. |
| `--session-idle-timeout-ms <n>` | number | `1800000` | - | Disconnected-session idle timeout. `0` disables it. |
| `--rate-limit` / `--no-rate-limit` | boolean | env / off | - | Enables or disables per-tier HTTP rate limiting. |
| `--rate-limit-prompt <n>` | number | `10` | `--rate-limit` | Prompt requests per window. |
| `--rate-limit-mutation <n>` | number | `30` | `--rate-limit` | Mutation requests per window. |
| `--rate-limit-read <n>` | number | `120` | `--rate-limit` | Read requests per window. |
| `--rate-limit-window-ms <n>` | number | `60000` | `--rate-limit` | Rate limit window length; must be `>= 1000`. |
| Flag | Type | Default | Required when | Effect |
| --------------------------------------- | ------------------------------ | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--port <n>` | number | `4170` | - | TCP port; `0` means OS-assigned ephemeral port. |
| `--hostname <host>` | string | `127.0.0.1` | Non-loopback refuses on an empty token source or a `localhost` resolving off-loopback with no token source | Bind address. Loopback values: any `127.0.0.0/8` address, `localhost`, `::1`, `[::1]`; `localhost` is resolved once and pinned, and trusted mode verifies the actual listener address before startup completes. `[::1]` brackets are stripped automatically; `host:port` input is rejected with guidance to use `--port`. A non-loopback result always carries a bearer: with neither `--token` nor `QWEN_SERVER_TOKEN`, boot generates an ephemeral one instead of refusing. Carve-out: generation keys on the spelling, so a `localhost` that resolves off-loopback never generates, and boots only when a token source resolved (the refusal reads the resolved address), while a non-literal name that resolves to loopback generates and prints its bearer token-only. |
| `--token <s>` | string | env / none | No valid source needed on a non-loopback spelling (generated instead — but a blank value refuses there); on loopback: `--require-auth`, `--allow-origin '*'`, or a non-loopback HTTP(S) `--allow-origin` | Bearer token; trimmed once. An explicitly empty value is a _supplied_ source, so it shadows `QWEN_SERVER_TOKEN` and resolves to no token — a non-loopback bind then refuses to boot, as does a `localhost` bind whose resolution lands off-loopback with no other token source (generation never runs for it). **It appears in `/proc/<pid>/cmdline`, so prefer `QWEN_SERVER_TOKEN`**. Boot stderr also warns about this. |
| `--max-sessions <n>` | number | `32` | - | Per-workspace active session cap. Excess spawn returns 503. `0` means unlimited. `NaN` / negative values throw. |
| `--max-total-sessions <n>` | number | derived for multiple startup/restored workspaces | - | Daemon-wide active session cap. When omitted, a finite default is derived once from the per-workspace cap and startup/restored workspace count; dynamic registration does not recompute it. `0` means unlimited. |
| `--memory-budget-mb <n>` | integer in `[1024, 1048576]` | 50% of cgroup/host memory | - | Total memory budget for the daemon process tree, capped at resolved available memory. No child is sized from it; the one consumer today is the adaptive live-journal growth pool (see `--max-journal-bytes`). Reported under `limits.memory`, including a modeled per-child partition. |
| `--max-journal-events <n>` | positive safe integer | `10000` | - | Per-session baseline cap on in-flight `liveJournal` replay entries. Adaptive growth can raise it (see `--max-journal-bytes`); pinning either journal flag disables growth. |
| `--max-journal-bytes <n>` | positive safe integer | `8388608` | - | Per-session baseline byte cap on the in-flight `liveJournal`. Breaching turns grow the caps on demand (toward double, limited by remaining pool headroom) within one daemon-wide pool of 5% of the effective `--memory-budget-mb` (capped at `1024` MB; 0 — growth disabled — when the effective budget falls below the 1024 MB minimum), never past a 256 MiB per-session hard cap; pinning either journal flag disables growth. |
| `--memory-pressure-mode <mode>` | `off` \| `observe` | `observe` | Observation only | Reports `runtime.memory.pressure` in both modes; only `observe` raises the `daemon_memory_pressure` issue. Root process only. |
| `--child-heap-mode <mode>` | `off` \| `observe` | `observe` | Observation only | Under `observe`, reports the modeled partition under `limits.memory.childHeap`; applies nothing and refuses nothing. Under `off`, that block's two figures are `null`. |
| `--max-pending-prompts-per-session <n>` | number | `5` | - | Accepted but pending/running prompt cap per session. Excess prompt returns 503. `0` / `Infinity` means unlimited. Negative or non-integer values throw. |
| `--workspace <dir>` | string / repeatable | `process.cwd()` | - | Startup workspace runtime; repeat to register additional isolated runtimes. The first is primary. Each value **must be an absolute path, must exist, and must be a directory**. Boot canonicalizes every value via `canonicalizeWorkspace`. `POST /session` with a mismatched `cwd` returns `400 workspace_mismatch`. |
| `--max-connections <n>` | number | `256` | - | Listener-level `server.maxConnections`. `0` / `Infinity` means unlimited. `NaN` / negative values fail boot to avoid fail-open behavior. |
| `--require-auth` | boolean | `false` | Token source on loopback | Extends bearer auth to loopback **and** `/health`. Boot refuses to start when no token source resolves — a **loopback-only** fail-fast, because on a non-loopback bind the generated ephemeral token satisfies the flag. Loopback sources are `--token`, `QWEN_SERVER_TOKEN`, and `--open-with-auth` (which installs its own generated token before boot, so `--require-auth --open-with-auth` starts). |
| `--enable-session-shell` | boolean | `false` | Bearer or trusted loopback | Enables direct `POST /session/:id/shell` execution. Callers must also send a session-bound `X-Qwen-Client-Id`. |
| `--event-ring-size <n>` | number | `8000` | - | Per-session SSE replay ring depth. Soft cap is `MAX_EVENT_RING_SIZE = 1_000_000`; out-of-range values throw during bridge construction. |
| `--http-bridge` | boolean | `true` | - | Bridge mode: production attempts to preheat one primary `qwen --acp` child and retries on first use after failure; trusted secondaries start one on demand, while untrusted secondaries cannot start ACP. Stage 2 in-process mode is not implemented yet; `--no-http-bridge` falls back and prints to stderr. |
| `--mcp-client-budget <n>` | number | none | Required for `mcp-budget-mode=enforce` | Workspace MCP client cap. Must be a positive integer. |
| `--mcp-budget-mode <m>` | `'enforce' \| 'warn' \| 'off'` | `warn` when a budget is set, otherwise `off` | `enforce` requires `--mcp-client-budget` | `enforce` refuses, `warn` only warns at 75%, `off` is observation only. |
| `--allow-origin <pattern>` | repeatable string | none | - | CORS allowlist that replaces the default Origin denial. Wildcard and non-loopback HTTP(S) origins require a resolved token; these guards read the token _after_ generation, so on a non-loopback bind the ephemeral bearer satisfies them and the refusals are loopback-only. |
| `--allow-private-auth-base-url` | boolean | `false` | - | Allows localhost / private-network auth provider `baseUrl` installation. Use only for trusted local development. |
| `--prompt-deadline-ms <n>` | number | none | - | Server-side prompt wallclock limit in ms; timeout aborts the prompt. |
| `--writer-idle-timeout-ms <n>` | number | none | - | Per-SSE-connection idle timeout in ms. |
| `--channel-idle-timeout-ms <n>` | non-negative integer | `0` | - | ACP child auto-reap delay after runtime work drains. Plain preheat is preserved for first use. Active keepalive windows may extend the configured delay; the longer remaining delay wins. |
| `--initialize-timeout-ms <n>` | number | `10000` | - | ACP child startup deadline (channel factory + initialize handshake) and default request timeout (ms). |
| `--session-reap-interval-ms <n>` | number | `60000` | - | Session reaper scan interval. `0` disables it. |
| `--session-idle-timeout-ms <n>` | number | `1800000` | - | Disconnected-session idle timeout. `0` disables it. |
| `--rate-limit` / `--no-rate-limit` | boolean | env / off | - | Enables or disables per-tier HTTP rate limiting. |
| `--rate-limit-prompt <n>` | number | `10` | `--rate-limit` | Prompt requests per window. |
| `--rate-limit-mutation <n>` | number | `30` | `--rate-limit` | Mutation requests per window. |
| `--rate-limit-read <n>` | number | `120` | `--rate-limit` | Read requests per window. |
| `--rate-limit-window-ms <n>` | number | `60000` | `--rate-limit` | Rate limit window length; must be `>= 1000`. |
## 4. Environment variables
| Env | Equivalent flag / effect |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `QWEN_SERVER_TOKEN` | Equivalent to `--token`; `--token` wins. Trimmed once at boot to avoid a trailing newline from `cat token.txt`. |
| `QWEN_SERVE_DEBUG` | `1` / `true` / `on` / `yes` (case-insensitive) enables verbose stderr logs. |
| `QWEN_SERVE_NO_MCP_POOL` | `1` disables the workspace MCP pool entirely and falls back to per-session `McpClientManager`. Capabilities stop advertising `mcp_workspace_pool` / `mcp_pool_restart`. |
| `QWEN_SERVE_MCP_CLIENT_BUDGET` | ACP-child internal budget input. The CLI generates it from `--mcp-client-budget` through `childEnvOverrides`; it is not a parent-process env fallback. |
| `QWEN_SERVE_MCP_BUDGET_MODE` | ACP-child internal budget mode. The CLI generates it from `--mcp-budget-mode` through `childEnvOverrides`; it is not a parent-process env fallback. |
| `QWEN_SERVE_PROMPT_DEADLINE_MS` | Env fallback for `--prompt-deadline-ms`. |
| `QWEN_SERVE_WRITER_IDLE_TIMEOUT_MS` | Env fallback for `--writer-idle-timeout-ms`. |
| `QWEN_SERVE_MCP_POOL_TRANSPORTS` | Read by the ACP child. Comma-separated pooled transport allowlist; default is `stdio,websocket`. |
| `QWEN_SERVE_MCP_POOL_DRAIN_MS` | Read by the ACP child. Pool entry idle drain delay; default is `30000`, clamped to `1000..600000` ms. |
| `QWEN_SERVE_RATE_LIMIT` | `1` / `true` enables rate limiting; CLI flag wins. |
| `QWEN_SERVE_RATE_LIMIT_PROMPT` | Env fallback for `--rate-limit-prompt`. |
| `QWEN_SERVE_RATE_LIMIT_MUTATION` | Env fallback for `--rate-limit-mutation`. |
| `QWEN_SERVE_RATE_LIMIT_READ` | Env fallback for `--rate-limit-read`. |
| `QWEN_SERVE_RATE_LIMIT_WINDOW_MS` | Env fallback for `--rate-limit-window-ms`. |
| Env | Equivalent flag / effect |
| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `QWEN_SERVER_TOKEN` | Equivalent to `--token`; `--token` wins. Trimmed once at boot to avoid a trailing newline from `cat token.txt`. When neither source is set, a non-loopback bind generates an ephemeral bearer instead; the daemon never sets this variable itself. A _supplied but blank_ source is never "absent", so it always suppresses that generation — but blankness decides the resolved token in one direction only: a blank `--token` shadows a set value here and resolves to no token (a non-loopback bind then refuses), whereas a blank value _here_ resolves to no token only when `--token` is not passed — a non-blank `--token` still wins, and the daemon boots on it. A `localhost` bind whose resolution lands off-loopback likewise never generates, and boots only when a token source resolved (see `--hostname`). |
| `QWEN_SERVE_DEBUG` | `1` / `true` / `on` / `yes` (case-insensitive) enables verbose stderr logs. |
| `QWEN_SERVE_NO_MCP_POOL` | `1` disables the workspace MCP pool entirely and falls back to per-session `McpClientManager`. Capabilities stop advertising `mcp_workspace_pool` / `mcp_pool_restart`. |
| `QWEN_SERVE_MCP_CLIENT_BUDGET` | ACP-child internal budget input. The CLI generates it from `--mcp-client-budget` through `childEnvOverrides`; it is not a parent-process env fallback. |
| `QWEN_SERVE_MCP_BUDGET_MODE` | ACP-child internal budget mode. The CLI generates it from `--mcp-budget-mode` through `childEnvOverrides`; it is not a parent-process env fallback. |
| `QWEN_SERVE_PROMPT_DEADLINE_MS` | Env fallback for `--prompt-deadline-ms`. |
| `QWEN_SERVE_WRITER_IDLE_TIMEOUT_MS` | Env fallback for `--writer-idle-timeout-ms`. |
| `QWEN_SERVE_MCP_POOL_TRANSPORTS` | Read by the ACP child. Comma-separated pooled transport allowlist; default is `stdio,websocket`. |
| `QWEN_SERVE_MCP_POOL_DRAIN_MS` | Read by the ACP child. Pool entry idle drain delay; default is `30000`, clamped to `1000..600000` ms. |
| `QWEN_SERVE_RATE_LIMIT` | `1` / `true` enables rate limiting; CLI flag wins. |
| `QWEN_SERVE_RATE_LIMIT_PROMPT` | Env fallback for `--rate-limit-prompt`. |
| `QWEN_SERVE_RATE_LIMIT_MUTATION` | Env fallback for `--rate-limit-mutation`. |
| `QWEN_SERVE_RATE_LIMIT_READ` | Env fallback for `--rate-limit-read`. |
| `QWEN_SERVE_RATE_LIMIT_WINDOW_MS` | Env fallback for `--rate-limit-window-ms`. |
Per-handle env overrides are intentional: two daemons running in the same process do not race on `process.env`. `defaultSpawnChannelFactory` snapshots env at spawn time.
@@ -148,23 +152,27 @@ Settings I/O failure, such as malformed JSON, falls back to defaults. `InvalidPo
`run-qwen-serve.ts` intentionally throws instead of falling back in these cases:
| Scenario | Error prefix |
| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| Non-loopback bind without token | `Refusing to bind ... without a bearer token` |
| `--require-auth` without token | `Refusing to start with --require-auth set but no bearer token` |
| `--workspace` does not exist, is not a directory, or is not absolute | `Invalid --workspace ...` |
| `--workspace` stat permission denied | `Invalid --workspace ...: permission denied` |
| `--mcp-client-budget` is not a positive integer | `Must be a positive integer` |
| `--mcp-budget-mode=enforce` without budget | `requires a positive mcpClientBudget` |
| `--hostname` is written as `localhost:4170` | `looks like a "host:port" combination. Use --port` |
| `--hostname [::1]:8080` | `Invalid --hostname ... brackets indicate an IPv6 literal but the value is not a clean [addr] form` |
| `--max-connections` is `NaN` or negative | `Must be >= 0` |
| `--event-ring-size > 1_000_000` | Thrown during bridge construction |
| `--allow-origin '*'` without token | `Refusing to start with --allow-origin '*' but no bearer token configured` |
| Non-loopback HTTP(S) `--allow-origin` without token | `Refusing to start with --allow-origin ... but no bearer token configured` |
| `--prompt-deadline-ms` / `--writer-idle-timeout-ms` is not a positive integer | `Must be a positive integer` |
| `--initialize-timeout-ms` is not a positive integer or exceeds `2^31-1` | `Must be a positive integer` / `Exceeds maximum JS timer delay` |
| Unknown `policy.permissionStrategy` or non-positive `policy.consensusQuorum` | `InvalidPolicyConfigError` |
A non-loopback bind with NO token source at all is not a refusal: the daemon generates an ephemeral 128-bit bearer (16 random bytes, 22 base64url characters), prints it once at startup, and rotates it on every restart (see the remote quickstart in `docs/users/qwen-serve.md`). Loopback spellings never generate. The `--open-with-auth` generator is separate and larger — 32 bytes, a 256-bit bearer — and only runs on loopback.
Every token guard below reads the **resolved** token, i.e. after that generation step. That is what makes the `--require-auth` and `--allow-origin` refusals loopback-only: on a non-loopback bind a generated bearer is already in place.
| Scenario | Error prefix |
| ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| Non-loopback bind with an explicitly empty token source (`--token ''` / `QWEN_SERVER_TOKEN=''`) | `Refusing to bind ... without a bearer token` |
| `--require-auth` on a loopback bind with no token source (`--token`, `QWEN_SERVER_TOKEN`, `--open-with-auth`) | `Refusing to start with --require-auth set but no bearer token` |
| `--workspace` does not exist, is not a directory, or is not absolute | `Invalid --workspace ...` |
| `--workspace` stat permission denied | `Invalid --workspace ...: permission denied` |
| `--mcp-client-budget` is not a positive integer | `Must be a positive integer` |
| `--mcp-budget-mode=enforce` without budget | `requires a positive mcpClientBudget` |
| `--hostname` is written as `localhost:4170` | `looks like a "host:port" combination. Use --port` |
| `--hostname [::1]:8080` | `Invalid --hostname ... brackets indicate an IPv6 literal but the value is not a clean [addr] form` |
| `--max-connections` is `NaN` or negative | `Must be >= 0` |
| `--event-ring-size > 1_000_000` | Thrown during bridge construction |
| `--allow-origin '*'` on a token-less loopback bind | `Refusing to start with --allow-origin '*' but no bearer token configured` |
| Non-loopback HTTP(S) `--allow-origin` on a token-less loopback bind | `Refusing to start with --allow-origin ... but no bearer token configured` |
| `--prompt-deadline-ms` / `--writer-idle-timeout-ms` is not a positive integer | `Must be a positive integer` |
| `--initialize-timeout-ms` is not a positive integer or exceeds `2^31-1` | `Must be a positive integer` / `Exceeds maximum JS timer delay` |
| Unknown `policy.permissionStrategy` or non-positive `policy.consensusQuorum` | `InvalidPolicyConfigError` |
## 7. Curl verification checklist
@@ -205,13 +213,18 @@ curl -N \
open http://127.0.0.1:4170/
```
When bearer auth is enabled, add `-H "Authorization: Bearer $QWEN_SERVER_TOKEN"` to every request.
When bearer auth is enabled, add `-H "Authorization: Bearer $QWEN_SERVER_TOKEN"` to every request. Under the generated-token flow that variable is never set — the daemon prints the bearer once at startup and does not export it — so paste the printed value into your own shell first:
```bash
TOKEN='<printed bearer>'
curl -s -H "Authorization: Bearer $TOKEN" http://<bind>:4170/capabilities | jq
```
## 8. Is there a browser UI?
**Yes — the Web Shell.** `resolveWebShellDir()` finds the built assets (bundled next to the CLI bundle in a release, `packages/web-shell/dist` in a checkout) and `mountWebShellAssets()` serves them at `/`, `/assets`, and `/session/:id` document navigations (browser deep links — a plain `curl /session/<id>` gets the API's 401/404, not the shell). When the assets are missing the daemon degrades to API-only instead of crashing; `--no-web` opts out explicitly.
The static shell is mounted **before** `bearerAuth` in every launch mode — a browser cannot attach an `Authorization` header to an address-bar navigation or a `<script src>` subresource, so gating it would just break the UI. API calls use trusted-loopback authority on the token-less primary listener or attach the configured bearer in authenticated modes. On a non-loopback bind the shell is read-only unless `--allow-origin <origin>` is passed — same-origin POSTs carry an `Origin` header that the CORS wall rejects (403) — so pass `--allow-origin` for any bind beyond loopback.
The static shell is mounted **before** `bearerAuth` in every launch mode — a browser cannot attach an `Authorization` header to an address-bar navigation or a `<script src>` subresource, so gating it would just break the UI. API calls use trusted-loopback authority on the token-less primary listener or attach the configured bearer in authenticated modes. On a non-loopback bind with a token the shell's same-origin **HTTP** requests need no allowlist: an `Origin` equal to the direct socket scheme plus the normalized `Host` authority is bearer-authenticated and then stripped ahead of the CORS wall, so its POSTs go through. Three cases still need an explicit `--allow-origin <origin>` — the WebSocket upgrades behind the terminal and voice features; browsers reaching the daemon through a TLS-terminating proxy, whose `https` origin never matches the plain socket; and any plain-HTTP intermediary that rewrites the `Host` header (nginx's default `proxy_set_header Host $proxy_host`, k8s Ingress), whose forwarded `Host` then no longer matches the browser's `Origin`. Forwarded headers are never trusted to establish same-origin, so the fix is the allowlist entry or a proxy that forwards `Host` verbatim; port translation alone (`docker -p 8080:4170`) needs nothing **on a non-loopback bind**, because only the forwarded `Host` is compared — on the default **loopback** bind the Host gate rejects the translated port with `403 Invalid Host header` for every request including the shell document, and `--allow-origin` cannot override it, so forward the same port (`ssh -L 4170:localhost:4170`) or bind non-loopback.
CSP is built by `buildWebShellCsp()` and is deliberately looser than a static page's (`'unsafe-inline'` for the inline `performance.measure` patch, `eval`/wasm/blob workers for shiki and mermaid, `data:` for katex fonts, `connect-src 'self'` for SSE). `frame-ancestors 'none'` plus `X-Frame-Options: DENY` block clickjacking, except when an extension origin is explicitly allowed via `--allow-origin` so the UI can be hosted in a Chrome side panel (#5626).
@@ -240,9 +253,9 @@ commands/serve.ts await runQwenServe({...})
|
v
serve/run-qwen-serve.ts runQwenServe(opts, deps)
| |- trim token
| |- resolve token (trim / env fallback / non-loopback ephemeral generation)
| |- hostname mismatch fallback
| |- auth preflight
| |- auth preflight (reads the resolved token)
| |- workspace validation + canonicalization
| |- MCP budget validation + childEnvOverrides
| |- loadSettings + validatePolicyConfig
@@ -255,7 +268,7 @@ serve/run-qwen-serve.ts const app = createServeApp(opts, () => actu
|
v
serve/server.ts createServeApp() - builds Express app (**does not listen**)
| |- middleware chain (Host allowlist / CORS / bearerAuth / mutation gate / rate limit)
| |- middleware chain (loopback Origin strip / access log + trace id / Host allowlist / remote same-origin strip / CORS / pre-auth health + shell / webhooks / bearerAuth / rate limit / JSON / telemetry / per-route mutation gate)
| |- route mounting (health / web-shell static / capabilities / workspace / session / SSE / ACP HTTP)
| `- return app
|
@@ -115,3 +115,9 @@ The release process is automated through GitHub Actions. The release workflow pe
1. Build the NPM packages using `tsc`.
2. Publish the NPM packages to the artifact registry.
3. Create GitHub releases with bundled assets.
**OpenTUI preview flavor**
Standalone archives ship in the classic Node.js flavor by default. Setting the `OPENTUI_PREVIEW_RELEASE_ENABLED` repository variable to `true` additionally builds the bun/OpenTUI preview flavor, whose archives carry an `-opentui-preview` suffix and whose launcher defaults `QWEN_TUI_RENDERER` to `opentui`. Leaving the variable unset keeps that flavor off.
The variable decides only what a new release builds. The workflow that mirrors a release to Aliyun OSS does not read it: that workflow can be re-dispatched for any tag, and a tag cut before the flavor existed has no preview archives to verify, so it derives the flavor from the archives the release actually shipped.
@@ -13,7 +13,7 @@ qwen serve --no-web --port 4170 \
# → qwen serve listening on http://127.0.0.1:4170 (mode=http-bridge, workspace=/path/to/project-a)
```
`--no-web` removes the Web Shell assets; it does not select a smaller REST/SSE API profile. Each `--workspace` value must be an absolute directory. The first startup workspace is primary and remains the compatibility default for requests that omit `cwd`; `/capabilities.workspaces[]` is the catalog clients should use when selecting any runtime explicitly.
`--no-web` removes the Web Shell assets and the surfaces bound to them: `POST /workspace/local-control/enable` then fails closed with `409 local_control_web_shell_unavailable` on every platform, and on macOS the `/live/*` routes, the `/live/host` WebSocket and the `experimental.liveVoice.*` settings keys are not registered — so an integration that drives the SDK's Live methods must run without `--no-web`. It is not a feature-profile switch: the session, prompt, workspace, permission and SSE routes are unchanged. Each `--workspace` value must be an absolute directory. The first startup workspace is primary and remains the compatibility default for requests that omit `cwd`; `/capabilities.workspaces[]` is the catalog clients should use when selecting any runtime explicitly.
The token-less loopback default is intended for a single-user workstation. On a shared host, set `QWEN_SERVER_TOKEN` and add `--require-auth`; non-loopback binds require a token.
+6 -6
View File
@@ -4,7 +4,7 @@ Stage 1 of the [qwen-code daemon design](https://github.com/QwenLM/qwen-code/iss
## Authentication
When the daemon was started with `--token` or `QWEN_SERVER_TOKEN`, **every normal API route except `/health` on ordinary loopback binds** must carry:
When the daemon was started with `--token` or `QWEN_SERVER_TOKEN` — or bound non-loopback with neither, which generates an ephemeral bearer and prints it once at startup — **every normal API route except `/health` on ordinary loopback binds** must carry:
```
Authorization: Bearer <token>
@@ -12,20 +12,20 @@ Authorization: Bearer <token>
Without a configured token on the loopback default, the header is optional and requests arriving through the primary listener have full operator API authority. Workspace trust, session ownership, `X-Qwen-Client-Id`, permission, feature, validation, and resource checks still apply. Token comparison is constant-time. 401 responses are uniform across `missing header` / `wrong scheme` / `wrong token`.
**`--open-with-auth`.** This default-off CLI mode requires a loopback bind and an available Web Shell. It reuses the normal `--token`-over-`QWEN_SERVER_TOKEN` selection, or generates 32 random bytes encoded as base64url before daemon startup when that selection is empty. The browser receives the selected bearer through `#token=` and stores it per tab; the protocol and middleware see an ordinary configured token. Bare `--open`, direct embedded callers, non-loopback binds, and other clients do not receive automatic credentials. Browser-ineligible environments print the secret-bearing fragment URL for manual opening. Loopback `/health` and static Web Shell assets retain the exemptions described below; `--require-auth` still gates `/health`.
**`--open-with-auth`.** This default-off CLI mode requires a loopback bind and an available Web Shell. It reuses the normal `--token`-over-`QWEN_SERVER_TOKEN` selection, or generates 32 random bytes encoded as base64url before daemon startup when that selection is empty. The browser receives the selected bearer through `#token=` and stores it per tab; the protocol and middleware see an ordinary configured token. Fragment delivery keys on the resolved token, not on this flag: any `--open` launch attaches the resolved bearer — configured or generated — to the launched URL's `#token=` fragment (visible to local users via `ps` / `/proc`, as the launcher warns), so a non-loopback bind with bare `--open` hands its generated bearer to the browser the same way; this flag's distinct contributions are token _generation_ on loopback and the browser-ineligible manual-URL fallback. Only direct embedded callers that ignore `RunHandle.resolvedToken`, and clients that never launch a browser, receive no automatic credential. Browser-ineligible environments print the secret-bearing fragment URL for manual opening. Loopback `/health` and static Web Shell assets retain the exemptions described below; `--require-auth` still gates `/health`.
Channel webhook ingress (`POST /channels/:channelName/webhooks/:source`) is separate from this bearer contract in every mode. When mounted, it is registered before `bearerAuth` and authenticates with its configured `x-qwen-webhook-secret`; rotating the daemon bearer does not rotate webhook source secrets.
**`/health` exemption** (Bctum): on loopback binds (`127.0.0.0/8` / `localhost` / `::1` / `[::1]`) `/health` is registered BEFORE the bearer middleware, so liveness probes inside the pod don't need to carry the token even when the daemon was started with `--token`. Non-loopback binds (`--hostname 0.0.0.0` etc.) gate `/health` with the other normal API routes — see the [`GET /health`](#get-health) section for the rationale.
**`--require-auth` (#4175 PR 15).** Pass this flag at boot to extend the "must have a token" rule to loopback as well. Boot fails without a token; the `/health` exemption is dropped (so `/health` also requires `Authorization: Bearer …`).
**`--require-auth` (#4175 PR 15).** Pass this flag at boot to extend the "must have a token" rule to loopback as well. Boot fails when no token source resolves — on loopback that means `--token`, `QWEN_SERVER_TOKEN`, or `--open-with-auth` (which installs its own generated token before boot, so `--require-auth --open-with-auth` starts). The fail-fast is loopback-only: a non-loopback bind resolves the ephemeral bearer it generates when neither configured source is present, and that satisfies the flag. The `/health` exemption is dropped either way, so `/health` also requires `Authorization: Bearer …`.
When the flag is on, the global `bearerAuth` middleware gates **every normal API route** — including `/health` and `/capabilities`. Channel webhook ingress remains independently shared-secret-authenticated, and Web Shell document and asset routes remain pre-auth. An **unauthenticated** client therefore cannot pre-flight `caps.features` to discover that auth is required: the discovery surface for that case is the **401 response body** itself (uniform across bearer-gated routes per the [Authentication](#authentication) section). The `require_auth` capability tag is a **post-authentication confirmation** — once a client successfully authenticates and reads `/capabilities`, the tag's presence confirms the daemon was started with `--require-auth` (useful for audit / compliance UIs and for SDK clients to surface "this deployment is hardened" in a settings panel). Strict mutation routes accept trusted-loopback primary-listener requests, bearer-authenticated requests, or paired Local Control requests. Non-trusted token-less embeds still receive `401 { code: "token_required", error: "…" }`; with `--require-auth`, global bearer middleware rejects first with the legacy `Unauthorized` body.
**`--allow-origin <pattern>` (T2.4 [#4514](https://github.com/QwenLM/qwen-code/issues/4514)).** Browser clients hitting the daemon cross-origin are blocked by default — any request carrying an `Origin` header returns `403 {"error":"Request denied by CORS policy"}` because CLI/SDK clients never send `Origin` and the daemon treats its presence as a sign the request came from a browser context the operator has not opted into. Pass `--allow-origin <pattern>` (repeatable) at boot to install an allowlist instead of the wall. Each pattern is either:
**`--allow-origin <pattern>` (T2.4 [#4514](https://github.com/QwenLM/qwen-code/issues/4514)).** Browser clients hitting the daemon cross-origin are blocked by default — a request carrying an `Origin` header returns `403 {"error":"Request denied by CORS policy"}` because CLI/SDK clients never send `Origin` and the daemon treats its presence as a sign the request came from a browser context the operator has not opted into. One exception precedes the wall on a non-loopback bind with a token: a same-origin request (`Origin` equal to the direct socket scheme plus the normalized `Host` authority) is bearer-authenticated and its `Origin` stripped **on the primary listener** — with a valid bearer the route's own status follows, with a missing or invalid bearer a `401`, **except the pre-auth Web Shell document, `/assets/*` and `/mcp-app-sandbox` routes, which are served without a credential as in every other mode** — and only cross-origin or non-matching `Origin` values keep the `403` envelope. Pass `--allow-origin <pattern>` (repeatable) at boot to install an allowlist instead of the wall. Each pattern is either:
- The literal `*` — admit any origin. **Risky**: boot refuses when `*` is configured but no bearer token is set (any source: `--token`, `QWEN_SERVER_TOKEN`, or `--require-auth` which mandates a token at boot). The boot breadcrumb emits a stderr warning when `*` is in the list. **Recommendation**: pair with `--require-auth` on loopback binds so `/health` is also gated by the bearer — it's registered before the bearer middleware on loopback by default (so k8s/Compose probes can reach it without a token), and a `*` allowlist makes it reachable from any cross-origin browser. `--require-auth` still leaves the Web Shell static assets (`/`, `/assets/*`, and `/session/:id` document navigations) pre-auth on loopback by design — they are mounted before the bearer middleware — so under a `*` allowlist they remain readable from any cross-origin browser; `--no-web` removes that surface. On non-loopback binds the bearer is already mandatory at boot and `/health` is registered behind it. Normal API routes are bearer-gated, channel webhook ingress retains its own shared-secret gate, and Web Shell static assets (`/`, `/assets/*`, and `/session/:id` document navigations) remain pre-auth unless `--no-web` removes them.
- A canonical URL origin — `<scheme>://<host>[:<port>]`. **No trailing slash, no path, no userinfo, no query.** Boot refuses with `InvalidAllowOriginPatternError` if the entry fails the round-trip `new URL(pattern).origin === pattern`; the error message names the bad pattern and the canonical form. Strict-by-intent: silent normalization (e.g. trimming a trailing `/`) would let typos slip through and accept ambiguous input. Without a token, HTTP(S) entries are limited to loopback hosts; a non-loopback browser origin requires a token because it can otherwise drive the full operator API, including code execution as the daemon user. Explicit browser-extension origins keep their existing tokenless local-automation path. Startup logs the authority granted to any tokenless allowed browser origin.
- The literal `*` — admit any origin. **Risky**: boot refuses when `*` is configured but no bearer token resolves. The guard reads the _resolved_ token — `--token`, `QWEN_SERVER_TOKEN`, `--open-with-auth`'s generated loopback token, or the ephemeral bearer a non-loopback bind generates when neither configured source is present — so this refusal is loopback-only. The boot breadcrumb emits a stderr warning when `*` is in the list. **Recommendation**: pair with `--require-auth` on loopback binds so `/health` is also gated by the bearer — it's registered before the bearer middleware on loopback by default (so k8s/Compose probes can reach it without a token), and a `*` allowlist makes it reachable from any cross-origin browser. `--require-auth` still leaves the Web Shell static assets (`/`, `/assets/*`, and `/session/:id` document navigations) pre-auth on loopback by design — they are mounted before the bearer middleware — so under a `*` allowlist they remain readable from any cross-origin browser; `--no-web` removes that surface. On non-loopback binds the bearer is already mandatory at boot and `/health` is registered behind it. Normal API routes are bearer-gated, channel webhook ingress retains its own shared-secret gate, and Web Shell static assets (`/`, `/assets/*`, and `/session/:id` document navigations) remain pre-auth unless `--no-web` removes them.
- A canonical URL origin — `<scheme>://<host>[:<port>]`. **No trailing slash, no path, no userinfo, no query.** Boot refuses with `InvalidAllowOriginPatternError` if the entry fails the round-trip `new URL(pattern).origin === pattern`; the error message names the bad pattern and the canonical form. Strict-by-intent: silent normalization (e.g. trimming a trailing `/`) would let typos slip through and accept ambiguous input. Without a resolved token — which after generation means a loopback bind — HTTP(S) entries are limited to loopback hosts; a non-loopback browser origin requires a token because it can otherwise drive the full operator API, including code execution as the daemon user. Explicit browser-extension origins keep their existing tokenless local-automation path. Startup logs the authority granted to any tokenless allowed browser origin.
Matched origins receive the standard CORS response headers on every request:
+1 -1
View File
@@ -56,7 +56,7 @@
| TodoWrite | `V0.0.10` | Task management and progress tracking | Coding Workflow | 1 |
| Tool: TextSearch | `V0.0.8+` | Text search tool (grep, supports .qwenignore) | Coding Workflow | 1 |
| Tool: WebFetch | `V0.0.7+` | Web content fetching tool | Coding Workflow | 1 |
| Tool: WebSearch | `V0.0.7+` | Web search tool (using Tavily API) | Coding Workflow | 1 |
| Tool: WebSearch | `V0.0.7+` | Built-in web search (DashScope Responses API) | Coding Workflow | 1 |
| OAuth | `V0.0.5+` | OAuth login authentication (Qwen OAuth) | User Experience | 1 |
#### Features to Develop
+2 -1
View File
@@ -47,6 +47,7 @@ Qwen Code's built-in tools can be broadly categorized as follows:
- **[Shell Tool](./shell.md) (`run_shell_command`):** For executing shell commands.
- **[Monitor Tool](./monitor.md) (`monitor`):** For running long-lived shell commands that stream output back as background task notifications.
- **[Web Fetch Tool](./web-fetch.md) (`web_fetch`):** For retrieving content from URLs.
- **[Web Search Tool](./web-search.md) (`web_search`):** For searching the web. Available at startup when a supported provider can back it.
- **[Todo Write Tool](./todo-write.md) (`todo_write`):** An opt-in tool for creating and managing structured task lists during coding sessions.
- **[Agent Tool](./task.md) (`agent`):** For delegating complex tasks to specialized subagents.
- **[Exit Plan Mode Tool](./exit-plan-mode.md) (`exit_plan_mode`):** For exiting plan mode and proceeding with implementation.
@@ -55,5 +56,5 @@ Additionally, these tools incorporate:
- **[MCP servers](./mcp-server.md)**: MCP servers act as a bridge between the model and your local environment or other services like APIs.
- **[MCP User Guide](../../users/features/mcp.md)**: Configure MCP servers and manage them from Qwen Code
- **[Web Search via MCP](./web-search.md)**: Connect to web search services (Bailian, Tavily, GLM) through MCP
- **[Web Search via MCP](./web-search.md)**: Connect additional web search services (Bailian, Tavily, GLM) through MCP
- **[Sandboxing](./sandbox.md)**: Sandboxing isolates the model and its changes from your environment to reduce potential risk.
+49 -21
View File
@@ -2,12 +2,38 @@
Qwen Code provides web search two ways:
1. **Built-in `web_search` tool** (opt-in) — backed by the DashScope Responses API server-side search. Works with a standard Bailian (DashScope) API key; no extra provider or MCP setup.
2. **MCP (Model Context Protocol) integrations** — connect any external search service (Tavily, GLM, and others). Use this when you don't have a DashScope key.
1. **Built-in `web_search` tool** — backed by the DashScope Responses API server-side search. On by default at startup for supported ModelStudio and OpenAI-compatible DashScope configurations; no extra provider or MCP setup.
2. **MCP (Model Context Protocol) integrations** — connect any external search service (Tavily, GLM, and others). Use this when your provider cannot back the built-in tool.
## Built-in `web_search` (opt-in)
## Built-in `web_search`
The built-in tool issues a self-contained search request to a small auxiliary model with DashScope's server-side `web_search` (and `web_extractor`) tools, and returns the narrated findings plus source URLs. It never activates implicitly — two settings are required:
The built-in tool issues a self-contained search request to a small auxiliary model with DashScope's server-side `web_search` (and `web_extractor`) tools, and returns the narrated findings plus source URLs.
### When it turns on by itself
If you configured nothing under `tools.webSearch`, the tool registers whenever the model you are running can back the search request with the same credentials:
| How you signed in | Built-in search |
| -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| Alibaba ModelStudio → **Standard API Key** | on |
| Alibaba ModelStudio → **Token Plan** | on |
| Alibaba ModelStudio → **Coding Plan** | off — its endpoint is not verified for this API |
| An OpenAI-compatible `modelProviders` or Custom Provider entry on a recognized DashScope Responses host, with a direct key | on |
| Third-party providers (OpenRouter, DeepSeek, ModelScope, …), custom endpoints on other hosts, local models | off |
Searches bill the same key as your main model. Permission handling follows the active approval mode and rules; in `default` approval mode the first search asks for confirmation. When your provider cannot back the tool, it simply does not appear at startup — no startup warning.
To turn it off:
```json
{ "tools": { "webSearch": { "enabled": false } } }
```
or `ENABLE_WEB_SEARCH=false`. Bare mode and safe mode always disable it.
### Configuring it explicitly
Point the tool at a ModelStudio Standard/Token Plan or another verified DashScope Responses entry. This is useful when your main model runs on another provider and you also hold a separate supported DashScope key. Coding Plan hosts are excluded from automatic activation because the Responses search tools are not verified there. You can opt in explicitly with `tools.webSearch.model`; if the endpoint does not serve them, the first search fails loudly. Use an MCP search provider if you do not want to rely on that unverified path.
```json
{
@@ -29,11 +55,11 @@ The built-in tool issues a self-contained search request to a small auxiliary mo
}
```
| Setting | Env override | Meaning |
| ------------------------------ | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tools.webSearch.enabled` | `ENABLE_WEB_SEARCH` | Opt-in flag. Required. |
| `tools.webSearch.model` | `WEB_SEARCH_MODEL` | Search model selector, resolved against `modelProviders` like `fastModel` (`modelId` or `authType:modelId`). Required — no default. Recommended: `qwen3.6-plus`. |
| `tools.webSearch.webExtractor` | `WEB_SEARCH_EXTRACTOR` | Let the search agent open result pages for better-grounded answers (default `true`; billed separately by DashScope). |
| Setting | Env override | Meaning |
| ------------------------------ | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tools.webSearch.enabled` | `ENABLE_WEB_SEARCH` | Set `false` to turn the tool off. Implicit startup activation requires leaving `enabled`, `model`, and the env-only backend unset. Setting `true` permits automatic derivation only when the env-only backend is also unset; otherwise a `model` is required. |
| `tools.webSearch.model` | `WEB_SEARCH_MODEL` | Search model selector for the explicit path (`modelId` or `authType:modelId`). With `WEB_SEARCH_BASE_URL` it is the plain model id for that endpoint; otherwise it must match a declared DashScope-compatible `modelProviders` entry. The automatic path uses `qwen3.6-plus`. |
| `tools.webSearch.webExtractor` | `WEB_SEARCH_EXTRACTOR` | Let the search agent open result pages for better-grounded answers (default `true`; billed separately by DashScope). |
### Env-only configuration (no settings.json)
@@ -57,28 +83,30 @@ model id. The API key is read from `WEB_SEARCH_API_KEY` if set, otherwise from
Notes:
- The selector must resolve to a DashScope-compatible `modelProviders` entry carrying a direct API key via `envKey`. Your main model can be any provider — only the search side request needs a DashScope entry. Qwen OAuth cannot back the tool.
- If enabled but misconfigured, the tool stays off and a startup notice explains which condition failed.
- Searches bill your DashScope key (`usage.x_tools` counts). The tool asks for confirmation by default; approving with "always allow" persists a standard `WebSearch` permission rule, like other tools.
- Which providers can activate the tool is decided at startup. Once active, the search backend follows the currently selected model on the next search; switching to an unsupported provider makes that invocation fail, while switching from a session where the tool was absent still requires a restart to register it.
- Automatic host detection intentionally accepts only known DashScope regional, Token Plan MaaS, and internal Alibaba hosts. Generic `*.alicloudapi.com` gateways and `DASHSCOPE_PROXY_BASE_URL` are excluded because they are not known to forward the Responses search tools.
- If enabled explicitly but misconfigured, the tool stays off and a startup notice explains which condition failed. Automatic activation never emits a notice.
- Searches bill your DashScope key (`usage.x_tools` counts). Auto approval mode (the default) lets the classifier approve searches without prompting; in `default` approval mode the tool asks, and approving with "always allow" persists a standard `WebSearch` permission rule, like other tools.
- There is no client-side model allowlist; a model the Responses endpoint does not serve fails loudly on first use.
## MCP alternatives
If you don't have a DashScope key, web search is available by connecting an external MCP server — see the services below.
If your provider cannot back the built-in tool, web search is available by connecting an external MCP server — see the services below.
## ⚠️ Historical Breaking Change: original built-in `web_search` removed
> **Affected versions:** `V0.0.7+` through the last release with the original multi-provider built-in web search.
The original built-in `web_search` tool (Tavily/Google/GLM/DashScope multi-provider) and its configuration were **removed**. The new opt-in built-in tool above is a different implementation with different configuration. If you were using any of the following, migrate either to the new built-in tool (DashScope) or to MCP:
The original built-in `web_search` tool (Tavily/Google/GLM/DashScope multi-provider) and its configuration were **removed**. The built-in tool documented above is a different implementation with different configuration. If you were using any of the following, migrate either to the new built-in tool (DashScope) or to MCP:
| Removed | What to do |
| ---------------------------------------------------------------------- | ----------------------------------------------------------------- |
| `webSearch` block in `settings.json` | Configure an MCP server in `mcpServers` instead (see below) |
| `advanced.tavilyApiKey` in `settings.json` | Use the [Tavily MCP server](#tavily-websearch) |
| `TAVILY_API_KEY` environment variable | Use the [Tavily MCP server](#tavily-websearch) |
| `DASHSCOPE_API_KEY` for web search | Use the [built-in `web_search` tool](#built-in-web_search-opt-in) |
| `GLM_API_KEY` for web search | Use the [GLM WebSearch Prime MCP](#glm-websearch-prime-zhipuai) |
| `--tavily-api-key` / `--glm-api-key` / `--dashscope-api-key` CLI flags | Configure via `mcpServers` in `settings.json` |
| Removed | What to do |
| ---------------------------------------------------------------------- | --------------------------------------------------------------- |
| `webSearch` block in `settings.json` | Configure an MCP server in `mcpServers` instead (see below) |
| `advanced.tavilyApiKey` in `settings.json` | Use the [Tavily MCP server](#tavily-websearch) |
| `TAVILY_API_KEY` environment variable | Use the [Tavily MCP server](#tavily-websearch) |
| `DASHSCOPE_API_KEY` for web search | Use the [built-in `web_search` tool](#built-in-web_search) |
| `GLM_API_KEY` for web search | Use the [GLM WebSearch Prime MCP](#glm-websearch-prime-zhipuai) |
| `--tavily-api-key` / `--glm-api-key` / `--dashscope-api-key` CLI flags | Configure via `mcpServers` in `settings.json` |
### Migration Examples
@@ -0,0 +1,253 @@
# DingTalk Tool Permission Cards Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Present ordinary DingTalk tool permission requests as owner-bound native cards while preserving exact permission semantics and text fallback.
**Architecture:** Add an adapter-neutral permission presentation hook beside the existing user-question hook, then implement DingTalk lifecycle and callback handling in a focused controller. Reuse the existing question card template and callback transport; keep ACP, daemon, core, and other adapters unchanged.
**Tech Stack:** TypeScript, Vitest, DingTalk Card OpenAPI, existing ChannelBase permission relay
**Spec:** `docs/design/2026-08-29-dingtalk-permission-cards.md`
## Global Constraints
- Minimum code that solves GitHub Issue #10388; no speculative abstraction.
- ESM, strict TypeScript, no `any`, kebab-case `.ts` files, collocated tests.
- Preserve `/approve`, `/approve-always`, and `/deny` fallback behavior.
- No `packages/core`, ACP, daemon route, provider, model, auth, or unrelated package behavioral changes.
- Do not claim real DingTalk delivery without callback and platform evidence.
---
### Task 1: Define the Channel permission-presentation seam
**Files:**
- Modify: `packages/channels/base/src/types.ts`
- Modify: `packages/channels/base/src/index.ts`
- Modify: `packages/channels/base/src/ChannelBase.ts`
- Test: `packages/channels/base/src/ChannelBase.test.ts`
**Interfaces:**
- Consumes: existing pending permission options, attended prompt owner, output-segment boundary, and `respondToUserInput()` serialization.
- Produces: `ChannelPermissionRequestContext` and `presentPermissionRequest(context): Promise<UserInputPresentationResult>`.
- [ ] **Step 1: Write failing tests for ordinary structured presentation**
```ts
it('presents an attended ordinary permission before text fallback', async () => {
channel.permissionPresentation = { kind: 'presented' };
await channel.dispatchPermissionRequest(permissionRequest());
expect(
channel.presentedPermission?.decisions.map(({ kind }) => kind),
).toEqual(['allow_once', 'allow_always', 'deny']);
expect(channel.sent).toEqual([]);
});
it('uses text fallback when permission presentation is unsupported', async () => {
channel.permissionPresentation = { kind: 'unsupported' };
await channel.dispatchPermissionRequest(permissionRequest());
expect(channel.sent.at(-1)).toContain('/approve');
});
```
- [ ] **Step 2: Run the focused test and verify RED**
Run: `cd packages/channels/base && npx vitest run src/ChannelBase.test.ts -t 'ordinary permission'`
Expected: FAIL because the permission hook and captured context do not exist.
- [ ] **Step 3: Add the minimal types, hook, and dispatch branch**
```ts
protected async presentPermissionRequest(
_context: ChannelPermissionRequestContext,
): Promise<UserInputPresentationResult> {
return { kind: 'unsupported' };
}
```
Build the context only for an attended non-loop prompt, derive its advertised decisions from the original options, and call the hook after question presentation returns unsupported but before formatting text.
- [ ] **Step 4: Add failing response-race and settlement tests, then implement one-shot response wiring**
```ts
const [cardResult, commandResult] = await Promise.all([
context.respond('allow_once'),
channel.receive('/deny'),
]);
expect(bridge.respondToPermission).toHaveBeenCalledTimes(1);
expect([cardResult, commandResult]).toContain(true);
```
Use the existing pending response promise for both presentation kinds. Preserve the question-only command warning, owner checks, chat/thread lookup, and settlement listeners.
- [ ] **Step 5: Run the complete ChannelBase test file**
Run: `cd packages/channels/base && npx vitest run src/ChannelBase.test.ts`
Expected: all ChannelBase tests pass.
### Task 2: Implement the DingTalk permission-card lifecycle
**Files:**
- Create: `packages/channels/dingtalk/src/permission-card-controller.ts`
- Create: `packages/channels/dingtalk/src/permission-card-controller.test.ts`
**Interfaces:**
- Consumes: `ChannelPermissionRequestContext`, `DingtalkInteractiveCardClient`, `DingtalkCardCallback`, and the existing question template.
- Produces: `present(context, target)`, `claim(callback)`, and `cancelRun(runId)`.
- [ ] **Step 1: Write failing rendering and decision tests**
```ts
await controller.present(context, { chatId: 'cid-1', isGroup: true });
expect(client.createAndDeliver).toHaveBeenCalledWith(
expect.objectContaining({
cardParamMap: expect.objectContaining({
form: {
fields: [expect.objectContaining({ name: 'permission_decision' })],
},
}),
}),
);
```
Assert allow-once and deny are rendered, allow-always is rendered only when advertised, and an accepted form value reaches `context.respond()` as the corresponding literal decision.
- [ ] **Step 2: Run the controller test and verify RED**
Run: `cd packages/channels/dingtalk && npx vitest run src/permission-card-controller.test.ts`
Expected: FAIL because the controller module does not exist.
- [ ] **Step 3: Implement reserved, pending, claimed, and terminal records**
```ts
type PermissionCardState = 'reserved' | 'pending' | 'claimed' | 'terminal';
type PermissionCardTerminalState =
| 'approved'
| 'denied'
| 'cancelled'
| 'expired';
```
Subscribe before delivery, index live records by request and outTrack ID, claim synchronously, delete terminal records, and update delivered cards best-effort.
- [ ] **Step 4: Add failing security and failure-path tests, then implement them**
Cover foreign actors, unknown fields, multiple values, unavailable decisions, duplicate callbacks, delivery failure returning `unsupported`, timeout cancellation, run cancellation, resolution during delivery, and resolution outside the card.
- [ ] **Step 5: Run the controller tests**
Run: `cd packages/channels/dingtalk && npx vitest run src/permission-card-controller.test.ts`
Expected: all permission controller tests pass.
### Task 3: Wire configuration, presenter, adapter, and callback routing
**Files:**
- Modify: `packages/channels/dingtalk/src/interactive-card-types.ts`
- Modify: `packages/channels/dingtalk/src/interactive-card-types.test.ts`
- Modify: `packages/channels/dingtalk/src/index.ts`
- Modify: `packages/channels/dingtalk/src/interaction-presenter.ts`
- Modify: `packages/channels/dingtalk/src/interaction-presenter.test.ts`
- Modify: `packages/channels/dingtalk/src/DingtalkAdapter.ts`
- Modify: `packages/channels/dingtalk/src/DingtalkAdapter.test.ts`
**Interfaces:**
- Consumes: the controller from Task 2 and the Channel hook from Task 1.
- Produces: independently configurable permission cards and callback routing through the existing streamed callback listener.
- [ ] **Step 1: Write failing configuration tests**
```ts
expect(parseDingtalkInteractiveCardConfig(undefined).permissionCard).toEqual({
enabled: true,
timeoutMs: 270_000,
});
expect(() =>
parseDingtalkInteractiveCardConfig({ permissionCard: { timeoutMs: 0 } }),
).toThrow('permissionCard.timeoutMs');
```
- [ ] **Step 2: Run the configuration tests and verify RED, then add parsing and management metadata**
Run: `cd packages/channels/dingtalk && npx vitest run src/interactive-card-types.test.ts`
Expected before implementation: FAIL because `permissionCard` is absent. Expected after implementation: pass.
- [ ] **Step 3: Write failing presenter and adapter wiring tests**
Assert that a matching registered run delegates permission presentation, mismatched owner or target returns `unsupported`, permission callbacks are offered to the permission controller before the question controller, and terminal run cleanup cancels permission cards.
- [ ] **Step 4: Implement the minimal wiring**
```ts
presentPermission(
context: ChannelPermissionRequestContext,
): Promise<UserInputPresentationResult> {
const run = this.runs.get(context.runId);
if (!run || run.terminal || run.ownerId !== context.owner.id) {
return Promise.resolve({ kind: 'unsupported' });
}
return this.options.permissionCards?.present(
context,
this.cardTarget(context.target),
) ?? Promise.resolve({ kind: 'unsupported' });
}
```
Construct the controller only when root cards and `permissionCard.enabled` are true. Override the Channel permission hook and route unclaimed callbacks onward to the question controller.
- [ ] **Step 5: Run all affected DingTalk tests**
Run: `cd packages/channels/dingtalk && npx vitest run src/permission-card-controller.test.ts src/question-card-controller.test.ts src/interaction-presenter.test.ts src/interactive-card-types.test.ts src/DingtalkAdapter.test.ts`
Expected: all affected DingTalk tests pass.
### Task 4: Validate and prepare the Draft PR
**Files:**
- Create: `.qwen/e2e-tests/2026-08-29-dingtalk-permission-cards.md` (ignored evidence)
- Modify if evidence changes: `docs/design/2026-08-29-dingtalk-permission-cards.md`
**Interfaces:**
- Consumes: completed implementation and tests.
- Produces: reproducible reviewer evidence and a template-compliant Draft PR linked to #10388.
- [ ] **Step 1: Run formatting, affected tests, build, and typecheck**
Run: `npm run format && (cd packages/channels/base && npx vitest run src/ChannelBase.test.ts) && (cd packages/channels/dingtalk && npx vitest run src/permission-card-controller.test.ts src/question-card-controller.test.ts src/interaction-presenter.test.ts src/interactive-card-types.test.ts src/DingtalkAdapter.test.ts) && npm run build && npm run typecheck`
Expected: every command exits zero.
- [ ] **Step 2: Perform the repository-required self-audit**
Read `git status --short`, `git diff --check`, the full diff, and all new untracked files. Run two consecutive clean review passes; any fix resets the count and reruns affected verification.
- [ ] **Step 3: Request read-only code review and resolve Critical or Important findings**
Review the exact `origin/main...HEAD` plus working-tree diff against Issue #10388, the design, and this plan. Re-run verification after any fix.
- [ ] **Step 4: Present the complete commit plan for explicit approval**
Use one scoped commit with this message after the user approves:
```text
to #10388 [feat] Present DingTalk permission requests as cards
Co-authored-by: Codex Using gpt-5.6-sol
```
- [ ] **Step 5: After separate push approval, push and create a Draft PR**
Use `.github/pull_request_template.md`, write corresponding English and Chinese sections, include `Closes #10388`, and run `gh pr create --draft --base main --head BenGuanRan:feat/dingtalk-permission-cards-10388`.
@@ -0,0 +1,372 @@
# DingTalk Permission Card Language Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the DingTalk permission card and its immediate text fallback follow Qwen's default UI language, with Chinese copy for Chinese configurations and English for every other value.
**Architecture:** Resolve a two-value locale snapshot (`'zh' | 'en'`) from the already-loaded `general.language` setting at Channel startup and pass it through the existing `ChannelBaseOptions`. `ChannelBase` localizes only the permission decisions and fallback request used by this PR; DingTalk localizes only its permission-card chrome, terminal copy, and the existing card-owner feedback reached by the new card. No session-language request, live synchronization, translation framework, or other Channel copy is added.
**Tech Stack:** TypeScript, Vitest, existing Qwen settings loader, existing Channel permission-presentation seam, DingTalk interactive-card template
**Spec:** `docs/design/2026-08-29-dingtalk-permission-cards.md`
## Global Constraints
- Build on Draft PR #10457 at commit `acf6330c64b8e94aa8aa1be0586aac781367079b`; preserve its permission semantics, callback arbitration, card lifecycle, and configuration shape.
- Read only the merged default setting `settings.merged.general.language`; do not call `/daemon/session/:sessionId/language` and do not write settings.
- Support exactly two rendered locales: Chinese when the normalized setting is `zh`, starts with `zh-`, or is `Chinese`/`中文`; English otherwise, including missing, `auto`, and unsupported values.
- Treat the locale as a startup snapshot. A settings change takes effect after the existing Channel reload or process restart.
- Do not initialize or depend on CLI global i18n state. Do not add locale files, a generic translator, a new configuration field, or localization for unrelated Channel messages.
- Preserve dynamic tool titles and unrecognized daemon-provided decision labels verbatim; translate only known stock permission copy.
- Keep protocol values such as `card_status: 'approved'` unchanged; only user-visible strings are localized.
- Fix the current exact-descriptor test failure caused by the already-added `permissionCard` management field.
---
### Task 1: Resolve and pass the default Channel locale
**Files:**
- Modify: `packages/channels/base/src/ChannelBase.ts:223-250, 998-1025`
- Modify: `packages/cli/src/commands/channel/runtime.ts:1-25, 162-173`
- Modify: `packages/cli/src/commands/channel/start.ts:313-380, 447-515, 597-617`
- Modify: `packages/cli/src/commands/channel/daemon-worker.ts:486-505, 619-650`
- Test: `packages/cli/src/commands/channel/runtime.test.ts`
- Test: `packages/cli/src/commands/channel/start.test.ts`
- Test: `packages/cli/src/commands/channel/daemon-worker.test.ts`
**Interfaces:**
- Consumes: `settings.merged.general?.language`, already loaded by standalone Channel startup and the daemon worker.
- Produces: `ChannelBaseOptions.locale?: 'en' | 'zh'` and `resolveChannelLocale(value: unknown): 'en' | 'zh'`.
- [ ] **Step 1: Add failing resolver tests**
Add focused cases to `runtime.test.ts`:
```ts
expect(resolveChannelLocale('zh')).toBe('zh');
expect(resolveChannelLocale('zh-CN')).toBe('zh');
expect(resolveChannelLocale('Chinese')).toBe('zh');
expect(resolveChannelLocale('中文')).toBe('zh');
expect(resolveChannelLocale('en')).toBe('en');
expect(resolveChannelLocale('auto')).toBe('en');
expect(resolveChannelLocale('ja')).toBe('en');
expect(resolveChannelLocale(undefined)).toBe('en');
```
- [ ] **Step 2: Run the resolver tests and verify RED**
Run: `cd packages/cli && npx vitest run src/commands/channel/runtime.test.ts -t 'channel locale'`
Expected: FAIL because `resolveChannelLocale` is not exported.
- [ ] **Step 3: Add the narrow runtime option and pure resolver**
Add this optional field to `ChannelBaseOptions`:
```ts
/** UI locale snapshot resolved from the default Qwen language setting. */
locale?: 'en' | 'zh';
```
Add a pure helper to `runtime.ts`:
```ts
export function resolveChannelLocale(value: unknown): 'en' | 'zh' {
if (typeof value !== 'string') return 'en';
const normalized = value.trim().toLowerCase().replaceAll('_', '-');
return normalized === 'zh' ||
normalized.startsWith('zh-') ||
normalized === 'chinese' ||
normalized === '中文'
? 'zh'
: 'en';
}
```
Do not read files or environment variables in this helper.
- [ ] **Step 4: Add failing standalone and daemon propagation tests**
In `start.test.ts`, return `general.language: 'zh'` from `mockLoadSettings` and assert every created Channel receives:
```ts
expect(mockCreateChannel.mock.calls[0]?.[3]).toMatchObject({ locale: 'zh' });
```
Cover both the named single-Channel handler and the all-Channels handler. In `daemon-worker.test.ts`, set the worker's loaded setting to `zh` and assert the same option on its `createChannel` call.
- [ ] **Step 5: Pass one resolved snapshot through all three creation paths**
Resolve the locale immediately after each existing `loadSettings()` call. Pass it into `startSingle`/`startAll`, then include `locale` in their `createChannel` options. In `runChannelDaemonWorker`, include the same resolved value in the worker's `createChannel` options.
Do not add locale to persisted Channel configuration and do not make DingTalk read `settings.json` itself.
- [ ] **Step 6: Run all locale propagation tests**
Run: `cd packages/cli && npx vitest run src/commands/channel/runtime.test.ts src/commands/channel/start.test.ts src/commands/channel/daemon-worker.test.ts`
Expected: all three test files pass.
### Task 2: Localize only the shared permission presentation and its fallback
**Files:**
- Modify: `packages/channels/base/src/ChannelBase.ts:223-250, 702-850, 998-1025, 2871-2890, 2932-2940`
- Test: `packages/channels/base/src/ChannelBase.test.ts:1824-1950`
**Interfaces:**
- Consumes: `ChannelBaseOptions.locale` from Task 1 and the existing permission option IDs/names.
- Produces: localized `ChannelPermissionRequestContext.decisions` and localized output from `formatPermissionRequest()`.
- [ ] **Step 1: Add failing Chinese presentation tests**
Construct the existing test Channel with `{ locale: 'zh' }`. Assert the structured context contains Chinese stock labels:
```ts
expect(context.decisions).toEqual([
{ kind: 'allow_once', label: '仅允许本次' },
{ kind: 'allow_always', label: '始终允许此项目' },
{ kind: 'deny', label: '拒绝' },
]);
```
Add a Chinese unsupported-presentation case and assert its text fallback contains all of:
```ts
expect(channel.sent.at(-1)?.text).toContain('运行工具需要授权');
expect(channel.sent.at(-1)?.text).toContain('操作:');
expect(channel.sent.at(-1)?.text).toContain('/approve 仅允许本次');
expect(channel.sent.at(-1)?.text).toContain('/deny 拒绝');
```
Retain the existing English assertions to prove omission of `locale` is backward compatible.
- [ ] **Step 2: Run the focused ChannelBase tests and verify RED**
Run: `cd packages/channels/base && npx vitest run src/ChannelBase.test.ts -t 'permission presentation|text fallback'`
Expected: the new Chinese assertions fail while existing English cases pass.
- [ ] **Step 3: Add a two-entry permission-copy constant and store the locale**
Store `options?.locale ?? 'en'` once on `ChannelBase`. Add one local constant containing only the strings used by `permissionPresentationDecisions()`, `approvalAlwaysLabel()`, and `formatPermissionRequest()`:
```ts
const PERMISSION_COPY = {
en: {
toolUse: 'Tool use',
allowOnce: 'Allow once',
allowAlwaysProject: 'always allow for this project',
allowAlwaysUser: 'always allow for this user',
allowAlways: 'always allow',
deny: 'Deny',
required: 'Permission required to run a tool',
command: 'Command:',
replyWith: 'Reply with:',
},
zh: {
toolUse: '工具调用',
allowOnce: '仅允许本次',
allowAlwaysProject: '始终允许此项目',
allowAlwaysUser: '始终允许此用户',
allowAlways: '始终允许',
deny: '拒绝',
required: '运行工具需要授权',
command: '操作:',
replyWith: '回复以下命令:',
},
} as const;
```
- [ ] **Step 4: Apply copy without changing permission semantics**
Use the selected copy for stock labels and fallback headings. Preserve the original option IDs and response closures. Preserve an unrecognized non-empty `allow_once` name rather than guessing a translation; translate the known stock names `Allow` and `Allow once`, plus the fallback used when the name is absent.
Keep slash commands unchanged. Only their descriptions change by locale.
- [ ] **Step 5: Run the complete ChannelBase test file**
Run: `cd packages/channels/base && npx vitest run src/ChannelBase.test.ts`
Expected: every existing permission race/settlement test and both locale cases pass.
### Task 3: Localize the DingTalk card copy reached by the current PR
**Files:**
- Modify: `packages/channels/dingtalk/src/permission-card-controller.ts:34-47, 242-277, 313-338`
- Modify: `packages/channels/dingtalk/src/DingtalkAdapter.ts:732-843, 1353-1381`
- Test: `packages/channels/dingtalk/src/permission-card-controller.test.ts`
- Test: `packages/channels/dingtalk/src/DingtalkAdapter.test.ts`
**Interfaces:**
- Consumes: `ChannelBaseOptions.locale` and localized decision labels from Task 2.
- Produces: localized DingTalk card chrome, terminal user-visible copy, and owner-only interaction feedback.
- [ ] **Step 1: Add failing Chinese card tests**
Extend the controller harness to accept `locale: 'en' | 'zh' = 'en'`. For `zh`, assert initial card data contains:
```ts
expect.objectContaining({
question_title: '需要授权',
form_btn_text: '提交',
form: {
fields: [expect.objectContaining({ label: '请选择后续操作' })],
},
});
```
Assert the four terminal projections use Chinese descriptions/buttons while retaining these protocol states unchanged: `approved`, `denied`, `expired`, and `cancelled`.
- [ ] **Step 2: Run the controller tests and verify RED**
Run: `cd packages/channels/dingtalk && npx vitest run src/permission-card-controller.test.ts -t 'Chinese|terminal'`
Expected: the new Chinese assertions fail.
- [ ] **Step 3: Add a local two-entry card-copy constant**
Add `locale?: 'en' | 'zh'` to `PermissionCardControllerOptions`, default it to English, and select from this local copy:
```ts
const PERMISSION_CARD_COPY = {
en: {
required: 'Permission required',
submit: 'Submit',
choose: 'Choose how to continue',
approved: ['Permission approved.', 'Approved'],
denied: ['Permission denied.', 'Denied'],
expired: ['This permission request is no longer available.', 'Expired'],
cancelled: ['Permission request cancelled.', 'Cancelled'],
},
zh: {
required: '需要授权',
submit: '提交',
choose: '请选择后续操作',
approved: ['已授权。', '已授权'],
denied: ['已拒绝授权。', '已拒绝'],
expired: ['此授权请求已失效。', '已失效'],
cancelled: ['授权请求已取消。', '已取消'],
},
} as const;
```
Pass `options?.locale` from `DingtalkChannel` when constructing the permission controller.
- [ ] **Step 4: Cover and localize the existing owner-only feedback**
Add adapter tests showing `{ locale: 'en' }` sends the existing card-owner rejection in English and `{ locale: 'zh' }` preserves the current Chinese copy. Select only these four existing strings by locale:
```ts
const english = {
title: 'Card interaction',
group:
'Only the task initiator can operate this card. This action had no effect.',
direct:
'You cannot operate this card. Only the task initiator can submit or stop it.',
};
```
Do not localize other DingTalk status/question-card text in this change.
- [ ] **Step 5: Run affected DingTalk tests**
Run: `cd packages/channels/dingtalk && npx vitest run src/permission-card-controller.test.ts src/interaction-presenter.test.ts src/DingtalkAdapter.test.ts`
Expected: all affected tests pass and existing English snapshots remain unchanged.
### Task 4: Reconcile the current PR, documentation, and verification
**Files:**
- Modify: `packages/cli/src/serve/routes/workspace-channel-management.test.ts:181-217`
- Modify: `docs/design/2026-08-29-dingtalk-permission-cards.md`
- Add: `docs/plans/2026-08-31-dingtalk-permission-card-language.md`
**Interfaces:**
- Consumes: the already-added `interactiveCards.permissionCard` descriptor and the completed locale behavior.
- Produces: green focused CI coverage and reviewer-facing documentation that matches the implementation.
- [ ] **Step 1: Fix the known exact management-descriptor expectation**
Add the existing `permissionCard` descriptor beside `questionCard` in the expected `interactiveCards.properties` array:
```ts
{
key: 'permissionCard',
label: 'Permission Card',
kind: 'object',
properties: [
{ key: 'enabled', label: 'Enabled', kind: 'boolean' },
{
key: 'timeoutMs',
label: 'Timeout (ms)',
kind: 'number',
exclusiveMinimum: 0,
},
],
},
```
Run: `cd packages/cli && npx vitest run src/serve/routes/workspace-channel-management.test.ts`
Expected: the currently failing exact-descriptor test passes.
- [ ] **Step 2: Update the existing design, without adding a second design document**
Add a short “Language behavior” subsection stating:
- the source is merged `general.language` at Channel startup;
- Chinese is used for `zh*`, English otherwise;
- card and immediate text fallback use the same snapshot;
- runtime language switching and non-permission Channel localization remain non-goals;
- no daemon session-language endpoint is used.
Adjust the existing “No daemon changes” wording to the more precise “No daemon route, session-language, ACP, permission-policy, provider, or core changes”; the existing daemon worker only forwards the startup snapshot.
- [ ] **Step 3: Run focused verification**
Run:
```bash
cd packages/cli && npx vitest run \
src/commands/channel/runtime.test.ts \
src/commands/channel/start.test.ts \
src/commands/channel/daemon-worker.test.ts \
src/serve/routes/workspace-channel-management.test.ts
cd ../channels/base && npx vitest run src/ChannelBase.test.ts
cd ../dingtalk && npx vitest run \
src/permission-card-controller.test.ts \
src/question-card-controller.test.ts \
src/interaction-presenter.test.ts \
src/interactive-card-types.test.ts \
src/DingtalkAdapter.test.ts
```
Expected: every command exits zero.
- [ ] **Step 4: Run repository checks required for the changed package boundary**
From repository root run: `npm run build && npm run typecheck && git diff --check`
Expected: build and typecheck exit zero; `git diff --check` prints nothing.
- [ ] **Step 5: Perform the repository-required self-audit**
Read `git status --short` and the complete `git diff origin/main...HEAD`, including this follow-up. Verify two consecutive clean passes. Any correction resets the clean-pass count and reruns the affected tests.
- [ ] **Step 6: Prepare one follow-up commit only after explicit approval**
Stage only the files named in this plan and use one scoped commit:
```text
fix(dingtalk): localize permission cards from default language
```
Do not push or update Draft PR #10457 until separately authorized.
+1 -1
View File
@@ -2,7 +2,7 @@
Qwen Code's first-run `/auth` menu has three top-level options. Pick the one that matches how you want to run the CLI:
- **Alibaba ModelStudio**: official recommended setup. Opens a sub-menu with **Coding Plan** (for individual developers · weekly quota included), **Token Plan** (for teams and companies · usage-based billing with a dedicated endpoint), or **Standard API Key** (connect with an existing ModelStudio API key).
- **Alibaba ModelStudio**: official recommended setup. Opens a sub-menu with **Coding Plan** (for individual developers · weekly quota included), **Token Plan** (for teams and companies · usage-based billing with a dedicated endpoint), or **Standard API Key** (connect with an existing ModelStudio API key). Token Plan and Standard API Key also enable the built-in [`web_search` tool](../../developers/tools/web-search.md) with no extra setup.
- **Third-party Providers**: choose a built-in provider and connect with an API key (DeepSeek, Grok, MiniMax, Z.AI, Kimi, Idealab, ModelScope, OpenRouter, Requesty).
- **Custom Provider**: manually connect a local server, proxy, or unsupported provider — supports OpenAI, Anthropic, Gemini, and other compatible endpoints.
+19 -15
View File
@@ -666,7 +666,7 @@ The merge strategy for `modelProviders` itself is REPLACE: the entire `modelProv
## Reasoning / thinking configuration
The optional `reasoning` field under `generationConfig` controls how aggressively the model reasons before responding. The Anthropic and Gemini converters always honor it. The OpenAI-compatible pipeline honors it **unless** `generationConfig.samplingParams` is set — see the "Interaction with `samplingParams`" caveat below.
The optional `reasoning` field under `generationConfig` controls how aggressively the model reasons before responding. The Anthropic and Gemini converters always honor it. The OpenAI-compatible pipeline honors it **unless** `generationConfig.samplingParams` is set. Known GPT-5 models and GPT-6 Astra are an exception: unrelated sampling keys do not suppress configured effort. See "Interaction with `samplingParams`" below.
```jsonc
{
@@ -693,33 +693,37 @@ The optional `reasoning` field under `generationConfig` controls how aggressivel
### Per-provider behavior
| Protocol / provider | Wire shape | Notes |
| --------------------------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **OpenAI / DashScope** (`qwen3.8-max` family) | Flat `reasoning_effort: <effort>` body parameter | The `/effort` tiers are passed through for any model id starting with `qwen3.8-max` (including dated snapshots and `-latest` aliases); DashScope applies any model-specific mapping. This family's ladder stops at `xhigh`, so a configured `max` is clamped to `xhigh` (logged once) rather than sent and rejected. An explicit `reasoning_effort` in `samplingParams` or `extra_body` is a verbatim override and is not clamped. When `reasoning_effort` and `thinking_budget` conflict, the normal `extra_body` > `samplingParams` > `reasoning` precedence keeps only the higher-priority field; an explicit same-layer pair keeps `reasoning_effort`, matching the provider's behavior before cross-layer resolution. If a static field wins, `/effort` reports that field instead of implying the requested tier is effective. When an effort tier wins, a conflicting `enable_thinking` is also dropped. An explicit `enable_thinking: false` in `extra_body` is honoured rather than dropped: it overrides the configured tier as `reasoning_effort: 'none'`, one of the few places `extra_body` does not win verbatim. Other Qwen models continue to map a selected effort to `enable_thinking: true`; a `reasoning_effort` override passes through there unless it conflicts with a `thinking_budget` (a pair DashScope rejects), in which case the inert `reasoning_effort` is dropped and both `enable_thinking` and `thinking_budget` survive. |
| **OpenAI / DeepSeek** (`api.deepseek.com`) | Flat `reasoning_effort: <effort>` body parameter | When `reasoning.effort` is set in the nested config shape, it's rewritten to flat `reasoning_effort` and `'low'`/`'medium'` are normalized to `'high'`, `'xhigh'` to `'max'` — mirroring DeepSeek's [server-side back-compat](https://api-docs.deepseek.com/zh-cn/api/create-chat-completion). Top-level `samplingParams.reasoning_effort` or `extra_body.reasoning_effort` overrides skip this normalization and ship verbatim. `max` is accepted only on a real DeepSeek hostname; a `deepseek`-named model on another host keeps the generic `xhigh` ceiling, matching the hostname gate on the reshape itself. |
| **OpenAI / Z.ai** (`z.ai`, `bigmodel.cn`) | Flat `reasoning_effort: <effort>` body parameter | GLM-5.2+ on a Z.ai host takes the full ladder, `max` included, and the nested `reasoning.effort` is rewritten to the flat field. Older GLM ids, and a `glm-*` model reached on any other host, keep the generic `xhigh` ceiling: the model name alone says nothing about what that endpoint accepts. |
| **OpenAI** (other compatible servers) | `reasoning: { effort, ... }` passed through | A configured `max` is clamped to `xhigh` (logged once), since `max` is a vendor extension rather than part of the generic OpenAI ladder. Set via `samplingParams` (e.g. `samplingParams.reasoning_effort` for GPT-5/o-series) when the provider expects a different shape; an explicit `samplingParams` / `extra_body` value is not clamped. |
| **Anthropic** (real `api.anthropic.com`) | `output_config: { effort }` plus the `effort-2025-11-24` beta header | Real Anthropic accepts `'low'`/`'medium'`/`'high'` only. `'max'` is **clamped to `'high'`** with a `debugLogger.warn` line (once per generator); if you want max effort, switch the baseURL to a DeepSeek-compatible endpoint that supports it. |
| **Anthropic** (`api.deepseek.com/anthropic`) | Same `output_config: { effort }` + beta header | `'max'` is passed through unchanged. |
| **Gemini** (`@google/genai`) | `thinkingConfig: { includeThoughts: true, thinkingLevel }` | `'low'` → `LOW`, `'high'`/`'max'` → `HIGH`, others → `THINKING_LEVEL_UNSPECIFIED` (Gemini has no `MAX` tier). |
| Protocol / provider | Wire shape | Notes |
| --------------------------------------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **OpenAI / DashScope** (`qwen3.8-max` family) | Flat `reasoning_effort: <effort>` body parameter | The `/effort` tiers are passed through for any model id starting with `qwen3.8-max` (including dated snapshots and `-latest` aliases); DashScope applies any model-specific mapping. This family's ladder stops at `xhigh`, so a configured `max` is clamped to `xhigh` (logged once) rather than sent and rejected. An explicit `reasoning_effort` in `samplingParams` or `extra_body` is a verbatim override and is not clamped. When `reasoning_effort` and `thinking_budget` conflict, the normal `extra_body` > `samplingParams` > `reasoning` precedence keeps only the higher-priority field; an explicit same-layer pair keeps `reasoning_effort`, matching the provider's behavior before cross-layer resolution. If a static field wins, `/effort` reports that field instead of implying the requested tier is effective. When an effort tier wins, a conflicting `enable_thinking` is also dropped. An explicit `enable_thinking: false` in `extra_body` is honoured rather than dropped: it overrides the configured tier as `reasoning_effort: 'none'`, one of the few places `extra_body` does not win verbatim. Other Qwen models continue to map a selected effort to `enable_thinking: true`; a `reasoning_effort` override passes through there unless it conflicts with a `thinking_budget` (a pair DashScope rejects), in which case the inert `reasoning_effort` is dropped and both `enable_thinking` and `thinking_budget` survive. |
| **OpenAI / DeepSeek** (`api.deepseek.com`) | Flat `reasoning_effort: <effort>` body parameter | When `reasoning.effort` is set in the nested config shape, it's rewritten to flat `reasoning_effort` and `'low'`/`'medium'` are normalized to `'high'`, `'xhigh'` to `'max'` — mirroring DeepSeek's [server-side back-compat](https://api-docs.deepseek.com/zh-cn/api/create-chat-completion). Top-level `samplingParams.reasoning_effort` or `extra_body.reasoning_effort` overrides skip this normalization and ship verbatim. `max` is accepted only on a real DeepSeek hostname; a `deepseek`-named model on another host keeps the generic `xhigh` ceiling, matching the hostname gate on the reshape itself. |
| **OpenAI / Z.ai** (`z.ai`, `bigmodel.cn`) | Flat `reasoning_effort: <effort>` body parameter | GLM-5.2+ on a Z.ai host takes the full ladder, `max` included, and the nested `reasoning.effort` is rewritten to the flat field. Older GLM ids, and a `glm-*` model reached on any other host, keep the generic `xhigh` ceiling: the model name alone says nothing about what that endpoint accepts. |
| **OpenAI** (other compatible servers) | Known GPT-5 / GPT-6 Astra: flat `reasoning_effort`; other models: nested `reasoning` | GPT effort is clamped in both directions to the supported subset for the known model. GPT-5.6 and GPT-6 Astra allow `max`, while earlier models have lower ceilings. Tiers below the model floor are raised: GPT-5 Pro accepts only `high`; GPT-5.2 Pro, GPT-5.4 Pro and GPT-5.5 Pro raise `low` to `medium`. OpenRouter retains nested `reasoning`. Unknown model names keep the generic `xhigh` ceiling and nested shape. Explicit reasoning values in `samplingParams` / `extra_body` bypass the configured-tier clamp. |
| **Anthropic** (real `api.anthropic.com`) | `output_config: { effort }` plus the `effort-2025-11-24` beta header | Real Anthropic accepts `'low'`/`'medium'`/`'high'` only. `'max'` is **clamped to `'high'`** with a `debugLogger.warn` line (once per generator); if you want max effort, switch the baseURL to a DeepSeek-compatible endpoint that supports it. |
| **Anthropic** (`api.deepseek.com/anthropic`) | Same `output_config: { effort }` + beta header | `'max'` is passed through unchanged. |
| **Gemini** (`@google/genai`) | `thinkingConfig: { includeThoughts: true, thinkingLevel }` | `'low'` → `LOW`, `'high'`/`'max'` → `HIGH`, others → `THINKING_LEVEL_UNSPECIFIED` (Gemini has no `MAX` tier). |
### `reasoning: false`
Setting `reasoning: false` (the literal boolean) explicitly disables thinking on every provider — useful for cheap side queries that don't benefit from reasoning. This is honored at the request level too via `request.config.thinkingConfig.includeThoughts: false` for one-off calls (e.g. suggestion generation).
Setting `reasoning: false` (the literal boolean) explicitly disables thinking on models that support disabling — useful for cheap side queries that don't benefit from reasoning. This is honored at the request level too via `request.config.thinkingConfig.includeThoughts: false` for one-off calls (e.g. suggestion generation).
On a `api.deepseek.com` baseURL, the OpenAI pipeline emits the explicit `thinking: { type: 'disabled' }` field that DeepSeek V4+ requires — the server-side default is `'enabled'`, so simply omitting `reasoning_effort` would still pay thinking latency/cost. Self-hosted DeepSeek backends (sglang/vllm) and other OpenAI-compatible servers do **not** receive this field; if you need to disable thinking on those, inject `thinking: { type: 'disabled' }` (or whatever knob your inference framework exposes) via `samplingParams`/`extra_body`.
On an `openrouter.ai` baseURL, the OpenAI pipeline emits OpenRouter's provider-level `reasoning: { enabled: false }` field when reasoning is disabled. Other OpenAI-compatible servers do not receive this OpenRouter-specific field; use `samplingParams`/`extra_body` for their native disable knob.
For known GPT models that allow disabling, non-OpenRouter endpoints receive `reasoning_effort: 'none'`; OpenRouter receives nested `reasoning: { enabled: false }` instead. Mandatory-thinking models reject off in model controls and omit unsupported disable values from requests, so `reasoning: false` cannot turn their thinking off. An explicit model reasoning capability takes precedence over the built-in tier list and selects the native disable field; OpenRouter keeps its provider-level disable behavior. The built-in mandatory set is `gpt-5`, `gpt-5-mini`, `gpt-5-nano`, `gpt-5-pro`, `gpt-5.1-codex`, `gpt-5.1-codex-max`, `gpt-5.2-codex`, `gpt-5.3-codex`, `gpt-5.2-pro`, `gpt-5.4-pro`, `gpt-5.5-pro`, and `gpt-6-astra`.
On an `openrouter.ai` baseURL, the OpenAI pipeline emits OpenRouter's provider-level `reasoning: { enabled: false }` field when reasoning is disabled. Mandatory-thinking models do not receive this disable field. Other OpenAI-compatible servers do not automatically receive this OpenRouter-specific field; use their native disable knob.
### Interaction with `samplingParams` (OpenAI-compatible only)
> [!warning]
>
> When `generationConfig.samplingParams` is set on an OpenAI-compatible provider, the pipeline ships those keys to the wire **verbatim** and skips the separate `reasoning` injection entirely. So a config like `{ samplingParams: { temperature: 0.5 }, reasoning: { effort: 'max' } }` will silently drop the reasoning field on OpenAI/DeepSeek requests. A `reasoning` object placed inside `samplingParams` is your own value and ships unchanged: the effort ceiling above applies only to the tier the pipeline injects from `/effort`.
> Except for known GPT models and models with explicit reasoning capabilities, when `generationConfig.samplingParams` is set on an OpenAI-compatible provider, the pipeline ships those keys to the wire **verbatim** and skips the separate `reasoning` injection entirely. So a config like `{ samplingParams: { temperature: 0.5 }, reasoning: { effort: 'max' } }` will silently drop the reasoning field on OpenAI/DeepSeek requests. A `reasoning` object placed inside `samplingParams` is your own value and ships unchanged while reasoning is enabled: the effort ceiling above applies only to the tier the pipeline injects from `/effort`.
>
> DashScope Qwen models are an exception: their provider reads `reasoning` directly and maps it to `reasoning_effort` or `enable_thinking`. On the qwen3.8-max family, provider-specific `samplingParams` fields still take precedence when the wire parameters conflict; on older qwen hybrids, a configured effort tier collapses to `enable_thinking: true`, which overrides a `samplingParams.enable_thinking` value.
> Known GPT-5 models and GPT-6 Astra keep configured effort alongside unrelated sampling keys. For example, `{ samplingParams: { temperature: 0.5 }, reasoning: { effort: 'max' } }` sends `temperature: 0.5` and flat `reasoning_effort: 'xhigh'` on GPT-5.4, or `'max'` on GPT-5.6 / GPT-6 Astra. On an `openrouter.ai` baseURL the same clamped tier ships as nested `reasoning: { effort }` instead. On non-OpenRouter endpoints, explicit flat reasoning overrides win; nullish or empty-string flat placeholders allow the configured tier. On OpenRouter, a sampling flat override suppresses configured nested effort unless explicit model capabilities inject it; an extra-body-only flat override does not replace the configured nested effort.
>
> If you set `samplingParams`, include the reasoning knob inside it directly — for DeepSeek that's `samplingParams.reasoning_effort`, for GPT-5/o-series it's `samplingParams.reasoning_effort` (their flat field) or `samplingParams.reasoning` (the nested object). For OpenRouter and other providers the field name varies; consult the provider docs.
> DashScope Qwen models are another exception: their provider reads `reasoning` directly and maps it to `reasoning_effort` or `enable_thinking`. On the qwen3.8-max family, provider-specific `samplingParams` fields still take precedence when the wire parameters conflict; on older qwen hybrids, a configured effort tier collapses to `enable_thinking: true`, which overrides a `samplingParams.enable_thinking` value.
>
> For other models, include the provider's reasoning knob directly when using `samplingParams` — for DeepSeek that is `samplingParams.reasoning_effort`. Known GPT models map configured effort automatically; only add a raw override when intentionally bypassing that mapping. Raw nested `reasoning`, including `null`, remains a whole-object override while reasoning is enabled. Disabling via `reasoning: false` or request-level `includeThoughts: false` removes the nested value, including raw overrides in either layer. Non-OpenRouter GPT requests then send `reasoning_effort: 'none'` when disabling is allowed; OpenRouter uses its nested disable field instead. Mandatory-thinking models receive neither substitute. Outside OpenRouter its meaning depends on the gateway, so model controls show the model default. Any raw override that blocks a configured tier causes an explicit tier change to fail without saving a preference. The thinking switch restores configured raw defaults after disabling only when they permit thinking. If the raw state or configured reasoning default is off, the thinking switch cannot be turned on and the saved preference is retained. This also applies when explicit capabilities omit a default tier or expose only a thinking toggle. An explicit default command still resets the preference. Remove the blocking raw override to choose a different tier.
>
> The Anthropic and Gemini converters are unaffected — they always read `reasoning.effort` directly regardless of `samplingParams`.
+1 -1
View File
@@ -181,7 +181,7 @@ These settings are read from operator scopes only (User, System, and SystemDefau
| `model.maxSessionTurns` | integer | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` |
| `model.maxWallTimeSeconds` | number | Wall-clock budget for headless / unattended runs, in seconds. `-1` means unlimited. Overridable per-invocation via `--max-wall-time`, which requires a positive duration (`90`, `30s`, `5m`, `1h`, `1.5h`); the minimum is 1 second — sub-second values (`500ms`, `0.5`) are rejected as typos. Omit the flag to fall back to this setting. Aborts with exit code 55 when exceeded. | `-1` |
| `model.goalTokenBudget` | integer | Autonomous spend window armed on each new Goal, in tokens as counted by the Goal meter (`totalTokenCount` summed over the model calls the Goal makes in its own turns). A Goal that spends its window gets one wind-down turn to hand off, then stops until you resume it, which arms another window. Unset uses the built-in default of 30,000,000; `-1` means unlimited. Zero, values above 300,000,000 (10x the default, a typo guard), other negative, fractional, or non-number values are rejected at startup. | `30000000` |
| `model.goalCheckpointTimeoutSeconds` | integer | Ceiling on one Goal evidence-checkpoint call, in seconds. A long Goal periodically compresses its evidence into checkpoint claims with a side model call; a call that does not finish in time is abandoned as an inconclusive check — the checkpoint stall streak is preserved rather than incremented — and a later turn retries it. Unset uses the built-in default of 180. Must be an integer between 1 and 900; other values are rejected at startup. The call is streamed, so the per-request transport timeout (`model.generationConfig.timeout`, default 120 s) bounds only connect and first response, and values above 900 are rejected because past the default stream lifetime guard that guard, not this setting, ends the call. The 900 ceiling is fixed: raising `QWEN_STREAM_MAX_LIFETIME_MS` does not lift it. | `180` |
| `model.goalCheckpointTimeoutSeconds` | integer | Ceiling on one Goal evidence-checkpoint check, in seconds. A long Goal periodically compresses its evidence into checkpoint claims with a side model call. A check whose claims overrun the aggregate byte budget, or include a claim over the per-claim character limit, makes one corrective retry, and both calls share this ceiling. A check that does not finish in time is abandoned as inconclusive; it counts toward the checkpoint stall limit only when the evidence window has overflowed, while a non-overflowing check preserves the streak and retries on a later turn. Unset uses the built-in default of 180. Must be an integer between 1 and 900; other values are rejected at startup. The calls are streamed, so the per-request transport timeout (`model.generationConfig.timeout`, default 120 s) bounds only connect and first response, and values above 900 are rejected because past the default stream lifetime guard that guard, not this setting, ends the check. The 900 ceiling is fixed: raising `QWEN_STREAM_MAX_LIFETIME_MS` does not lift it. | `180` |
| `model.maxToolCalls` | number | Cumulative tool-call budget for a run (counts every executed tool, success or failure; `structured_output` under `--json-schema` is exempt). `-1` means unlimited; `0` means "no tool calls allowed". Capped at 1,000,000 to catch typos. Overridable via `--max-tool-calls`. Aborts with exit code 55 when exceeded. | `-1` |
| `model.maxSubagentDepth` | number | Maximum sub-agent nesting depth (1-based levels: a top-level sub-agent is level 1). `1` keeps sub-agents available but disables nesting — the pre-nesting behavior. Values clamp to the range 1–100; non-finite values fall back to the default. Teammates, forks, and workflow-spawned agents never nest regardless of this setting. Overridable via `--max-subagent-depth`. | `5` |
| `model.generationConfig` | object | Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, `retryInitialDelayMs`, `retryMaxDelayMs`, `enableCacheControl`, `splitToolMedia` (default `true`; splits tool-returned media — including images read by the built-in read_file — into a follow-up user message instead of the spec-violating `role: "tool"` message, so strict OpenAI-compatible servers like doubao / new-api / LM Studio can see it; set `false` to restore the legacy embed-in-tool behavior), `toolResultContentFormat` (default `"parts"`; set `"string"` only for legacy OpenAI-compatible runtimes whose tool templates ignore text content parts), `contextWindowSize` (override model's context window size), `modalities` (override auto-detected input modalities), `customHeaders` (custom HTTP headers for API requests), and `extra_body` (additional body parameters for OpenAI-compatible API requests only), along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults. | `undefined` |
+1 -1
View File
@@ -198,7 +198,7 @@ You can send photos and documents to the bot, not just text.
**Files:** Send a PDF, code file, or any document. The bot downloads it from DingTalk's servers and saves it locally so the agent can read it with its file tools. Audio and video files are also supported. This works with any model.
**Generated files:** Ask the agent explicitly to send a completed local file and it can return the file as a native DingTalk attachment. Files must be non-empty, no larger than 20 MB, and located inside the configured workspace or the system temporary directory. One response can send at most five files. Outbound file attachments are unavailable when `blockStreaming` is set to `"on"`; upload or delivery failures are reported in the final text instead.
**Generated files:** Ask the agent explicitly to send a completed local file and it can return the file as a native DingTalk attachment. Files must be non-empty, no larger than 20 MB, and located inside the configured workspace or the system temporary directory. One response can send at most five files. Upload or delivery failures are reported in the final text instead.
## Forwarded Chat Records
+1 -9
View File
@@ -46,7 +46,6 @@ Add the channel to `~/.qwen/settings.json`:
"allowedUsers": ["operator-github-username"],
"sessionScope": "chat_thread",
"cwd": "/path/to/your/project",
"blockStreaming": "off",
"groupPolicy": "open",
"groups": {
"*": { "requireMention": true }
@@ -87,7 +86,6 @@ Local `gh` authentication requires an HTTPS `baseUrl` so the daemon host credent
| `groupPolicy` | `"disabled"` | Must be `"open"`, `"allowlist"` with the repo (`owner/repo`) listed in `groups`, or `"pairing"` with the repo approved for notifications to flow |
| `senderPolicy` | `"allowlist"` | Who can trigger the bot |
| `groups.*.requireMention` | `true` | Require @mentions for ordinary comments; directed notification reasons still run |
| `blockStreaming` | `"off"` | Always forced to `"off"`; intermediate model chunks aren't published; `"on"` is not supported |
| `reasonFilter` | unset | Optional allowlist of GitHub notification reasons to process |
Use `reasonFilter` to drop noisy notification classes such as `ci_activity` or `state_change`. Do not use `reasonFilter: ["mention"]` as a replacement for `groups.*.requireMention`: GitHub's `mention` reason is sticky at the thread level, so real new @mentions can arrive later under `comment`, `subscribed`, `author`, or other reasons and would be skipped.
@@ -133,13 +131,7 @@ For an accepted issue or pull-request comment, the channel adds GitHub's `👀`
### Final-only output
The GitHub channel always forces final-only delivery. The adapter sets `blockStreaming` to `"off"`, so intermediate model chunks are never published as separate comments and `blockStreaming: "on"` is not supported.
```json
{
"blockStreaming": "off"
}
```
The GitHub channel publishes only completed responses. Intermediate model chunks are never published as separate comments.
If GitHub returns a definite no-write delivery failure, such as a rate-limit
response, the channel stores the final reply in
+26 -51
View File
@@ -50,32 +50,29 @@ Channels are configured under the `channels` key in `settings.json`. Each channe
### Options
| Option | Required | Description |
| ------------------------ | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | Yes | Channel type: `telegram`, `weixin`, `qq`, `dingtalk`, `dws`, `wecom`, `feishu`, `github`, `gitlab`, or a custom type from an extension (see [Plugins](./plugins)) |
| `token` | Telegram | Bot token. Supports `$ENV_VAR` syntax to read from environment variables. Not needed for WeChat, DingTalk, WeCom, or Feishu |
| `clientId` | DingTalk, Feishu | DingTalk AppKey or Feishu App ID. Supports `$ENV_VAR` syntax |
| `clientSecret` | DingTalk, Feishu | DingTalk AppSecret or Feishu App Secret. Supports `$ENV_VAR` syntax |
| `botId` | WeCom | WeCom intelligent robot Bot ID. Supports `$ENV_VAR` syntax. See [WeCom](./wecom) |
| `secret` | WeCom | WeCom intelligent robot Secret. Supports `$ENV_VAR` syntax. See [WeCom](./wecom) |
| `model` | No | Model to use for this channel (e.g., `qwen3.5-plus`). Overrides the default model. Useful for multimodal models that support image input |
| `senderPolicy` | No | Who can talk to the bot: `allowlist` (default), `open`, or `pairing` |
| `allowedUsers` | No | List of user IDs allowed to use the bot (used by `allowlist` and `pairing` policies) |
| `sessionScope` | No | How sessions are scoped: `user` (default), `chat_thread`, or `single`. Legacy `thread` remains compatible when already configured but is not offered for new Web Shell configurations |
| `multiSession` | No | Retain up to eight owner-scoped named tasks in one chat. Requires daemon-managed mode, `sessionScope: "user"`, no webhooks or group-history backfill, and no enabled Channel loops |
| `messagePrefix` | No | Only dispatch user messages that begin with this exact, case-sensitive prefix after any leading `@mentions`; the prefix and following whitespace are removed before dispatch |
| `cwd` | No | Working directory for the agent. Defaults to the current directory |
| `approvalMode` | No | Tool approval mode for channel sessions. Unattended webhook tasks require `yolo`; the setting applies to every session on the channel |
| `instructions` | No | Custom instructions prepended to the first message of each session |
| `webhooks` | No | Webhook sources and delivery targets for daemon-managed channels. See [Webhook-triggered tasks](#webhook-triggered-tasks) |
| `groupPolicy` | No | Group chat access: `disabled` (default), `allowlist`, `pairing`, or `open`. See [Group Chats](#group-chats) |
| `dmPolicy` | No | Private/DM access: `open` (default) or `disabled` (silently drop all DMs). Useful for group-only bots |
| `groupHistoryLimit` | No | Opt-in group history backfill. `0` or omitted disables it. A positive number persists that many unmentioned group messages from authorized senders or members of approved paired groups for the next bot mention/reply. |
| `groups` | No | Per-group settings. Keys are group chat IDs or `"*"` for defaults. See [Group Chats](#group-chats) |
| `dispatchMode` | No | What happens when you send a message while the bot is busy: `steer` (default), `collect`, or `followup`. See [Dispatch Modes](#dispatch-modes) |
| `blockStreaming` | No | Progressive response delivery: `on` or `off` (default). See [Block Streaming](#block-streaming) |
| `blockStreamingChunk` | No | Chunk size bounds: `{ "minChars": 400, "maxChars": 1000 }`. See [Block Streaming](#block-streaming) |
| `blockStreamingCoalesce` | No | Idle flush: `{ "idleMs": 1500 }`. See [Block Streaming](#block-streaming) |
| Option | Required | Description |
| ------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | Yes | Channel type: `telegram`, `weixin`, `qq`, `dingtalk`, `dws`, `wecom`, `feishu`, `github`, `gitlab`, or a custom type from an extension (see [Plugins](./plugins)) |
| `token` | Telegram | Bot token. Supports `$ENV_VAR` syntax to read from environment variables. Not needed for WeChat, DingTalk, WeCom, or Feishu |
| `clientId` | DingTalk, Feishu | DingTalk AppKey or Feishu App ID. Supports `$ENV_VAR` syntax |
| `clientSecret` | DingTalk, Feishu | DingTalk AppSecret or Feishu App Secret. Supports `$ENV_VAR` syntax |
| `botId` | WeCom | WeCom intelligent robot Bot ID. Supports `$ENV_VAR` syntax. See [WeCom](./wecom) |
| `secret` | WeCom | WeCom intelligent robot Secret. Supports `$ENV_VAR` syntax. See [WeCom](./wecom) |
| `model` | No | Model to use for this channel (e.g., `qwen3.5-plus`). Overrides the default model. Useful for multimodal models that support image input |
| `senderPolicy` | No | Who can talk to the bot: `allowlist` (default), `open`, or `pairing` |
| `allowedUsers` | No | List of user IDs allowed to use the bot (used by `allowlist` and `pairing` policies) |
| `sessionScope` | No | How sessions are scoped: `user` (default), `chat_thread`, or `single`. Legacy `thread` remains compatible when already configured but is not offered for new Web Shell configurations |
| `multiSession` | No | Retain up to eight owner-scoped named tasks in one chat. Requires daemon-managed mode, `sessionScope: "user"`, no webhooks or group-history backfill, and no enabled Channel loops |
| `messagePrefix` | No | Only dispatch user messages that begin with this exact, case-sensitive prefix after any leading `@mentions`; the prefix and following whitespace are removed before dispatch |
| `cwd` | No | Working directory for the agent. Defaults to the current directory |
| `approvalMode` | No | Tool approval mode for channel sessions. Unattended webhook tasks require `yolo`; the setting applies to every session on the channel |
| `instructions` | No | Custom instructions prepended to the first message of each session |
| `webhooks` | No | Webhook sources and delivery targets for daemon-managed channels. See [Webhook-triggered tasks](#webhook-triggered-tasks) |
| `groupPolicy` | No | Group chat access: `disabled` (default), `allowlist`, `pairing`, or `open`. See [Group Chats](#group-chats) |
| `dmPolicy` | No | Private/DM access: `open` (default) or `disabled` (silently drop all DMs). Useful for group-only bots |
| `groupHistoryLimit` | No | Opt-in group history backfill. `0` or omitted disables it. A positive number persists that many unmentioned group messages from authorized senders or members of approved paired groups for the next bot mention/reply. |
| `groups` | No | Per-group settings. Keys are group chat IDs or `"*"` for defaults. See [Group Chats](#group-chats) |
| `dispatchMode` | No | What happens when you send a message while the bot is busy: `steer` (default), `collect`, or `followup`. See [Dispatch Modes](#dispatch-modes) |
When `messagePrefix` is set, every user-authored message must begin with the prefix and a non-empty payload, for example `/review inspect #123`. Only the prefix and the mentions ahead of it are removed; a mention the user typed after the prefix reaches the agent unchanged. Shared and agent commands use the same rule (`/review /help`, `/review /clear`, and so on). Telegram's registered command-menu actions remain available without the prefix — unless the configured prefix is itself one of them, in which case the prefix wins and that command has to be sent prefixed too (`/new /new`). Attachments need a matching caption when the platform supports one; captionless Telegram, Feishu, WeChat, DingTalk and WeCom media messages continue to run, and their placeholder text is never quoted back as group history. Native todos, webhooks, and provider-generated assignment or review-request events also continue to run without a prefix because they are system events rather than chat messages.
@@ -417,33 +414,11 @@ You can also set dispatch mode per group, overriding the channel default:
}
```
## Block Streaming
## Response delivery
By default, the agent works for a while and then sends one large response. With block streaming enabled, the response arrives as multiple shorter messages while the agent is still working — similar to how ChatGPT or Claude show progressive output.
Channels use their normal response delivery path. The shared delivery layer sends completed responses, and adapters may provide native progressive display, such as updating an interactive card in place. Platform message-length limits may still split long responses.
```json
{
"channels": {
"my-channel": {
"type": "telegram",
"blockStreaming": "on",
"blockStreamingChunk": { "minChars": 400, "maxChars": 1000 },
"blockStreamingCoalesce": { "idleMs": 1500 },
...
}
}
}
```
### How it works
- The agent's response is split into blocks at paragraph boundaries and sent as separate messages
- `minChars` (default 400) — don't send a block until it's at least this long, to avoid spamming tiny messages
- `maxChars` (default 1000) — if a block gets this long without a natural break, send it anyway
- `idleMs` (default 1500) — if the agent pauses (e.g., running a tool), send what's buffered so far
- When the agent finishes, any remaining text is sent immediately
Only `blockStreaming` is required. The chunk and coalesce settings are optional and have sensible defaults.
The obsolete `blockStreaming`, `blockStreamingChunk`, and `blockStreamingCoalesce` settings are no longer supported and can be removed from channel configuration. They do not affect delivery. Channel settings management rejects newly added or changed values for these fields. An unchanged stored value is retained, or removed, when the edit keeps the channel's `type`; changing a channel's `type` requires removing these fields first.
## Scheduled Channel Loops
+1 -2
View File
@@ -63,7 +63,6 @@ export QQ_APP_SECRET=<your-app-secret>
"sessionScope": "user",
"cwd": "/path/to/your/project",
"instructions": "你是一个通过 QQ Bot 对话的 AI 助手。回复控制在 2000 字符以内。",
"blockStreaming": "on",
"groupPolicy": "disabled",
"groups": {
"*": { "requireMention": true }
@@ -82,7 +81,7 @@ export QQ_APP_SECRET=<your-app-secret>
| `sandbox` | `false` | Set to `true` to use the QQ sandbox API environment (`sandbox.api.sgroup.qq.com`) |
All standard channel options (see [Channel Overview](./overview#options)) are also supported:
`senderPolicy`, `allowedUsers`, `sessionScope`, `cwd`, `instructions`, `groupPolicy`, `groups`, `dispatchMode`, `blockStreaming`, `blockStreamingChunk`, `blockStreamingCoalesce`.
`senderPolicy`, `allowedUsers`, `sessionScope`, `cwd`, `instructions`, `groupPolicy`, `groups`, `dispatchMode`.
## Running
+48 -9
View File
@@ -871,14 +871,50 @@ session registry rather than deriving it.
The `send_message` call only confirms the message was handed to the other
session. What became of it arrives later as a receipt: if it was held,
declined, refused, expired, or misaddressed (the address changed hands —
list the agents again) — or released after a hold — a notice appears in
the sending session's transcript (`Message to <name>: …`). Declined and
refused are different answers: declined means someone reviewed the
message and said no, while refused means that session's
`agents.crossSessionInbound` is `refuse` and nobody saw it at all. The
model that sent it is not told; if the other session replies, the reply
arrives as a cross-session message.
declined, refused, dropped, expired, or misaddressed (the address changed
hands — list the agents again) — or released after a hold — a notice
appears in the sending session's transcript (`Message to <name>: …`).
Declined, refused and dropped are three different answers: declined means
someone reviewed the message and said no, refused means that session's
`agents.crossSessionInbound` is `refuse` and nobody saw it at all, and
dropped means its inbox turned the message away before any of that (see
below). The first drop is answered at once and the rest are folded into a
receipt every few seconds, each naming the messages it stands for, so a
run of them costs a handful of lines rather than one line each. The model that
sent it is not told; if the other session replies, the reply arrives as a
cross-session message.
### Flood protection
A session accepts up to 30 messages at once from one sender and then one
every two seconds, and up to 32 at once from all senders together and
then one a second. The second limit exists because a sender names itself:
rotating that name gets a fresh allowance from the first limit but not
from the second. It is barely above the first because every accepted
message draws a receipt, and a session can only have so many of those
going out at once. A message from another session that repeats that
sender's previous message word for word within 30 seconds is also turned
away — a model looping on one sentence mints a fresh message id every
time, so the text is what catches it. Messages from a script the session
started and from a trusted controller are exempt from the repeat check,
because a hook reporting the same line twice is reporting two facts and a
person saying "continue" twice means it twice; both are still subject to
the rate limits. Finally, a message that is accepted but cannot be queued
because the session already has 50 waiting is turned away too.
A message turned away this way is never held, never shown to the model,
and leaves no record, so the sender can try again later and land. The
receiving session says so in its transcript at most once a minute per
sender, with a count of what that line stands for. The sending session
gets one receipt naming every message the burst cost it, and its
transcript says to fold what still matters into one later message rather
than re-sending.
The sending side does not wait to find out. Each session tracks what it
has sent to each address and refuses a send that the receiver would
drop, so the model is told to batch before the message is written rather
than after — and the receiver never spends a connection on a message it
was going to turn away.
### Inbox authentication and scripted injection
@@ -904,7 +940,10 @@ recognized as the session's own rather than as another session's.
Give every injection a fresh `msgId`. The receiving gate remembers the
ids it has already settled, so a hook that reuses one is delivered the
first time and silently deduplicated on every run after that.
first time and silently deduplicated on every run after that. Repeating
the same _text_ is fine — the repeat check above does not apply to a
session's own processes — but the rate limits do apply, so a hook in a
loop is dropped like any other flood.
An injected message still goes through the inbound gate and is marked as
not coming from the user, but the gate knows it came from the session's
+1 -1
View File
@@ -20,7 +20,7 @@ Once a Goal has billed a turn, the footer pill and every status card show what i
Each turn the session takes on its own reports what the Goal has spent so far, how many turns are behind it, and — unless the Goal runs unbounded — the window it is allowed. Every such turn except the final wind-down hand-off also carries standing instructions to re-check the workspace rather than trust earlier turns' reports, to work toward the end state the objective asks for, to do something different when the previous turn changed nothing (from the second turn on, once there is a previous turn to judge), and to check every requirement against citable evidence before proposing that the Goal is done.
A long Goal periodically compresses the evidence it has recorded into checkpoint claims with a side model call, so later turns and the verifier still have it to cite. That call is bounded by [`model.goalCheckpointTimeoutSeconds`](../configuration/settings.md), 180 seconds by default; a checkpoint that does not finish in time is abandoned as an inconclusive check — the checkpoint stall streak is preserved rather than incremented — and a later turn retries it. The call is streamed, so the per-request transport timeout bounds only connect and first response, and the ceiling itself stops at the stream guards' 15-minute lifetime cap because past that the guard, not the setting, ends the call. That 15-minute limit on the setting is fixed, and raising the stream guard's own cap does not lift it.
A long Goal periodically compresses the evidence it has recorded into checkpoint claims with a side model check, so later turns and the verifier still have it to cite. The check is bounded by [`model.goalCheckpointTimeoutSeconds`](../configuration/settings.md), 180 seconds by default. If its claims overrun the aggregate byte budget, or include a claim over the per-claim character limit, it makes one corrective model call and both calls share that ceiling. A check that does not finish in time is abandoned as inconclusive; it counts toward the checkpoint stall limit only when the evidence window has overflowed, while a non-overflowing check preserves the streak and retries on a later turn. The calls are streamed, so the per-request transport timeout bounds only connect and first response, and the ceiling itself stops at the stream guards' 15-minute lifetime cap because past that the guard, not the setting, ends the check. That 15-minute limit on the setting is fixed, and raising the stream guard's own cap does not lift it.
## Interrupting a Goal
+8
View File
@@ -131,6 +131,14 @@ When a session is restored, compatible background agents are added back to the s
Use continuation for related follow-up work. Launch a new agent when the task is unrelated or the previous agent cannot be resumed.
## Notification Queue
In the interactive TUI and ACP session, completion notifications from background agents, shells, monitors and workflows share a queue that drains into a model turn once the session is idle. These queues hold at most 20 notifications so a noisy producer cannot accumulate an unbounded backlog. The headless CLI's local queue is not capped by this rule.
When a 21st notification arrives, Qwen Code evicts an interim monitor pulse first — the monitor's next poll supersedes it — and otherwise the oldest queued notification. Agent results, workflow results and scheduled prompts are never evicted in the interactive TUI; a notification that would displace one is dropped instead, and so is an arriving pulse when only terminal results are queued.
Discarded notifications are reported rather than dropped quietly. The summary appears before the next notification in the live transcript. ACP also prefixes it to that turn's model input; the TUI keeps it parked for the next Notification batch so cron prompts still pass unchanged through slash, shell and `@` preprocessing. A daemon notification is recorded before it is acknowledged, so after a reload its durable record can precede the later overflow summary. ACP can discard a pending summary if the session is cleared or switched, or if a client cancels or preempts the notification turn. Discarding a notification never stops or deletes its task, and completed tasks retain their results; the summary points at `/tasks` and task output files when there is a task to inspect. A discarded scheduled prompt was never delivered and is not retried. A daemon notification that was recorded but could not be delivered live remains available in the session transcript and is reported separately from lost notifications.
## Agent Working Directory
For a named regular subagent, `working_dir` pins the agent to an existing git worktree of the current repository. Relative paths resolve from the current directory, and the worktree must already be registered with git as a linked worktree of this repository.
+62 -48
View File
@@ -43,7 +43,7 @@ The first npm release of `qwen serve` (v0.16-alpha) is intentionally narrow —
**Hardening — minimum viable for local single-user:**
- ✅ Boot-time security gate (refuses non-loopback bind without a token, [PR 15 / #4236](https://github.com/QwenLM/qwen-code/pull/4236))
- ✅ Boot-time security gate (non-loopback binds always carry a bearer token — operator-supplied or generated at startup; explicitly empty sources still refuse to boot, [PR 15 / #4236](https://github.com/QwenLM/qwen-code/pull/4236))
- ✅ Mutation-route auth gate, session-scoped permission routing (Wave 4 PRs)
- ✅ MCP guardrails + multi-client permission coordination (F2 / F3)
- ✅ **Prompt absolute deadline + SSE writer idle timeout** — opt-in via `--prompt-deadline-ms` and `--writer-idle-timeout-ms`; advertised through `prompt_absolute_deadline` and `writer_idle_timeout` when enabled.
@@ -55,6 +55,18 @@ For the deeper "what we won't fix in Stage 1" enumeration (single-host session-s
## Quickstart
### Remote Web Shell and phone access
```bash
qwen serve --hostname 0.0.0.0
```
When neither `--token` nor `QWEN_SERVER_TOKEN` is supplied, a non-loopback listener generates a 128-bit ephemeral bearer (22 URL-safe characters) and prints it once at startup. Explicit flag values take precedence over the environment; on a non-loopback bind an explicitly empty value still fails closed and refuses to boot, while on loopback it shadows `QWEN_SERVER_TOKEN` and leaves the bind in the trusted token-less mode described under `--require-auth`. Default loopback behavior is unchanged.
On wildcard binds, startup lists token-free local/network URLs from actual interfaces and — for the generated token, or for your own token at an interactive terminal — a **secret-bearing QR code** for one labeled private-LAN address. Wildcard enumeration lists only the same private/link-local LAN population Local Control advertises (RFC 1918 / RFC 3927 IPv4, plus fc00::/7 ULA IPv6 on dual-stack binds); the software-interface filter that keeps VPN adapters and container bridges out is a **name heuristic** ([#9158](https://github.com/QwenLM/qwen-code/issues/9158)), not a guarantee, so k8s overlay names (`cbr0`, `cali*`, `vxlan.*`) can slip through. An explicit non-loopback bind instead prints and QRs exactly the address you chose — including a routable one. Inside a container the enumerated address is the container's own interface, so reaching a published port means substituting the host address; a pty-allocated container counts as an interactive terminal, so its logs stay secret-bearing. Scan the QR to open the built-in Web Shell with the bearer in the URL fragment. The browser removes the credential from the URL and keeps it in per-tab `sessionStorage`. Alternatively, open a printed URL and enter the token; missing or expired credentials show a retry form. Generated tokens rotate on restart. `--no-web` omits the QR, and a piped/redirected stdout never re-publishes a stable operator token as a QR. Addresses do not imply firewall or NAT reachability; no public IP is discovered automatically.
The built-in Web Shell's same-origin **HTTP** requests work without `--allow-origin`; other browser origins still need that allowlist, and `Origin: null` remains rejected. WebSocket-backed features (terminal, voice) and browsers reaching the daemon through a TLS-terminating reverse proxy still require an explicit `--allow-origin <origin>` entry — forwarded headers are never trusted to establish same-origin. So does any plain-HTTP intermediary that rewrites the `Host` header: nginx's default `proxy_set_header Host $proxy_host` and k8s Ingress both make the daemon see a `Host` that no longer matches the browser's `Origin`. Pass `--allow-origin <origin>` for the origin your browser actually sees, or configure the proxy to forward `Host` verbatim. On a **non-loopback** bind, port translation alone (`docker -p 8080:4170`) needs nothing — the same-origin check compares `Origin` against the forwarded `Host` and never consults the listening port. On the default **loopback** bind it does not: the DNS-rebinding Host allowlist only accepts the daemon's own port, so a port-translating tunnel (`ssh -L 8080:localhost:4170`) is rejected with `403 Invalid Host header` for every request including the shell document, and `--allow-origin` cannot override it — forward the same port (`ssh -L 4170:localhost:4170`) or bind non-loopback. The bearer grants full daemon API authority, including code execution. Treat terminal tokens and QR captures as secrets. HTTP is unencrypted; the existing `--tls-cert` and `--tls-key` options provide HTTPS with your own certificate.
### 1. Start the daemon (loopback, no auth)
```bash
@@ -66,7 +78,7 @@ qwen serve
The default bind is `127.0.0.1:4170`. Bearer auth is **off** and the primary listener is trusted, so any local process that can reach the port can use the full operator API, including executing code as the daemon user. Route-specific workspace trust, session ownership, `X-Qwen-Client-Id`, permission, feature, validation, and resource checks still apply. The daemon registers the current working directory as its primary workspace; use an absolute `--workspace /path/to/dir` to override it, and repeat the flag to register additional isolated runtimes.
For an API-only daemon, disable the Web Shell without narrowing the daemon's REST or SSE API:
For an API-only daemon, disable the Web Shell. The session, prompt, workspace, permission and SSE routes are unchanged; the surfaces bound to the Web Shell go with it — Local Control enablement fails closed on every platform, and on macOS the `/live/*` routes and the `/live/host` WebSocket are not registered:
```bash
qwen serve --no-web
@@ -368,17 +380,17 @@ workspace.
## Authentication
For anything beyond loopback, you **must** pass a bearer token:
For anything beyond loopback, every API request requires a bearer token. Supply a stable one for reusable clients, or start without one and use the ephemeral token the daemon generates and prints:
```bash
export QWEN_SERVER_TOKEN="$(openssl rand -hex 32)"
qwen serve --hostname 0.0.0.0 --port 4170
# → boot refuses without QWEN_SERVER_TOKEN
# → uses QWEN_SERVER_TOKEN; generates an ephemeral token if neither token source is supplied
```
Clients then send `Authorization: Bearer $QWEN_SERVER_TOKEN` on normal API requests. `/health` is exempted **only on ordinary loopback binds** so k8s/Compose liveness probes inside the pod don't need credentials. On non-loopback binds (`--hostname 0.0.0.0` etc.) `/health` requires the token with the other normal API routes — otherwise an attacker can probe arbitrary addresses to confirm the daemon's existence. Channel webhook ingress is separate in every mode and uses its configured `x-qwen-webhook-secret` instead of the daemon bearer. Use `/capabilities` to verify your configured token is correct end-to-end:
Clients then send `Authorization: Bearer <token>` on normal API requests — `$QWEN_SERVER_TOKEN` when you exported one, or the bearer printed at startup when the daemon generated it (that path never sets the env var, so copy the value out of the startup output). `/health` is exempted **only on ordinary loopback binds** so k8s/Compose liveness probes inside the pod don't need credentials. On non-loopback binds (`--hostname 0.0.0.0` etc.) `/health` requires the token with the other normal API routes — otherwise an attacker can probe arbitrary addresses to confirm the daemon's existence. Channel webhook ingress is separate in every mode and uses its configured `x-qwen-webhook-secret` instead of the daemon bearer. Use `/capabilities` to verify your configured token is correct end-to-end:
> **Hardened loopback (`--require-auth`).** The default loopback no-token behavior is fine for a single-user laptop but unsafe on shared dev hosts, CI runners, or multi-tenant workstations where any local user can `curl 127.0.0.1:4170`. Pass `--require-auth` to make the bearer token mandatory on every normal API route — including `/health` and `/capabilities` — even when bound to `127.0.0.1`. Channel webhook ingress remains independently authenticated by `x-qwen-webhook-secret`. Web Shell document and asset routes remain pre-auth; use `--no-web` to remove them. Boot fails without a token. With the flag on, an **unauthenticated** client can't read `/capabilities` to discover that auth is required; the discovery surface is the 401 response body itself. Once authenticated, the `caps.features.require_auth` tag is a post-auth confirmation that the deployment is hardened (useful for audit / compliance UIs):
> **Hardened loopback (`--require-auth`).** The default loopback no-token behavior is fine for a single-user laptop but unsafe on shared dev hosts, CI runners, or multi-tenant workstations where any local user can `curl 127.0.0.1:4170`. Pass `--require-auth` to make the bearer token mandatory on every normal API route — including `/health` and `/capabilities` — even when bound to `127.0.0.1`. Channel webhook ingress remains independently authenticated by `x-qwen-webhook-secret`. Web Shell document and asset routes remain pre-auth; use `--no-web` to remove them. Boot fails when no token source resolves, and on loopback that means `--token`, `QWEN_SERVER_TOKEN`, or `--open-with-auth` — the last installs its own generated token before boot, so `--require-auth --open-with-auth` starts. Generation keys on the `--hostname` spelling: loopback spellings (`127.0.0.0/8`, `localhost`, `::1`, `[::1]`) never generate, while any other spelling — including a DNS name that happens to resolve to loopback — generates an ephemeral bearer that satisfies `--require-auth` and is printed at startup (token-only when the socket bound loopback), rotating on every restart, so pin `--token` or `QWEN_SERVER_TOKEN` for reusable clients. With the flag on, an **unauthenticated** client can't read `/capabilities` to discover that auth is required; the discovery surface is the 401 response body itself. Once authenticated, the `caps.features.require_auth` tag is a post-auth confirmation that the deployment is hardened (useful for audit / compliance UIs):
>
> ```bash
> qwen serve --require-auth --token "$(openssl rand -hex 32)"
@@ -390,7 +402,9 @@ Clients then send `Authorization: Bearer $QWEN_SERVER_TOKEN` on normal API reque
> ```
```bash
curl -H "Authorization: Bearer $QWEN_SERVER_TOKEN" http://your-host:4170/capabilities
# Paste the bearer the daemon printed at startup, or your own QWEN_SERVER_TOKEN.
TOKEN='<printed bearer>'
curl -H "Authorization: Bearer $TOKEN" http://your-host:4170/capabilities
# → {"v":1,"mode":"http-bridge","features":[...],"modelServices":[],"workspaceCwd":"/path/to/your-project"}
# Wrong token → 401
```
@@ -428,51 +442,51 @@ qwen serve \
Notes:
- **Both flags or neither** — boot fails if only one is given (a cert with no key can't start an HTTPS listener).
- **TLS is orthogonal to auth** — HTTPS encrypts the transport; when a bearer token is configured, it gates normal API routes except `/health` on an ordinary loopback bind. Pass `--require-auth` to gate loopback `/health` too. Channel webhook ingress always uses its own shared secret. Non-loopback binds require a token with or without TLS.
- **TLS is orthogonal to auth** — HTTPS encrypts the transport; when a bearer token is configured, it gates normal API routes except `/health` on an ordinary loopback bind. Pass `--require-auth` to gate loopback `/health` too. Channel webhook ingress always uses its own shared secret. Non-loopback binds always carry a token with or without TLS — the one you pass, or the ephemeral one the daemon generates and prints at startup.
- **Scope is TLS termination only** — no auto-generation, no ACME / Let's Encrypt. This is a LAN / dev convenience; for internet-facing deployments terminate TLS at a reverse proxy (see the threat model below).
- **Channel workers dial the daemon back over `https://`** — so they need to trust the serving certificate too. A self-signed cert (or a fullchain that carries its own root) needs nothing: the daemon injects it into each worker's `NODE_EXTRA_CA_CERTS`. The mkcert flow above is **CA-issued**, so the leaf alone cannot anchor the chain — export `NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem"` in the daemon's launch environment before starting with `--channel`. An operator-set value is _merged_ with the daemon cert, not replaced. Without it the daemon boots green while every channel worker restart-loops on `UNABLE_TO_VERIFY_LEAF_SIGNATURE`; the daemon log names the gap at boot.
- **An IPv6 wildcard bind is dialled back on the loopback this host actually assigns** — `--hostname ::` (or `[::]`) binds an IPv6 socket, and an empty `--hostname` binds one too when IPv6 is available (when it is not, Node falls back to binding `0.0.0.0`). That socket is dual-stack (Node pins `IPV6_V6ONLY=0` on it, so the `net.ipv6.bindv6only` sysctl does not change this), and both loopbacks usually reach it — but a host with no IPv4 at all has only `::1`, while a host that binds `::` yet carries no `::1` on its loopback (for example `net.ipv6.conf.lo.disable_ipv6=1`) has only `127.0.0.1`. Workers are sent to `[::1]` when this host assigns it and to `127.0.0.1` otherwise. A serving certificate for an IPv6 wildcard bind should carry both loopbacks in its SANs (`mkcert localhost 127.0.0.1 ::1` covers it); the boot trust diagnostic inspects the exact URL workers will dial and names the gap. `--hostname 0.0.0.0` is unchanged and still needs `127.0.0.1`.
- **An IPv6 wildcard bind is dialled back on the loopback this host actually assigns** — `--hostname ::` (or `[::]`) binds an IPv6 socket. (An empty `--hostname` never reaches the listener: boot refuses it as operator error before binding, so a compose file with an unset `HOSTNAME` fails loudly instead of silently wildcarding.) That socket is dual-stack (Node pins `IPV6_V6ONLY=0` on it, so the `net.ipv6.bindv6only` sysctl does not change this), and both loopbacks usually reach it — but a host with no IPv4 at all has only `::1`, while a host that binds `::` yet carries no `::1` on its loopback (for example `net.ipv6.conf.lo.disable_ipv6=1`) has only `127.0.0.1`. Workers are sent to `[::1]` when this host assigns it and to `127.0.0.1` otherwise. A serving certificate for an IPv6 wildcard bind should carry both loopbacks in its SANs (`mkcert localhost 127.0.0.1 ::1` covers it); the boot trust diagnostic inspects the exact URL workers will dial and names the gap. `--hostname 0.0.0.0` is unchanged and still needs `127.0.0.1`.
- **Rotating `--tls-cert` in place needs a daemon restart** — the daemon serves the bytes it read at boot, so until it restarts, respawned workers can load newer contents than the daemon presents and their handshakes fail.
## CLI flags
| Flag | Default | Purpose |
| --------------------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--port <n>` | `4170` | TCP port. `0` = OS-assigned ephemeral port. |
| `--hostname <addr>` | `127.0.0.1` | Bind interface. Anything beyond loopback requires a token. |
| `--local-control` | `false` | Share the Web Shell on one selected private IPv4 interface with a daemon-owned revocable pairing token, terminal QR code, exact browser origin, and best-effort sleep inhibition. Composes with `--token`, `--allow-origin`, and `--port 0`; conflicts with `--no-web` and non-default `--hostname`. Use `--local-control-address` when multiple LAN candidates are available, and add `--tls-cert` + `--tls-key` for secure-context browser APIs such as voice input. |
| `--local-control-address <ip>` | — | Which LAN IPv4 address to share when the host has more than one candidate. Only needed if `--local-control` reports an ambiguous choice. |
| `--token <str>` | — | Bearer token. Falls back to `QWEN_SERVER_TOKEN` env var (with leading/trailing whitespace stripped — handy for `$(cat token.txt)`). |
| `--require-auth` | `false` | Refuse to start without a bearer token, even on loopback. Hardens the `127.0.0.1` developer default for shared dev hosts / CI runners / multi-tenant workstations where any local user can hit the listener. Boots only with `--token` or `QWEN_SERVER_TOKEN` set; gates `/health` behind the bearer too. |
| `--enable-session-shell` | `false` | Enable direct session shell execution. Effective with either a configured bearer token or trusted-loopback mode; calls still require a valid session-bound `X-Qwen-Client-Id`. |
| `--tls-cert <path>` | — | Path to a PEM certificate file. Serve over **HTTPS** instead of HTTP. Must be paired with `--tls-key` (boot fails if only one is given). Unlocks secure-context browser APIs — voice input (`getUserMedia`), WebRTC — over a LAN IP, which browsers otherwise block on plain `http://`. TLS termination only; no auto-generation / ACME. See [HTTPS / TLS](#https--tls-for-mobile--cross-device-access) below. |
| `--tls-key <path>` | — | Path to a PEM private key file. Must be paired with `--tls-cert`. |
| `--max-sessions <n>` | `32` | Cap on concurrent live sessions. New `POST /session` requests that would spawn a fresh child return `503` (with `Retry-After: 5`) when the cap is hit; attaches to existing sessions are NOT counted. Set to `0` to disable. Sized for single-user / small-team usage; raise it if your deployment has the RAM/FD headroom (~30–50 MB per session). |
| `--max-total-sessions <n>` | derived | Optional non-negative integer daemon-wide cap on fresh session creation across all registered workspace runtimes. It applies to new child sessions, session restore, and branch/fork-created sessions; attaching to an existing live session does not consume a slot. Set to `0` for unlimited. When omitted with several startup/restored workspaces, the daemon derives a fixed cap from the per-workspace limit and the startup workspace count; later dynamic registration does not recompute it. |
| `--max-pending-prompts-per-session <n>` | `5` | Per-session cap on prompts accepted by `POST /session/:id/prompt` but not yet settled, including queued prompts and the active prompt. The bridge rejects overflow synchronously with `503`, `Retry-After: 5`, and `code: "prompt_queue_full"` before returning a `promptId`. Set to `0` to disable. `branchSession` serializes on the same FIFO but does not count against this prompt cap. |
| `--workspace <path>` | `process.cwd()` | Absolute workspace directory registered by this daemon. Repeat the flag to host multiple workspaces in one process; the first is primary and remains the default when a request omits `cwd`. Relative values are rejected. Session requests whose canonical `cwd` is not registered return `400 workspace_mismatch`. |
| `--memory-project-scope <mode>` | `workspace` | Project-memory partitioning mode. `workspace` (default) keys memory by the exact registered workspace directory so each daemon workspace gets its own isolated memory; `git-root` is the legacy compatibility mode shared by workspaces resolved to the same Git root. Overrides `QWEN_CODE_MEMORY_PROJECT_SCOPE` when provided; a blank env value is treated as unset, while an unrecognized non-empty value is ignored with a one-time warning and retains the legacy `git-root` behavior. The new default does not migrate existing git-root project memory — use an explicit `git-root` scope to read those entries during migration. |
| `--channel <name\|all>` | — | Experimental daemon-managed channel worker. Repeat the flag to select multiple configured channels, or pass `all` to start every configured channel. `all` cannot be combined with named channels. Selected channel `cwd` values must resolve to a registered workspace; a multi-workspace daemon runs one worker per owning workspace. The worker is owned by `qwen serve`; stop the daemon to stop serve-managed channels. |
| `--max-connections <n>` | `256` | Listener-level TCP connection cap (`server.maxConnections`). Bounds raw socket count irrespective of session count — slow / phantom SSE clients get rejected at accept time once full. Raise alongside `--max-sessions` if your deployment expects many SSE subscribers per session. |
| `--memory-budget-mb <n>` | 50% of cgroup/host | Total memory budget in MB for the whole daemon process tree. When unset, derived as 50% of the cgroup limit or host memory; either way the effective value is capped at resolved available memory, and both the configured and effective figures are reported. It does not change how any `qwen --acp` child is sized; the one consumer today is adaptive live-journal growth: one daemon-wide growth pool derived as 5% of the effective budget (capped at `1024` MB; on hosts reporting `insufficientMemory` the pool is 0 and adaptive growth is disabled) is shared by every workspace bridge — see `--max-journal-bytes`. Resolved figures appear under `limits.memory` in `GET /daemon/status`, alongside registered and live child counts and advisory per-child shares under `runtime.memory`. A host too small for the minimum reports `insufficientMemory` rather than being clamped upward; because the derived fraction is 50%, any host under ~2 GB trips this. Pass an explicit `--memory-budget-mb 1024` on such a host to override the derived figure (the flag still requires at least 1024 MB of available memory to clear the warning). Must be an integer in `[1024, 1048576]`. |
| `--memory-pressure-mode <mode>` | `observe` | Whether the daemon turns its own memory reading into a verdict. `observe` (default) reports the pressure level under `runtime.memory.pressure` in `GET /daemon/status` and raises a `daemon_memory_pressure` issue — a `warning`, so the overall `status` leaves `ok` — whenever the level leaves `normal`. `off` still reports every figure, including the level, but raises no issue, so the overall `status` is unchanged; use it while calibrating, or if you alert on the top-level status. The level is the worse of two ratios: RSS against available memory (what the cgroup OOM killer watches) and V8 heap used against this process's heap ceiling. It covers the daemon root process only; compare it against `runtime.memory.children.rssBytes` for the children. Nothing remediates in either mode. One of `off`, `observe`. |
| `--child-heap-mode <mode>` | `observe` | Whether the daemon models a per-child heap partition of `--memory-budget-mb`. `observe` (default) reports what it would apply — `limits.memory.childHeap.perChildCeilingMb` and `maxConcurrentChildren` — and counts spawns that would have exceeded the limit. **Nothing is applied**: no child is sized from the budget and no spawn is refused. `off` models nothing, and says so on the wire: `maxConcurrentChildren` and `perChildCeilingMb` are both `null` rather than carrying a partition you switched off. A refusal count of 0 does **not** mean the partition would be safe to apply: children still run on the much larger host-derived ceiling, so a workload needing more old space than the modeled ceiling looks perfectly healthy here. Applying the partition ships with the measurement that can answer that. |
| `--event-ring-size <n>` | `8000` | Per-session SSE replay ring depth (#3803 §02 target). Sets the backlog available to `GET /session/:id/events` with `Last-Event-ID: N`. Larger = more reconnect headroom at the cost of a few hundred KB extra RAM per session. SDK clients can additionally request a larger per-subscriber backlog cap on a specific subscription via `?maxQueued=N` (range `[16, 2048]`, default 256). Daemons also emit a non-terminal `slow_client_warning` SSE frame at 75% queue fill so clients can drain / reconnect before getting evicted. Pre-flight `caps.features.slow_client_warning`. |
| `--compacted-replay-max-bytes <n>` | `4194304` | Per-live-session byte cap for the retained replay events in the bounded snapshot returned by `POST /session/:id/load`. The cap applies to `compactedReplay`; the current in-flight `liveJournal` is separately capped by `--max-journal-events` and `--max-journal-bytes` (baseline caps that adaptive growth can raise — see `--max-journal-bytes`). Values must be positive safe integers; invalid values fail at boot, and the hard ceiling is 256 MiB. When older retained replay is dropped, the snapshot begins with `history_truncated`. This does not limit the on-disk transcript. |
| `--max-journal-events <n>` | `10000` | Per-session baseline cap on replay entries retained in the in-flight `liveJournal` for the current unfinished turn. Consecutive compatible text or thought chunks share an entry, with at most 256 source events per entry; other event boundaries are preserved. When exceeded, the daemon first tries adaptive growth (see `--max-journal-bytes`); if no headroom is granted or the grant does not cover the overshoot, the oldest entries are dropped and a `history_truncated` marker is prepended. The marker's `truncatedEvents` and `retainedEvents` counts describe source events. Must be a positive safe integer. Pinning this flag (or `--max-journal-bytes`) disables adaptive growth. |
| `--max-journal-bytes <n>` | `8388608` | Per-session baseline byte cap on the in-flight `liveJournal`, accounted from the serialized source events even when compatible chunks share a replay entry. When a turn outgrows the cap, adaptive growth raises the session's caps toward double (up to a per-session hard cap of 256 MiB, limited by the remaining pool headroom) while the growth granted across all of the daemon's live sessions fits in one shared growth pool sized at 5% of the daemon's effective memory budget — the `--memory-budget-mb` value when passed, capped at resolved available memory, otherwise 50% of auto-detected memory (see `--memory-budget-mb`) — capped at `1024` MB; on hosts reporting `insufficientMemory` the pool is 0 and adaptive growth is disabled. Growth happens on demand, and only as far as the pool allows; when it is refused, the pool is exhausted, or a grant does not cover the overshoot, the oldest entries are dropped whole (at least one entry is always kept), so the retained tail can be much smaller than the cap. Pinning this flag (or `--max-journal-events`) disables adaptive growth. Must be a positive safe integer. Defaults to 8 MiB. |
| `--mcp-client-budget <n>` | — | Positive integer cap on live MCP clients. When `mcp_workspace_pool` is advertised, the cap and transports are shared per workspace runtime; when the tag is absent, the legacy per-session manager enforces it. Combine with `--mcp-budget-mode`. When unset, no accounting-driven enforcement (but `GET /workspace/mcp` still reports `clientCount`). Distinct from claude-code's `MCP_SERVER_CONNECTION_BATCH_SIZE`, which gates startup concurrency rather than total live clients. Pre-flight `caps.features.mcp_guardrails` and `caps.features.mcp_workspace_pool`. |
| `--mcp-budget-mode <m>` | `warn` / `off` | How `--mcp-client-budget` is enforced. `warn` (default when budget set): no refusal, snapshot's `budgets[0].status` flips to `warning` at ≥75% of budget. `enforce`: connects past the cap are refused, per-server cell shows `disabledReason: 'budget'`, deterministic by `mcpServers` declaration order. `off` (default when budget unset): pure observability. Boot rejects `enforce` without a budget. |
| `--external-tool-guard-mode <m>` | `off` | Managed ACP external pre-execution policy. `off` makes no provider calls and advertises no capability. `required` fails startup unless a compatible provider completes the v1 handshake, then fails every supported top-level tool invocation closed unless its single prepare request is allowed. |
| `--external-tool-guard-endpoint <url>` | — | Origin-only loopback HTTP(S) provider URL used in `required` mode, for example `http://127.0.0.1:8787`. Paths, URL credentials, redirects, non-loopback hosts, and proxy routing are not accepted. |
| `--external-tool-guard-timeout-ms <n>` | `3000` | Integer `100..30000`; applies independently to the startup handshake and each prepare request. |
| `--http-bridge` | `true` | Stage 1 mode: production attempts to preheat one primary `qwen --acp` child for compatibility and retries on first use after failure, while each trusted secondary can start one child on demand. Sessions targeting a runtime multiplex onto its child via ACP `newSession()`; untrusted secondaries cannot start ACP. Stage 2 native in-process becomes available later. |
| `--initialize-timeout-ms <n>` | `10000` | ACP child request timeout, including the `initialize` handshake (ms). Must be a positive integer up to `2147483647`. Values above the JS timer ceiling (`2^31-1`) are rejected at boot because Node silently compresses them to 1 ms. Cold-container deployments that need extra headroom for child startup can raise this; the same value governs `newSession`, workspace-status polls, and other ACP ext-method deadlines. |
| `--session-restore-timeout-ms <n>` | `60000` | ACP session load/resume deadline in milliseconds. Must be a positive integer up to `2147483647`; `0` is invalid. If omitted, the default is 60 seconds, raised to an explicitly supplied `--initialize-timeout-ms` when that value is larger; a shorter initialize timeout never lowers the restore budget. The SDK and Web Shell add 10 and 15 seconds of client headroom. A timeout returns retryable `504 session_restore_timeout`; it does not imply that the daemon itself exited. |
| `--allow-origin <pat>` | — | T2.4 ([#4514](https://github.com/QwenLM/qwen-code/issues/4514)). Cross-origin allowlist for browser clients. Repeatable. Each value is `*` (any origin — boot refuses if no bearer token is configured; `--require-auth` on loopback is recommended so `/health` is also bearer-gated, since it is pre-auth on loopback by default; the Web Shell static assets stay pre-auth in every mode, so pass `--no-web` to remove them) or a canonical URL origin (`<scheme>://<host>[:<port>]`, no trailing slash / path / userinfo / query). Without a token, HTTP(S) origins must be loopback; remote HTTP(S) origins require a token. Browser extensions retain tokenless access locally. Subdomain wildcards (`https://*.example.com`) are intentionally unsupported — list each subdomain explicitly, or use `*` with a configured token (and `--require-auth` for full hardening). Matched origins receive CORS response headers (`Access-Control-Allow-Origin`, `Vary: Origin`, methods, headers, max-age, and exposed `Retry-After`); unmatched origins still get a 403 with the same envelope as today's wall. `Origin: null` (sandboxed iframes, file:// docs) is always rejected, even under `*`. Pre-flight via `caps.features.allow_origin`. Loopback self-origin hits are unaffected. |
| `--web` / `--no-web` | `true` | Serve the built Web Shell SPA at the daemon root (`GET /`, `/assets/*`, and `GET /session/<id>` document navigations). These entry points are registered **before** the bearer-auth gate — a browser can't attach a token to a `<script>` subresource or an address-bar navigation, and the shell carries no secrets. API requests follow the daemon's normal authority policy: configured tokens gate normal API routes except loopback `/health` unless `--require-auth` is set, channel webhook ingress always uses its own shared secret, and the token-less trusted-loopback primary listener has full operator access. The SPA deep-link fallback for all other paths sits behind the bearer middleware. On non-loopback binds a one-line stderr warning notes the UI is reachable without auth. Use `--no-web` for an API-only daemon. No effect when the build omits the Web Shell assets (the daemon logs a breadcrumb and runs API-only). |
| `--open` | `false` | After the listener is up, open the Web Shell in your default browser at the daemon URL (with `#token=` appended as a URL fragment when a token is configured — a fragment is never sent to the server, keeping the token out of access logs and Referer headers). No-op with `--no-web`, or in headless / CI / SSH environments where no browser is available. |
| `--open-with-auth` | `false` | Open the Web Shell with bearer authentication on loopback. Requires an enabled Web Shell and built assets. Reuses a configured token or generates a process-lifetime 256-bit bearer and delivers it in the Web Shell fragment. Browser-ineligible environments print the secret-bearing manual URL. Other clients need the same explicitly configured shared token instead. |
| Flag | Default | Purpose |
| --------------------------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--port <n>` | `4170` | TCP port. `0` = OS-assigned ephemeral port. |
| `--hostname <addr>` | `127.0.0.1` | Bind interface. Anything beyond loopback requires a token; when neither `--token` nor `QWEN_SERVER_TOKEN` supplies one, an ephemeral token is generated and printed at startup. |
| `--local-control` | `false` | Share the Web Shell on one selected private IPv4 interface with a daemon-owned revocable pairing token, terminal QR code, exact browser origin, and best-effort sleep inhibition. Composes with `--token`, `--allow-origin`, and `--port 0`; conflicts with `--no-web` and non-default `--hostname`. Use `--local-control-address` when multiple LAN candidates are available, and add `--tls-cert` + `--tls-key` for secure-context browser APIs such as voice input. |
| `--local-control-address <ip>` | — | Which LAN IPv4 address to share when the host has more than one candidate. Only needed if `--local-control` reports an ambiguous choice. |
| `--token <str>` | — | Bearer token. Falls back to `QWEN_SERVER_TOKEN` env var (with leading/trailing whitespace stripped — handy for `$(cat token.txt)`). A non-loopback bind with neither source generates an ephemeral token. An explicitly empty value still refuses to boot on a non-loopback bind; on loopback it shadows the env var and selects the trusted token-less mode (or refuses outright under `--require-auth`). |
| `--require-auth` | `false` | Refuse to start without a bearer token, even on loopback. Hardens the `127.0.0.1` developer default for shared dev hosts / CI runners / multi-tenant workstations where any local user can hit the listener. That fail-fast is loopback-only: loopback boots only with one of its three token sources — `--token`, `QWEN_SERVER_TOKEN`, or `--open-with-auth`, which installs its own generated token before boot (generation is loopback-suppressed) — while on a non-loopback bind the generated ephemeral token satisfies the flag. Gates `/health` behind the bearer too. |
| `--enable-session-shell` | `false` | Enable direct session shell execution. Effective with either a configured bearer token or trusted-loopback mode; calls still require a valid session-bound `X-Qwen-Client-Id`. |
| `--tls-cert <path>` | — | Path to a PEM certificate file. Serve over **HTTPS** instead of HTTP. Must be paired with `--tls-key` (boot fails if only one is given). Unlocks secure-context browser APIs — voice input (`getUserMedia`), WebRTC — over a LAN IP, which browsers otherwise block on plain `http://`. TLS termination only; no auto-generation / ACME. See [HTTPS / TLS](#https--tls-for-mobile--cross-device-access) below. |
| `--tls-key <path>` | — | Path to a PEM private key file. Must be paired with `--tls-cert`. |
| `--max-sessions <n>` | `32` | Cap on concurrent live sessions. New `POST /session` requests that would spawn a fresh child return `503` (with `Retry-After: 5`) when the cap is hit; attaches to existing sessions are NOT counted. Set to `0` to disable. Sized for single-user / small-team usage; raise it if your deployment has the RAM/FD headroom (~30–50 MB per session). |
| `--max-total-sessions <n>` | derived | Optional non-negative integer daemon-wide cap on fresh session creation across all registered workspace runtimes. It applies to new child sessions, session restore, and branch/fork-created sessions; attaching to an existing live session does not consume a slot. Set to `0` for unlimited. When omitted with several startup/restored workspaces, the daemon derives a fixed cap from the per-workspace limit and the startup workspace count; later dynamic registration does not recompute it. |
| `--max-pending-prompts-per-session <n>` | `5` | Per-session cap on prompts accepted by `POST /session/:id/prompt` but not yet settled, including queued prompts and the active prompt. The bridge rejects overflow synchronously with `503`, `Retry-After: 5`, and `code: "prompt_queue_full"` before returning a `promptId`. Set to `0` to disable. `branchSession` serializes on the same FIFO but does not count against this prompt cap. |
| `--workspace <path>` | `process.cwd()` | Absolute workspace directory registered by this daemon. Repeat the flag to host multiple workspaces in one process; the first is primary and remains the default when a request omits `cwd`. Relative values are rejected. Session requests whose canonical `cwd` is not registered return `400 workspace_mismatch`. |
| `--memory-project-scope <mode>` | `workspace` | Project-memory partitioning mode. `workspace` (default) keys memory by the exact registered workspace directory so each daemon workspace gets its own isolated memory; `git-root` is the legacy compatibility mode shared by workspaces resolved to the same Git root. Overrides `QWEN_CODE_MEMORY_PROJECT_SCOPE` when provided; a blank env value is treated as unset, while an unrecognized non-empty value is ignored with a one-time warning and retains the legacy `git-root` behavior. The new default does not migrate existing git-root project memory — use an explicit `git-root` scope to read those entries during migration. |
| `--channel <name\|all>` | — | Experimental daemon-managed channel worker. Repeat the flag to select multiple configured channels, or pass `all` to start every configured channel. `all` cannot be combined with named channels. Selected channel `cwd` values must resolve to a registered workspace; a multi-workspace daemon runs one worker per owning workspace. The worker is owned by `qwen serve`; stop the daemon to stop serve-managed channels. |
| `--max-connections <n>` | `256` | Listener-level TCP connection cap (`server.maxConnections`). Bounds raw socket count irrespective of session count — slow / phantom SSE clients get rejected at accept time once full. Raise alongside `--max-sessions` if your deployment expects many SSE subscribers per session. |
| `--memory-budget-mb <n>` | 50% of cgroup/host | Total memory budget in MB for the whole daemon process tree. When unset, derived as 50% of the cgroup limit or host memory; either way the effective value is capped at resolved available memory, and both the configured and effective figures are reported. It does not change how any `qwen --acp` child is sized; the one consumer today is adaptive live-journal growth: one daemon-wide growth pool derived as 5% of the effective budget (capped at `1024` MB; on hosts reporting `insufficientMemory` the pool is 0 and adaptive growth is disabled) is shared by every workspace bridge — see `--max-journal-bytes`. Resolved figures appear under `limits.memory` in `GET /daemon/status`, alongside registered and live child counts and advisory per-child shares under `runtime.memory`. A host too small for the minimum reports `insufficientMemory` rather than being clamped upward; because the derived fraction is 50%, any host under ~2 GB trips this. Pass an explicit `--memory-budget-mb 1024` on such a host to override the derived figure (the flag still requires at least 1024 MB of available memory to clear the warning). Must be an integer in `[1024, 1048576]`. |
| `--memory-pressure-mode <mode>` | `observe` | Whether the daemon turns its own memory reading into a verdict. `observe` (default) reports the pressure level under `runtime.memory.pressure` in `GET /daemon/status` and raises a `daemon_memory_pressure` issue — a `warning`, so the overall `status` leaves `ok` — whenever the level leaves `normal`. `off` still reports every figure, including the level, but raises no issue, so the overall `status` is unchanged; use it while calibrating, or if you alert on the top-level status. The level is the worse of two ratios: RSS against available memory (what the cgroup OOM killer watches) and V8 heap used against this process's heap ceiling. It covers the daemon root process only; compare it against `runtime.memory.children.rssBytes` for the children. Nothing remediates in either mode. One of `off`, `observe`. |
| `--child-heap-mode <mode>` | `observe` | Whether the daemon models a per-child heap partition of `--memory-budget-mb`. `observe` (default) reports what it would apply — `limits.memory.childHeap.perChildCeilingMb` and `maxConcurrentChildren` — and counts spawns that would have exceeded the limit. **Nothing is applied**: no child is sized from the budget and no spawn is refused. `off` models nothing, and says so on the wire: `maxConcurrentChildren` and `perChildCeilingMb` are both `null` rather than carrying a partition you switched off. A refusal count of 0 does **not** mean the partition would be safe to apply: children still run on the much larger host-derived ceiling, so a workload needing more old space than the modeled ceiling looks perfectly healthy here. Applying the partition ships with the measurement that can answer that. |
| `--event-ring-size <n>` | `8000` | Per-session SSE replay ring depth (#3803 §02 target). Sets the backlog available to `GET /session/:id/events` with `Last-Event-ID: N`. Larger = more reconnect headroom at the cost of a few hundred KB extra RAM per session. SDK clients can additionally request a larger per-subscriber backlog cap on a specific subscription via `?maxQueued=N` (range `[16, 2048]`, default 256). Daemons also emit a non-terminal `slow_client_warning` SSE frame at 75% queue fill so clients can drain / reconnect before getting evicted. Pre-flight `caps.features.slow_client_warning`. |
| `--compacted-replay-max-bytes <n>` | `4194304` | Per-live-session byte cap for the retained replay events in the bounded snapshot returned by `POST /session/:id/load`. The cap applies to `compactedReplay`; the current in-flight `liveJournal` is separately capped by `--max-journal-events` and `--max-journal-bytes` (baseline caps that adaptive growth can raise — see `--max-journal-bytes`). Values must be positive safe integers; invalid values fail at boot, and the hard ceiling is 256 MiB. When older retained replay is dropped, the snapshot begins with `history_truncated`. This does not limit the on-disk transcript. |
| `--max-journal-events <n>` | `10000` | Per-session baseline cap on replay entries retained in the in-flight `liveJournal` for the current unfinished turn. Consecutive compatible text or thought chunks share an entry, with at most 256 source events per entry; other event boundaries are preserved. When exceeded, the daemon first tries adaptive growth (see `--max-journal-bytes`); if no headroom is granted or the grant does not cover the overshoot, the oldest entries are dropped and a `history_truncated` marker is prepended. The marker's `truncatedEvents` and `retainedEvents` counts describe source events. Must be a positive safe integer. Pinning this flag (or `--max-journal-bytes`) disables adaptive growth. |
| `--max-journal-bytes <n>` | `8388608` | Per-session baseline byte cap on the in-flight `liveJournal`, accounted from the serialized source events even when compatible chunks share a replay entry. When a turn outgrows the cap, adaptive growth raises the session's caps toward double (up to a per-session hard cap of 256 MiB, limited by the remaining pool headroom) while the growth granted across all of the daemon's live sessions fits in one shared growth pool sized at 5% of the daemon's effective memory budget — the `--memory-budget-mb` value when passed, capped at resolved available memory, otherwise 50% of auto-detected memory (see `--memory-budget-mb`) — capped at `1024` MB; on hosts reporting `insufficientMemory` the pool is 0 and adaptive growth is disabled. Growth happens on demand, and only as far as the pool allows; when it is refused, the pool is exhausted, or a grant does not cover the overshoot, the oldest entries are dropped whole (at least one entry is always kept), so the retained tail can be much smaller than the cap. Pinning this flag (or `--max-journal-events`) disables adaptive growth. Must be a positive safe integer. Defaults to 8 MiB. |
| `--mcp-client-budget <n>` | — | Positive integer cap on live MCP clients. When `mcp_workspace_pool` is advertised, the cap and transports are shared per workspace runtime; when the tag is absent, the legacy per-session manager enforces it. Combine with `--mcp-budget-mode`. When unset, no accounting-driven enforcement (but `GET /workspace/mcp` still reports `clientCount`). Distinct from claude-code's `MCP_SERVER_CONNECTION_BATCH_SIZE`, which gates startup concurrency rather than total live clients. Pre-flight `caps.features.mcp_guardrails` and `caps.features.mcp_workspace_pool`. |
| `--mcp-budget-mode <m>` | `warn` / `off` | How `--mcp-client-budget` is enforced. `warn` (default when budget set): no refusal, snapshot's `budgets[0].status` flips to `warning` at ≥75% of budget. `enforce`: connects past the cap are refused, per-server cell shows `disabledReason: 'budget'`, deterministic by `mcpServers` declaration order. `off` (default when budget unset): pure observability. Boot rejects `enforce` without a budget. |
| `--external-tool-guard-mode <m>` | `off` | Managed ACP external pre-execution policy. `off` makes no provider calls and advertises no capability. `required` fails startup unless a compatible provider completes the v1 handshake, then fails every supported top-level tool invocation closed unless its single prepare request is allowed. |
| `--external-tool-guard-endpoint <url>` | — | Origin-only loopback HTTP(S) provider URL used in `required` mode, for example `http://127.0.0.1:8787`. Paths, URL credentials, redirects, non-loopback hosts, and proxy routing are not accepted. |
| `--external-tool-guard-timeout-ms <n>` | `3000` | Integer `100..30000`; applies independently to the startup handshake and each prepare request. |
| `--http-bridge` | `true` | Stage 1 mode: production attempts to preheat one primary `qwen --acp` child for compatibility and retries on first use after failure, while each trusted secondary can start one child on demand. Sessions targeting a runtime multiplex onto its child via ACP `newSession()`; untrusted secondaries cannot start ACP. Stage 2 native in-process becomes available later. |
| `--initialize-timeout-ms <n>` | `10000` | ACP child request timeout, including the `initialize` handshake (ms). Must be a positive integer up to `2147483647`. Values above the JS timer ceiling (`2^31-1`) are rejected at boot because Node silently compresses them to 1 ms. Cold-container deployments that need extra headroom for child startup can raise this; the same value governs `newSession`, workspace-status polls, and other ACP ext-method deadlines. |
| `--session-restore-timeout-ms <n>` | `60000` | ACP session load/resume deadline in milliseconds. Must be a positive integer up to `2147483647`; `0` is invalid. If omitted, the default is 60 seconds, raised to an explicitly supplied `--initialize-timeout-ms` when that value is larger; a shorter initialize timeout never lowers the restore budget. The SDK and Web Shell add 10 and 15 seconds of client headroom. A timeout returns retryable `504 session_restore_timeout`; it does not imply that the daemon itself exited. |
| `--allow-origin <pat>` | — | T2.4 ([#4514](https://github.com/QwenLM/qwen-code/issues/4514)). Cross-origin allowlist for browser clients. Repeatable. Each value is `*` (any origin — on loopback binds boot refuses when no bearer token is configured; on non-loopback binds a generated ephemeral token satisfies these guards, so the refusals are loopback-only; `--require-auth` on loopback is recommended so `/health` is also bearer-gated, since it is pre-auth on loopback by default; the Web Shell static assets stay pre-auth in every mode, so pass `--no-web` to remove them) or a canonical URL origin (`<scheme>://<host>[:<port>]`, no trailing slash / path / userinfo / query). Without a token, HTTP(S) origins must be loopback on loopback binds; on non-loopback binds the generated token satisfies the same guard. Remote HTTP(S) origins authenticate with the bearer on every API route; the pre-auth exceptions are only the Web Shell document/asset routes, the MCP App sandbox, the channel webhook ingress, which authenticates with its own `x-qwen-webhook-secret`, and `/health` on loopback binds (bearer-gated elsewhere). Browser extensions retain tokenless access locally. Subdomain wildcards (`https://*.example.com`) are intentionally unsupported — list each subdomain explicitly, or use `*` with a configured token (and `--require-auth` for full hardening). Matched origins receive CORS response headers (`Access-Control-Allow-Origin`, `Vary: Origin`, methods, headers, max-age, and exposed `Retry-After`); unmatched origins still get a 403 with the same envelope as today's wall. `Origin: null` (sandboxed iframes, file:// docs) is always rejected, even under `*`. Pre-flight via `caps.features.allow_origin`. Loopback self-origin hits are unaffected. |
| `--web` / `--no-web` | `true` | Serve the built Web Shell SPA at the daemon root (`GET /`, `/assets/*`, and `GET /session/<id>` document navigations). These entry points are registered **before** the bearer-auth gate — a browser can't attach a token to a `<script>` subresource or an address-bar navigation, and the shell carries no secrets. API requests follow the daemon's normal authority policy: configured tokens gate normal API routes except loopback `/health` unless `--require-auth` is set, channel webhook ingress always uses its own shared secret, and the token-less trusted-loopback primary listener has full operator access. The SPA deep-link fallback for all other paths sits behind the bearer middleware. On non-loopback binds a one-line stderr warning notes the UI is reachable without auth. Use `--no-web` for an API-only daemon. No effect when the build omits the Web Shell assets (the daemon logs a breadcrumb and runs API-only). |
| `--open` | `false` | After the listener is up, open the Web Shell in your default browser at the daemon URL (with `#token=` appended as a URL fragment when a token resolves — configured or generated — since a fragment is never sent to the server, keeping the token out of access logs and Referer headers). No-op with `--no-web`, or in headless / CI / SSH environments where no browser is available. |
| `--open-with-auth` | `false` | Open the Web Shell with bearer authentication on loopback. Requires an enabled Web Shell and built assets. Reuses a configured token or generates a process-lifetime 256-bit bearer and delivers it in the Web Shell fragment. Browser-ineligible environments print the secret-bearing manual URL. Other clients need the same explicitly configured shared token instead. |
> **Memory project scope caveats.**
>
@@ -678,10 +692,10 @@ provider decision with their normal tool policy and isolation boundary.
- **Loopback only** — `127.0.0.0/8`, `localhost`, or `::1`; no auth needed.
- **`localhost` is resolved once, pinned, and verified after listen** — if its actual listener address is outside `127.0.0.0/8` or `::1`, token-less startup closes the listener and fails.
- **`--hostname 0.0.0.0` requires a token** — boot refuses without one.
- **`--hostname 0.0.0.0` requires a token** — one is generated when neither token source is supplied; explicitly empty sources still fail closed.
- **`LOOPBACK_BINDS` includes IPv6** — `::1` and `[::1]` count as loopback for the no-token rule.
- **Host header allowlist** — on **loopback** binds the daemon checks `Host:` matches `localhost:port` / `127.0.0.1:port` / `[::1]:port` / `host.docker.internal:port` or the exact bound loopback address and port (case-insensitive per RFC 7230 §5.4) to defend against DNS rebinding. When listening on port 80 or 443, the corresponding port-less forms are also accepted because browsers omit scheme-default ports. This supports the complete IPv4 loopback range (`127.0.0.0/8`) without admitting unrelated Hosts. **Non-loopback binds (`--hostname 0.0.0.0`) intentionally bypass the Host allowlist** — the operator has chosen the surface area, so the bearer-token gate is the sole authentication layer for normal API routes; reverse proxies / SNI / client cert pinning are the operator's responsibility, not the daemon's. If you need Host-based isolation on a non-loopback bind, terminate TLS + check Host at a front proxy.
- **CORS denies any browser Origin by default** — returns `403` JSON. Pass **`--allow-origin <pattern>`** (repeatable, T2.4 #4514) to opt specific browser origins through. Each value is either the literal `*` (any origin — boot refuses if no bearer token is configured; `--require-auth` on loopback is recommended for full hardening since `/health` remains pre-auth on loopback by default — note that the Web Shell static assets (`/`, `/assets/*`, `/session/:id` document navigations) are mounted before the bearer in every mode and stay pre-auth even under `--require-auth`, so use `--no-web` when the residual browser surface matters) or a canonical URL origin (`<scheme>://<host>[:<port>]`, no trailing slash / path / userinfo). Without a token, HTTP(S) entries must use a loopback host; remotely hosted browser origins require a token because they can execute code through the operator API as the daemon user. Explicit browser-extension origins retain their tokenless local-automation path. Matched origins receive proper CORS response headers (`Access-Control-Allow-Origin: <echoed>`, `Vary: Origin`, plus standard methods / headers / max-age and exposed `Retry-After`); unmatched origins still get a 403 with the same envelope as the default wall. `caps.features.allow_origin` is advertised conditionally so SDK / webui clients can pre-flight whether the daemon honors cross-origin hits before issuing them. Example: `qwen serve --allow-origin http://localhost:3000 --allow-origin http://localhost:5173`. Loopback self-origin hits (e.g. the Web Shell UI) are unaffected — a separate Origin-strip shim handles them regardless of `--allow-origin`. **Browser webuis without `--allow-origin` configured** still fall back to the same Stage 1 options as before: package as a native shell (Electron/Tauri) so no `Origin` header is sent, or front the daemon with a same-origin reverse proxy.
- **CORS denies any browser Origin by default** — returns `403` JSON. Pass **`--allow-origin <pattern>`** (repeatable, T2.4 #4514) to opt specific browser origins through. Each value is either the literal `*` (any origin — on a **loopback** bind boot refuses if no bearer token is configured, while a non-loopback bind boots because token generation supplies one; `--require-auth` on loopback is recommended for full hardening since `/health` remains pre-auth on loopback by default — note that the Web Shell static assets (`/`, `/assets/*`, `/session/:id` document navigations) are mounted before the bearer in every mode and stay pre-auth even under `--require-auth`, so use `--no-web` when the residual browser surface matters) or a canonical URL origin (`<scheme>://<host>[:<port>]`, no trailing slash / path / userinfo). On a **loopback** bind without a token, HTTP(S) entries must use a loopback host; a non-loopback bind always carries a token — supplied or generated — precisely because remotely hosted browser origins can execute code through the operator API as the daemon user. Explicit browser-extension origins retain their tokenless local-automation path. Matched origins receive proper CORS response headers (`Access-Control-Allow-Origin: <echoed>`, `Vary: Origin`, plus standard methods / headers / max-age and exposed `Retry-After`); unmatched origins still get a 403 with the same envelope as the default wall. `caps.features.allow_origin` is advertised conditionally so SDK / webui clients can pre-flight whether the daemon honors cross-origin hits before issuing them. Example: `qwen serve --allow-origin http://localhost:3000 --allow-origin http://localhost:5173`. Loopback self-origin hits (e.g. the Web Shell UI) are unaffected — a separate Origin-strip shim handles them regardless of `--allow-origin`. The built-in remote Web Shell's same-origin **HTTP** requests also work without `--allow-origin`: on a non-loopback primary listener an `Origin` equal to the direct socket scheme plus the normalized `Host` authority is bearer-authenticated and then stripped ahead of the CORS wall, while its existing public static routes stay unauthenticated. Two exclusions still need `--allow-origin` — forwarded headers never establish same-origin trust, so a TLS-terminating reverse proxy's `https` browser origin is not same-origin (and neither is a plain-HTTP intermediary that rewrites the `Host` header, such as nginx's default `proxy_set_header Host $proxy_host` or k8s Ingress; the remedy there is `--allow-origin <origin>` for the origin your browser sees, or a proxy that forwards `Host` verbatim — port translation alone needs nothing, because only the forwarded `Host` is compared, with just the scheme-default `:80`/`:443` stripped), and the WebSocket upgrade routes (terminal, voice) keep a separate CSWSH gate that admits only loopback origins, allowlisted origins, and the Local Control listener's own origin.
- **Chrome extension browser automation is separate from framing.** `qwen serve --allow-origin chrome-extension://<id>` lets the extension frame the Web Shell and connect to the daemon. Console/network/screenshot/click tools require an external CDP MCP adapter command: `QWEN_CDP_MCP_COMMAND=/path/to/cdp-mcp-adapter qwen serve --allow-origin chrome-extension://<id>`. The main CLI package does not bundle a browser automation adapter; clients can check `caps.features.includes('browser_automation_mcp')` before presenting those tools as available.
- **A spawned `qwen --acp` child receives its owning runtime's effective environment.** The daemon freezes a process-env base, applies that workspace's settings/env-file overlay to a runtime-local snapshot, and never writes the overlay back to `process.env`; same-named keys in another runtime do not cross over. `QWEN_SERVER_TOKEN` is scrubbed before spawn because the agent does not need the daemon bearer. Loader-affecting variables (`NODE_OPTIONS`, `npm_config_node_options` and npm's config-file redirects, `NODE_PATH`, `OPENSSL_CONF`, `NODE_REPL_EXTERNAL_MODULE`, `npm_config_node_gyp`, `npm_config_init_module`, `LD_PRELOAD`, `LD_AUDIT`, `DYLD_INSERT_LIBRARIES`, `BASH_ENV`, `ZDOTDIR`, exported bash function definitions `BASH_FUNC_*`) are likewise never passed to session subprocesses — the daemon scrubs them from its own `process.env` and from the frozen base env that session-hosting children spawn with (the base env keeps them only under the `DEV=true` harness, whose `.ts` entries still need the tsx loader), and `.env` / `settings.json` `env` sources reject them (see [settings](./configuration/settings.md)); this applies to every session the daemon hosts. Base credentials such as `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `QWEN_*`, and `DASHSCOPE_API_KEY` otherwise pass through unless the runtime overlay changes them. **This is intentional, not a sandbox.** The agent runs as the same UID with shell-tool access, so anything in `~/.bashrc`, `~/.aws/credentials`, or `~/.npmrc` is reachable by prompt injection regardless. Environment isolation between runtimes is not an operating-system security boundary; do not run `qwen serve` under an identity that has credentials you would not trust the agent with.
- **Agent text reads are child-local and follow the regular CLI permission rules, not the workspace filesystem boundary.** Direct `read_file` can reach host text paths outside every registered workspace: external paths default to confirmation, and allow rules or approval modes may approve them automatically. Approved reads use the configurable CLI output limits rather than the workspace filesystem's returned-output, full-snapshot, and large-text scan caps. This applies to every shared text-read consumer, so the pre-reads performed by write, edit, notebook, sed, and artifact operations lose those caps together with the workspace filesystem's read audit, symlink rejection, and read-side TOCTOU protections — see [the read design](../design/daemon-local-text-reads.md) for the exact list. Because a confirmation payload is built by reading the file, an out-of-workspace diff is fanned out to **every** attached SSE subscriber before anyone approves it — in the interactive CLI that content is seen only by the person at the terminal. Treat authenticated daemon clients as the same security principal. HTTP filesystem routes remain workspace-scoped and agent discovery-tool behavior is unchanged.
+8 -7
View File
@@ -535,11 +535,12 @@ is not a scope source. Foreign or missing targets do not disclose their text.
The selected absence contract is either HTTP 404 (`http-404`) or HTTP 200 with
JSON null (`null-200`); other shapes are not interpreted as absence.
DELETE must return HTTP 200 with the exact message `Memory deleted successfully`
or `Memory deleted successfully!`. Optional status must be `SUCCEEDED`, event
must be `DELETE`, and cascade_count must be numeric zero. Non-null error/errors,
conflicting fields, 202, 204 and other unrecognized replies remain unknown.
Responses are bounded to 1 MiB and strictly decoded as UTF-8/JSON.
DELETE follows the official client's response handling: require a successful
HTTP status and parse the bounded UTF-8 JSON response without matching message
text or imposing extra response-field rules. A successful status alone does not
confirm deletion: an exact GET must then verify absence. Empty responses
(including HTTP 204), invalid JSON and non-success HTTP responses remain unknown.
Responses are bounded to 1 MiB. There are no automatic retries.
### Approval, verification and limits
@@ -553,7 +554,7 @@ permission policy; read-only annotations are not automatic authorization.
Web Shell displays the exact ID and full expected text through ordinary MCP
parameter approval. After approval, forget reads the target again and compares
ID, all configured scope fields and full text before submitting one DELETE.
If it receives a recognized success acknowledgement, it performs one exact GET
If it receives a successful HTTP response with valid JSON, it performs one exact GET
to verify absence. One total 100–30000 ms deadline covers those three steps;
human approval waiting is outside that deadline. No state, confirmation token
or mandatory earlier get is required: a direct call with the correct full ID
@@ -561,7 +562,7 @@ and original text receives the same checks.
| Result | Meaning |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `deleted` | Recognized deletion acknowledgement followed by a read confirming absence. |
| `deleted` | Successful HTTP response with valid JSON followed by a read confirming absence. |
| `not_deleted` | This call submitted zero DELETE requests. A fixed reason distinguishes invalid input, unavailable/changed target, verification failure or pre-delete cancellation. |
| `unknown` | DELETE started but its result or subsequent absence check is uncertain. Do not retry automatically; explicitly read to inspect current state. |
@@ -194,6 +194,12 @@ describe('Mem0 deletion MCP contract', () => {
arguments: args,
});
expect(result.structuredContent).toMatchObject(outcome);
if (outcome.status === 'deleted') {
expect(result.structuredContent).toMatchObject({
message:
'The delete request returned a successful HTTP response and a subsequent exact read confirmed absence. Search indexes and existing conversations may still contain the text.',
});
}
expect(result.isError).toBe(outcome.status !== 'deleted');
expect(JSON.stringify(result)).not.toContain('中文');
const text = (result.content as Array<{ text: string }>)[0]!.text;
@@ -85,7 +85,7 @@ const getMessages = {
};
const forgetMessages = {
deleted:
'The provider confirmed deletion and a subsequent exact read confirmed absence. Search indexes and existing conversations may still contain the text.',
'The delete request returned a successful HTTP response and a subsequent exact read confirmed absence. Search indexes and existing conversations may still contain the text.',
not_deleted: 'This call did not submit a DELETE request.',
unknown:
'The record may have been deleted. Do not retry automatically; explicitly read the target to check its current state.',
@@ -310,17 +310,16 @@ describe('Mem0 single-record deletion HTTP engine', () => {
});
it.each([
{ message: 'Memory deleted successfully' },
{ message: 'Memory deleted successfully!' },
{
message: 'Memory deleted successfully!',
status: 'SUCCEEDED',
event: 'DELETE',
cascade_count: 0,
error: null,
},
{ message: 'Memory memory:1 deleted successfully.' },
{ message: 'ok' },
{},
null,
[],
{ status: 'PENDING', event: 'DELETE', cascade_count: 0 },
{ error: 'provider-specific response', status: 'FAILED', cascade_count: 1 },
])(
'requires absence after a recognized acknowledgement %j',
'confirms absence independently of DELETE JSON contents %j',
async (payload) => {
const fetcher = vi
.fn<FetchLike>()
@@ -330,43 +329,63 @@ describe('Mem0 single-record deletion HTTP engine', () => {
expect(
await createDeleteRequestEngine(runtime(), fetcher).forget(input()),
).toMatchObject({ status: 'deleted' });
expect(fetcher).toHaveBeenCalledTimes(3);
expect(calls(fetcher).map((call) => call.method)).toEqual([
'GET',
'DELETE',
'GET',
]);
},
);
it.each([
{},
null,
[],
{ message: 'ok' },
{ status: 'PENDING' },
...[
{ error: '' },
{ errors: [] },
{ status: 'FAILED' },
{ event: 'ADD' },
{ cascade_count: 1 },
{ cascade_count: '0' },
{ cascade_count: null },
].map((patch) => ({ message: 'Memory deleted successfully!', ...patch })),
])('stops after unknown DELETE acknowledgement %j', async (payload) => {
const fetcher = vi
.fn<FetchLike>()
.mockResolvedValueOnce(Response.json(record()))
.mockResolvedValueOnce(Response.json(payload));
expect(
await createDeleteRequestEngine(runtime(), fetcher).forget(input()),
).toMatchObject({ status: 'unknown' });
expect(fetcher).toHaveBeenCalledTimes(2);
});
it.each([202, 204, 301, 400, 401, 403, 404, 429, 500])(
'does not treat DELETE HTTP %s as success or rollback',
it.each([201, 202])(
'verifies absence after HTTP %s with JSON',
async (status) => {
const fetcher = vi
.fn<FetchLike>()
.mockResolvedValueOnce(Response.json(record()))
.mockResolvedValueOnce(new Response(null, { status }));
.mockResolvedValueOnce(
Response.json({ message: 'accepted' }, { status }),
)
.mockResolvedValueOnce(Response.json(record()));
expect(
await createDeleteRequestEngine(runtime(), fetcher).forget(input()),
).toMatchObject({ status: 'unknown' });
expect(calls(fetcher).map((call) => call.method)).toEqual([
'GET',
'DELETE',
'GET',
]);
},
);
it.each([201, 202])(
'reports deleted after HTTP %s when the follow-up GET confirms absence',
async (status) => {
const fetcher = vi
.fn<FetchLike>()
.mockResolvedValueOnce(Response.json(record()))
.mockResolvedValueOnce(
Response.json({ message: 'accepted' }, { status }),
)
.mockResolvedValueOnce(absent());
expect(
await createDeleteRequestEngine(runtime(), fetcher).forget(input()),
).toMatchObject({ status: 'deleted' });
expect(calls(fetcher).map((call) => call.method)).toEqual([
'GET',
'DELETE',
'GET',
]);
},
);
it.each([301, 400, 401, 403, 404, 429, 500])(
'rejects non-success DELETE HTTP %s even with valid JSON',
async (status) => {
const fetcher = vi
.fn<FetchLike>()
.mockResolvedValueOnce(Response.json(record()))
.mockResolvedValueOnce(Response.json({ message: 'ok' }, { status }));
expect(
await createDeleteRequestEngine(runtime(), fetcher).forget(input()),
).toMatchObject({ status: 'unknown' });
@@ -393,6 +412,9 @@ describe('Mem0 single-record deletion HTTP engine', () => {
});
it.each([
() => new Response(null, { status: 200 }),
() => new Response(null, { status: 202 }),
() => new Response(null, { status: 204 }),
() => new Response('{bad'),
() => new Response(new Uint8Array([0x22, 0xff, 0x22])),
() => new Response('x'.repeat(1024 * 1024 + 1)),
@@ -115,13 +115,11 @@ export function createDeleteRequestEngine(
return notDeleted('target_changed');
submitted = true;
const response = await fetcher(url, { ...init, method: 'DELETE' });
if (response.status !== 200) {
if (!response.ok) {
await response.body?.cancel().catch(() => undefined);
return { status: 'unknown', memoryId };
}
const value: unknown = JSON.parse(await readBoundedBody(response));
if (!isDeleteAcknowledgement(value))
return { status: 'unknown', memoryId };
JSON.parse(await readBoundedBody(response));
const after = await readTarget(memoryId, url, init);
init.signal.throwIfAborted();
return {
@@ -137,18 +135,6 @@ export function createDeleteRequestEngine(
};
}
function isDeleteAcknowledgement(value: unknown): boolean {
return (
isRecord(value) &&
!hasError(value) &&
(value['message'] === 'Memory deleted successfully' ||
value['message'] === 'Memory deleted successfully!') &&
(value['status'] === undefined || value['status'] === 'SUCCEEDED') &&
(value['event'] === undefined || value['event'] === 'DELETE') &&
(value['cascade_count'] === undefined || value['cascade_count'] === 0)
);
}
function hasError(value: Record<string, unknown>): boolean {
return ['error', 'errors'].some(
(key) => value[key] !== undefined && value[key] !== null,
+169 -121
View File
@@ -74,7 +74,7 @@
"prettier": "^3.5.3",
"react-devtools-core": "^6.1.5",
"semver": "^7.7.2",
"sharp": "^0.35.0",
"sharp": "^0.35.4",
"strip-ansi": "^7.1.2",
"tsx": "^4.20.3",
"typescript-eslint": "^8.30.1",
@@ -2427,9 +2427,9 @@
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
"cpu": [
"arm64"
],
@@ -2445,13 +2445,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.2"
"@img/sharp-libvips-darwin-arm64": "1.3.3"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
"cpu": [
"x64"
],
@@ -2467,20 +2467,20 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.2"
"@img/sharp-libvips-darwin-x64": "1.3.3"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
"@img/sharp-wasm32": "0.35.4"
},
"engines": {
"node": ">=20.9.0"
@@ -2490,9 +2490,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
"cpu": [
"arm64"
],
@@ -2506,9 +2506,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
"cpu": [
"x64"
],
@@ -2522,12 +2522,15 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2538,12 +2541,15 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2554,12 +2560,15 @@
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2570,12 +2579,15 @@
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2586,12 +2598,15 @@
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2602,12 +2617,15 @@
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2618,12 +2636,15 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2634,12 +2655,15 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2650,12 +2674,15 @@
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2668,16 +2695,19 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.2"
"@img/sharp-libvips-linux-arm": "1.3.3"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2690,16 +2720,19 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.2"
"@img/sharp-libvips-linux-arm64": "1.3.3"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2712,16 +2745,19 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.2"
"@img/sharp-libvips-linux-ppc64": "1.3.3"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2734,16 +2770,19 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.2"
"@img/sharp-libvips-linux-riscv64": "1.3.3"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2756,16 +2795,19 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.2"
"@img/sharp-libvips-linux-s390x": "1.3.3"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2778,16 +2820,19 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.2"
"@img/sharp-libvips-linux-x64": "1.3.3"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2800,16 +2845,19 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2822,17 +2870,17 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
"@img/sharp-libvips-linuxmusl-x64": "1.3.3"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.11.1"
"@emnapi/runtime": "^1.11.3"
},
"engines": {
"node": ">=20.9.0"
@@ -2842,16 +2890,16 @@
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
"@img/sharp-wasm32": "0.35.4"
},
"engines": {
"node": ">=20.9.0"
@@ -2861,9 +2909,9 @@
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
"cpu": [
"arm64"
],
@@ -2880,9 +2928,9 @@
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
"cpu": [
"ia32"
],
@@ -2899,9 +2947,9 @@
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
"cpu": [
"x64"
],
@@ -23051,9 +23099,9 @@
}
},
"node_modules/sharp": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.1.0",
@@ -23067,31 +23115,31 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.3",
"@img/sharp-darwin-x64": "0.35.3",
"@img/sharp-freebsd-wasm32": "0.35.3",
"@img/sharp-libvips-darwin-arm64": "1.3.2",
"@img/sharp-libvips-darwin-x64": "1.3.2",
"@img/sharp-libvips-linux-arm": "1.3.2",
"@img/sharp-libvips-linux-arm64": "1.3.2",
"@img/sharp-libvips-linux-ppc64": "1.3.2",
"@img/sharp-libvips-linux-riscv64": "1.3.2",
"@img/sharp-libvips-linux-s390x": "1.3.2",
"@img/sharp-libvips-linux-x64": "1.3.2",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
"@img/sharp-linux-arm": "0.35.3",
"@img/sharp-linux-arm64": "0.35.3",
"@img/sharp-linux-ppc64": "0.35.3",
"@img/sharp-linux-riscv64": "0.35.3",
"@img/sharp-linux-s390x": "0.35.3",
"@img/sharp-linux-x64": "0.35.3",
"@img/sharp-linuxmusl-arm64": "0.35.3",
"@img/sharp-linuxmusl-x64": "0.35.3",
"@img/sharp-webcontainers-wasm32": "0.35.3",
"@img/sharp-win32-arm64": "0.35.3",
"@img/sharp-win32-ia32": "0.35.3",
"@img/sharp-win32-x64": "0.35.3"
"@img/sharp-darwin-arm64": "0.35.4",
"@img/sharp-darwin-x64": "0.35.4",
"@img/sharp-freebsd-wasm32": "0.35.4",
"@img/sharp-libvips-darwin-arm64": "1.3.3",
"@img/sharp-libvips-darwin-x64": "1.3.3",
"@img/sharp-libvips-linux-arm": "1.3.3",
"@img/sharp-libvips-linux-arm64": "1.3.3",
"@img/sharp-libvips-linux-ppc64": "1.3.3",
"@img/sharp-libvips-linux-riscv64": "1.3.3",
"@img/sharp-libvips-linux-s390x": "1.3.3",
"@img/sharp-libvips-linux-x64": "1.3.3",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
"@img/sharp-libvips-linuxmusl-x64": "1.3.3",
"@img/sharp-linux-arm": "0.35.4",
"@img/sharp-linux-arm64": "0.35.4",
"@img/sharp-linux-ppc64": "0.35.4",
"@img/sharp-linux-riscv64": "0.35.4",
"@img/sharp-linux-s390x": "0.35.4",
"@img/sharp-linux-x64": "0.35.4",
"@img/sharp-linuxmusl-arm64": "0.35.4",
"@img/sharp-linuxmusl-x64": "0.35.4",
"@img/sharp-webcontainers-wasm32": "0.35.4",
"@img/sharp-win32-arm64": "0.35.4",
"@img/sharp-win32-ia32": "0.35.4",
"@img/sharp-win32-x64": "0.35.4"
},
"peerDependenciesMeta": {
"@types/node": {
@@ -27710,7 +27758,7 @@
"picomatch": "^4.0.1",
"prompts": "^2.4.2",
"proper-lockfile": "^4.1.2",
"sharp": "^0.35.0",
"sharp": "^0.35.4",
"shell-quote": "^1.9.0",
"simple-git": "^3.36.0",
"strip-ansi": "^7.1.0",
+1 -1
View File
@@ -165,7 +165,7 @@
"prettier": "^3.5.3",
"react-devtools-core": "^6.1.5",
"semver": "^7.7.2",
"sharp": "^0.35.0",
"sharp": "^0.35.4",
"strip-ansi": "^7.1.2",
"tsx": "^4.20.3",
"typescript-eslint": "^8.30.1",
+355
View File
@@ -21936,6 +21936,66 @@ describe('createAcpSessionBridge', () => {
await bridge.shutdown();
});
it.each(['yolo', 123, undefined])(
'forwards only a string expected Plan execution policy (%s)',
async (expectedPlanExecutionMode) => {
const { bridge, session, conn } = await setupForPermission();
const subAbort = new AbortController();
const events = bridge
.subscribeEvents(session.sessionId, {
signal: subAbort.signal,
})
[Symbol.asyncIterator]();
try {
const pending = (
conn as unknown as {
requestPermission(p: unknown): Promise<unknown>;
}
).requestPermission({
sessionId: session.sessionId,
toolCall: {
toolCallId: 'exit-plan',
title: 'Exit plan mode',
_meta: { toolName: 'exit_plan_mode' },
},
options: [
{
optionId: 'restore_previous',
name: 'Approve',
kind: 'allow_once',
},
],
});
const event = await events.next();
const { requestId } = event.value!.data as { requestId: string };
expect(
bridge.respondToSessionPermission(
session.sessionId,
requestId,
{
outcome: { outcome: 'selected', optionId: 'restore_previous' },
expectedPlanExecutionMode,
answers: { '0': 'Keep answers too' },
ignored: true,
} as RequestPermissionResponse,
{ clientId: session.clientId },
),
).toBe(true);
const response = await pending;
expect(response).toEqual({
outcome: { outcome: 'selected', optionId: 'restore_previous' },
answers: { '0': 'Keep answers too' },
...(typeof expectedPlanExecutionMode === 'string'
? { expectedPlanExecutionMode }
: {}),
});
} finally {
subAbort.abort();
await bridge.shutdown();
}
},
);
it('forwards session-scoped permission answers without arbitrary metadata', async () => {
const { bridge, session, conn } = await setupForPermission();
@@ -26194,6 +26254,301 @@ describe('createAcpSessionBridge', () => {
});
});
it('preserves DAC plan policies through controls, notifications and reconnect snapshots', async () => {
let currentMode = 'plan';
let planExecutionMode: string | undefined = 'yolo';
let reconcileMode: string | undefined;
let connection: AgentSideConnection;
const controls: Array<Record<string, unknown>> = [];
const factory: ChannelFactory = async () => {
const { clientStream, agentStream } = createInMemoryChannel();
const modes = () => ({
currentModeId: currentMode,
availableModes: [],
_meta: { planExecutionMode },
});
const agent = new FakeAgent({
newSessionImpl: (params) =>
Promise.resolve({ sessionId: `sess:${params.cwd}`, modes: modes() }),
extMethodImpl: (method, params) => {
if (method === SERVE_STATUS_EXT_METHODS.sessionContext) {
if (reconcileMode) {
planExecutionMode = reconcileMode;
reconcileMode = undefined;
}
return Promise.resolve({ state: { modes: modes() } });
}
if (method === SERVE_CONTROL_EXT_METHODS.sessionApprovalMode) {
const control = params as Record<string, unknown>;
controls.push(control);
const previous = currentMode;
currentMode = control['planMode']
? 'plan'
: String(control['mode']);
planExecutionMode = control['planMode']
? String(control['mode'])
: undefined;
return Promise.resolve({
previous,
current: currentMode,
planExecutionMode,
});
}
return Promise.resolve({});
},
});
connection = new AgentSideConnection(() => agent as Agent, agentStream);
return {
stream: clientStream,
exited: new Promise<
| { exitCode: number | null; signalCode: NodeJS.Signals | null }
| undefined
>(() => {}),
kill: async () => {},
killSync: () => {},
};
};
const bridge = makeBridge({ channelFactory: factory });
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const abort = new AbortController();
const events = bridge
.subscribeEvents(session.sessionId, {
snapshot: true,
signal: abort.signal,
})
[Symbol.asyncIterator]();
const nextMode = async () => {
for (;;) {
const event = await events.next();
if (event.done) throw new Error('Mode stream ended');
if (event.value.type === 'approval_mode_changed')
return event.value.data;
}
};
try {
expect((await events.next()).value?.data).toMatchObject({
currentApprovalMode: 'plan',
planExecutionMode: 'yolo',
});
expect(bridge.getDaemonStatusSnapshot().sessions[0]).toMatchObject({
currentApprovalMode: 'plan',
planExecutionMode: 'yolo',
});
await expect(
bridge.setSessionApprovalMode(session.sessionId, ApprovalMode.DEFAULT, {
persist: false,
planMode: true,
}),
).resolves.toMatchObject({ mode: 'plan', planExecutionMode: 'default' });
expect(controls).toEqual([
{ sessionId: session.sessionId, mode: 'default', planMode: true },
]);
expect(await nextMode()).toMatchObject({
next: 'plan',
planExecutionMode: 'default',
});
const reconnect = bridge
.subscribeEvents(session.sessionId, {
snapshot: true,
signal: abort.signal,
})
[Symbol.asyncIterator]();
expect((await reconnect.next()).value?.data).toMatchObject({
currentApprovalMode: 'plan',
planExecutionMode: 'default',
});
reconcileMode = 'yolo';
await bridge.setSessionApprovalMode(
session.sessionId,
ApprovalMode.DEFAULT,
{
persist: false,
planMode: true,
},
);
expect(await nextMode()).toMatchObject({
next: 'plan',
planExecutionMode: 'default',
});
expect(await nextMode()).toMatchObject({
next: 'plan',
planExecutionMode: 'yolo',
});
await connection!.extNotification('qwen/notify/session/mode-update', {
v: 1,
sessionId: session.sessionId,
currentModeId: 'plan',
planExecutionMode: 'auto-edit',
});
expect(await nextMode()).toMatchObject({
next: 'plan',
planExecutionMode: 'auto-edit',
});
expect(bridge.getDaemonStatusSnapshot().sessions[0]).toMatchObject({
currentApprovalMode: 'plan',
planExecutionMode: 'auto-edit',
});
await bridge.setSessionApprovalMode(
session.sessionId,
ApprovalMode.AUTO_EDIT,
{
persist: false,
planMode: false,
},
);
const exited = await nextMode();
expect(exited).toMatchObject({ next: 'auto-edit' });
expect(exited).not.toHaveProperty('planExecutionMode');
expect(bridge.getDaemonStatusSnapshot().sessions[0]).toMatchObject({
currentApprovalMode: 'auto-edit',
});
expect(bridge.getDaemonStatusSnapshot().sessions[0]).not.toHaveProperty(
'planExecutionMode',
);
} finally {
abort.abort();
await bridge.shutdown();
}
});
it.each([true, false])(
'keeps peer Plan and execution state unchanged when persisting DAC planMode=%s',
async (planMode) => {
const modesById = new Map<
string,
{
currentModeId: string;
availableModes: [];
_meta: { planExecutionMode: string | undefined };
}
>();
const factory: ChannelFactory = async () => {
const { clientStream, agentStream } = createInMemoryChannel();
const agent = new FakeAgent({
newSessionImpl: () => {
const index = modesById.size;
const sessionId = `dac-persist-${index}`;
const modes = {
currentModeId: index === 1 ? 'plan' : 'auto-edit',
availableModes: [] as [],
_meta: { planExecutionMode: index === 1 ? 'default' : undefined },
};
modesById.set(sessionId, modes);
return Promise.resolve({ sessionId, modes });
},
extMethodImpl: (method, params) => {
const control = params as Record<string, unknown>;
const modes = modesById.get(String(control['sessionId']));
if (method === SERVE_STATUS_EXT_METHODS.sessionContext)
return Promise.resolve({ state: { modes } });
if (
method === SERVE_CONTROL_EXT_METHODS.sessionApprovalMode &&
modes
) {
const previous = modes.currentModeId;
modes.currentModeId = control['planMode']
? 'plan'
: String(control['mode']);
modes._meta.planExecutionMode = control['planMode']
? String(control['mode'])
: undefined;
return Promise.resolve({
previous,
current: modes.currentModeId,
planExecutionMode: modes._meta.planExecutionMode,
});
}
return Promise.resolve({});
},
});
new AgentSideConnection(() => agent as Agent, agentStream);
return {
stream: clientStream,
exited: new Promise<
| { exitCode: number | null; signalCode: NodeJS.Signals | null }
| undefined
>(() => {}),
kill: async () => {},
killSync: () => {},
};
};
const persistApprovalMode = vi.fn(async () => {});
const bridge = makeBridge({
channelFactory: factory,
persistApprovalMode,
});
const sessions = [];
for (let index = 0; index < 3; index++) {
sessions.push(
await bridge.spawnOrAttach({
workspaceCwd: WS_A,
sessionScope: 'thread',
}),
);
}
expect(new Set(sessions.map((session) => session.sessionId)).size).toBe(
3,
);
const abort = new AbortController();
const modeEvents: unknown[][] = [[], [], []];
const collecting = sessions.map(async (session, index) => {
for await (const event of bridge.subscribeEvents(session.sessionId, {
signal: abort.signal,
})) {
if (event.type === 'approval_mode_changed')
modeEvents[index]!.push(event.data);
}
});
try {
await expect(
bridge.setSessionApprovalMode(
sessions[0]!.sessionId,
ApprovalMode.YOLO,
{ planMode, persist: true },
),
).resolves.toMatchObject({
mode: planMode ? 'plan' : 'yolo',
persisted: true,
});
expect(persistApprovalMode).toHaveBeenCalledWith(
WS_A,
ApprovalMode.YOLO,
);
for (const index of [1, 2]) {
const snapshot = bridge
.subscribeEvents(sessions[index]!.sessionId, {
snapshot: true,
signal: abort.signal,
})
[Symbol.asyncIterator]();
const event = (await snapshot.next()).value;
expect(event?.type).toBe('session_snapshot');
expect(event?.data).toMatchObject({
currentApprovalMode: index === 1 ? 'plan' : 'auto-edit',
});
if (index === 1)
expect(event?.data).toMatchObject({ planExecutionMode: 'default' });
else expect(event?.data).not.toHaveProperty('planExecutionMode');
}
await vi.waitFor(() => expect(modeEvents[0]).toHaveLength(1));
expect(modeEvents[0]![0]).toMatchObject({
next: planMode ? 'plan' : 'yolo',
persisted: true,
});
if (planMode)
expect(modeEvents[0]![0]).toMatchObject({
planExecutionMode: 'yolo',
});
else expect(modeEvents[0]![0]).not.toHaveProperty('planExecutionMode');
expect(modeEvents.slice(1)).toEqual([[], []]);
} finally {
abort.abort();
await Promise.all(collecting);
await bridge.shutdown();
}
},
);
describe('setSessionApprovalMode (#4175 Wave 4 PR 17)', () => {
/**
* #4282 fold-in 4 (qwen-latest C1). Build a channel factory whose
+88 -14
View File
@@ -1285,6 +1285,7 @@ interface SessionEntry {
currentModelId?: string;
/** §2.3: cached approval mode, updated by every `publishApprovalModeChanged` call. */
currentApprovalMode?: string;
planExecutionMode?: string;
/** §2.3: monotonic counter bumped on every `model_switched` publish. */
modelPublishGeneration: number;
/** §2.3: monotonic counter bumped on every `approval_mode_changed` publish. */
@@ -1487,8 +1488,7 @@ function extractPermissionResponseMetadata(
response: unknown,
): Readonly<Record<string, unknown>> | undefined {
if (response === null || typeof response !== 'object') return undefined;
// Keep this extension deliberately narrow. Today the only non-ACP field
// expected by the agent is AskUserQuestion's `answers` payload.
const metadata: Record<string, unknown> = {};
const answers = (response as { readonly answers?: unknown }).answers;
if (
answers !== null &&
@@ -1497,10 +1497,16 @@ function extractPermissionResponseMetadata(
) {
const entries = Object.entries(answers as Record<string, unknown>);
if (entries.every(([, v]) => typeof v === 'string')) {
return { answers };
metadata['answers'] = answers;
}
}
return undefined;
const expectedPlanExecutionMode = (
response as { readonly expectedPlanExecutionMode?: unknown }
).expectedPlanExecutionMode;
if (typeof expectedPlanExecutionMode === 'string') {
metadata['expectedPlanExecutionMode'] = expectedPlanExecutionMode;
}
return Object.keys(metadata).length > 0 ? metadata : undefined;
}
function parseWorkspaceMemoryRememberResult(
@@ -4662,7 +4668,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
publishModelSwitched(entry as SessionEntry, modelId, originator),
// A2: centralised approval_mode_changed publish on in-session mode
// promotion. `previous` is read from the bridge state cache.
(entry, modeId, originator) => {
(entry, modeId, originator, planExecutionMode) => {
const se = entry as SessionEntry;
publishApprovalModeChanged(
se,
@@ -4670,6 +4676,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
previous: se.currentApprovalMode ?? 'default',
next: modeId,
persisted: false,
planExecutionMode,
},
originator,
);
@@ -5952,11 +5959,13 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
mode: ApprovalMode,
persist: boolean,
originatorClientId?: string,
planMode?: boolean,
): Promise<{
sessionId: string;
mode: ApprovalMode;
previous: ApprovalMode;
persisted: boolean;
planExecutionMode?: ApprovalMode;
}> {
if (persist && !persistApprovalMode) {
throw new Error(
@@ -5975,13 +5984,21 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
withTimeout(
entry.connection.extMethod(
SERVE_CONTROL_EXT_METHODS.sessionApprovalMode,
{ sessionId: entry.sessionId, mode },
{
sessionId: entry.sessionId,
mode,
...(planMode !== undefined ? { planMode } : {}),
},
),
initTimeoutMs,
SERVE_CONTROL_EXT_METHODS.sessionApprovalMode,
),
getTransportClosedReject(entry),
])) as { previous: ApprovalMode; current: ApprovalMode };
])) as {
previous: ApprovalMode;
current: ApprovalMode;
planExecutionMode?: ApprovalMode;
};
if (
typeof response.current !== 'string' ||
@@ -5992,6 +6009,16 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
);
}
if (
response.planExecutionMode !== undefined &&
(response.planExecutionMode === 'plan' ||
!KNOWN_APPROVAL_MODES.has(response.planExecutionMode))
) {
throw new Error('Agent returned an invalid plan execution mode');
}
const planExecutionMode =
response.current === 'plan' ? response.planExecutionMode : undefined;
let persisted = false;
if (persist) {
try {
@@ -6015,10 +6042,13 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
previous: response.previous,
next: response.current,
persisted,
planExecutionMode,
},
originatorClientId,
);
if (persisted) {
// DAC controls remain session-scoped; persistence only changes the
// workspace default used by future sessions.
if (persisted && planMode === undefined) {
broadcastWorkspaceEvent(
{
type: 'approval_mode_changed',
@@ -6037,6 +6067,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
continue;
}
peer.currentApprovalMode = response.current;
peer.planExecutionMode = undefined;
}
}
succeeded = true;
@@ -6045,6 +6076,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
mode: response.current,
previous: response.previous,
persisted,
...(planExecutionMode ? { planExecutionMode } : {}),
};
} finally {
entry.approvalModeRoundtripInFlight = false;
@@ -6914,10 +6946,17 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
const publishApprovalModeChanged = (
entry: SessionEntry,
payload: { previous: string; next: string; persisted: boolean },
payload: {
previous: string;
next: string;
persisted: boolean;
planExecutionMode?: string;
},
originatorClientId: string | undefined,
): void => {
entry.currentApprovalMode = payload.next;
entry.planExecutionMode =
payload.next === 'plan' ? payload.planExecutionMode : undefined;
entry.approvalModePublishGeneration++;
// See `publishModelSwitched`: `publish()` never throws, so no wrapper.
entry.events.publish({
@@ -6928,6 +6967,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
previous: payload.previous,
next: payload.next,
persisted: payload.persisted,
...(entry.planExecutionMode
? { planExecutionMode: entry.planExecutionMode }
: {}),
},
...(originatorClientId ? { originatorClientId } : {}),
});
@@ -6990,9 +7032,18 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
publishModelSwitched(entry, actual, undefined);
}
} else {
const actual = (
status?.state?.modes as { currentModeId?: string } | undefined
)?.currentModeId;
const modes = status?.state?.modes as
| { currentModeId?: string; _meta?: Record<string, unknown> | null }
| undefined;
const actual = modes?.currentModeId;
const selected = modes?._meta?.['planExecutionMode'];
const planExecutionMode =
actual === 'plan' &&
typeof selected === 'string' &&
selected !== 'plan' &&
KNOWN_APPROVAL_MODES.has(selected)
? selected
: undefined;
// Same enum backstop as the demux path (`handleInSessionModeUpdate`):
// `actual` is an agent-supplied id typed `unknown`, and the SDK's
// `isApprovalModeChangedData` is a structural check (deliberately
@@ -7003,7 +7054,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
writeStderrLine(
`[reconcile] session=${entry.sessionId} target=approvalMode action=dropped reason=unknown_mode mode=${actual}`,
);
} else if (actual && actual !== entry.currentApprovalMode) {
} else if (
actual &&
(actual !== entry.currentApprovalMode ||
planExecutionMode !== entry.planExecutionMode)
) {
writeStderrLine(
`[reconcile] session=${entry.sessionId} target=approvalMode action=corrected cached=${entry.currentApprovalMode ?? '<unset>'} actual=${actual}`,
);
@@ -7013,6 +7068,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
previous: entry.currentApprovalMode ?? 'default',
next: actual,
persisted: false,
planExecutionMode,
},
undefined,
);
@@ -7370,7 +7426,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
entry: SessionEntry,
resp: {
models?: { currentModelId?: unknown } | null;
modes?: { currentModeId?: unknown } | null;
modes?: {
currentModeId?: unknown;
_meta?: Record<string, unknown> | null;
} | null;
},
): void => {
const model = resp.models?.currentModelId;
@@ -7384,6 +7443,14 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
const mode = resp.modes?.currentModeId;
if (typeof mode === 'string' && KNOWN_APPROVAL_MODES.has(mode)) {
entry.currentApprovalMode = mode;
const selected = resp.modes?._meta?.['planExecutionMode'];
entry.planExecutionMode =
mode === 'plan' &&
typeof selected === 'string' &&
selected !== 'plan' &&
KNOWN_APPROVAL_MODES.has(selected)
? selected
: undefined;
} else if (mode != null) {
writeStderrLine(
`[seed] session=${entry.sessionId} target=approvalMode action=dropped value=${JSON.stringify(mode)} reason=${typeof mode !== 'string' ? 'invalid_type' : 'unknown_mode'}`,
@@ -9416,6 +9483,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
...(entry.currentApprovalMode
? { currentApprovalMode: entry.currentApprovalMode }
: {}),
...(entry.planExecutionMode
? { planExecutionMode: entry.planExecutionMode }
: {}),
maxJournalEvents: journalLimits?.maxEvents ?? maxJournalEvents,
maxJournalBytes: journalLimits?.maxBytes ?? maxJournalBytes,
};
@@ -10891,6 +10961,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
sessionId: entry.sessionId,
currentModelId: entry.currentModelId ?? null,
currentApprovalMode: entry.currentApprovalMode ?? null,
...(entry.planExecutionMode
? { planExecutionMode: entry.planExecutionMode }
: {}),
recordingDegraded: entry.recordingDegraded,
},
});
@@ -13083,6 +13156,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
mode,
opts.persist,
originatorClientId,
opts.planMode,
);
},
@@ -1230,6 +1230,47 @@ describe('BridgeClient — A2UI session update publishing', () => {
});
});
describe('BridgeClient — mode promotion fallback', () => {
it.each(['plan', 'auto-edit'])(
'publishes %s policy metadata without an onModePromoted callback',
async (currentModeId) => {
const publish = vi.fn();
const entry = { sessionId: 'sess:mode', events: { publish } };
const client = new BridgeClient(
(() => entry) as never,
vi.fn(),
{ request: vi.fn() },
0,
Infinity,
);
await client.extNotification('qwen/notify/session/mode-update', {
v: 1,
sessionId: entry.sessionId,
currentModeId,
planExecutionMode: 'yolo',
});
const promoted = publish.mock.calls.find(
([event]) => event.type === 'approval_mode_changed',
)?.[0];
expect(promoted).toMatchObject({
data: {
sessionId: entry.sessionId,
previous: 'default',
next: currentModeId,
persisted: false,
},
});
if (currentModeId === 'plan') {
expect(promoted.data.planExecutionMode).toBe('yolo');
} else {
expect(promoted.data).not.toHaveProperty('planExecutionMode');
}
},
);
});
describe('BridgeClient — original timestamp preservation', () => {
const noPermissionFlow = () => {
throw new Error('test: permission flow should not run');
+13
View File
@@ -828,11 +828,14 @@ export class BridgeClient implements Client {
* Called by the A2 `current_mode_update` demux when the agent
* switches approval mode in-session (exit_plan_mode, ProceedAlways,
* /mode). `previous` is read from the bridge state cache.
* `planExecutionMode` is the validated non-Plan execution policy while
* modeId is Plan; undefined outside Plan or when no valid policy is sent.
*/
private readonly onModePromoted?: (
entry: BridgeClientSessionEntry,
modeId: string,
originatorClientId: string | undefined,
planExecutionMode?: string,
) => void,
/**
* Reverse tool channel (issue #5626, Phase 2). Resolves the
@@ -2849,6 +2852,14 @@ export class BridgeClient implements Client {
);
return;
}
const selected = params['planExecutionMode'];
const planExecutionMode =
currentModeId === 'plan' &&
typeof selected === 'string' &&
selected !== 'plan' &&
KNOWN_APPROVAL_MODES.has(selected)
? selected
: undefined;
const entry = this.resolveEntry(sessionId);
if (!entry) {
writeStderrLine(
@@ -2867,6 +2878,7 @@ export class BridgeClient implements Client {
entry,
currentModeId,
entry.activePromptOriginatorClientId,
planExecutionMode,
);
} else {
// Fallback path (no `onModePromoted` injected — tests / non-bridge
@@ -2890,6 +2902,7 @@ export class BridgeClient implements Client {
previous: 'default',
next: currentModeId,
persisted: false,
...(planExecutionMode ? { planExecutionMode } : {}),
},
...(entry.activePromptOriginatorClientId
? { originatorClientId: entry.activePromptOriginatorClientId }
+4 -1
View File
@@ -1305,6 +1305,8 @@ export interface BridgeDaemonSessionDiagnostic {
lastSeenAt?: number;
currentModelId?: string;
currentApprovalMode?: string;
/** Selected execution policy while the session is in Plan. */
planExecutionMode?: string;
/**
* The session's EFFECTIVE live-journal caps right now — the configured
* baseline, or higher when adaptive growth raised them mid-turn. One
@@ -2202,13 +2204,14 @@ export interface AcpSessionBridge extends WorkspaceEventBridge {
setSessionApprovalMode(
sessionId: string,
mode: ApprovalMode,
opts: { persist: boolean },
opts: { persist: boolean; planMode?: boolean },
context?: BridgeClientRequestContext,
): Promise<{
sessionId: string;
mode: ApprovalMode;
previous: ApprovalMode;
persisted: boolean;
planExecutionMode?: ApprovalMode;
}>;
/**
@@ -0,0 +1,21 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import {
CHANNEL_CONTROL_DEFAULT_TIMEOUT_MS,
MAX_DAEMON_WORKSPACES,
} from './channel-control-timeouts.js';
describe('channel-control compatibility', () => {
it('preserves the public legacy capacity export', () => {
expect(MAX_DAEMON_WORKSPACES).toBe(25);
});
it('preserves the transaction and rollback timeout budget', () => {
expect(CHANNEL_CONTROL_DEFAULT_TIMEOUT_MS).toBe(2_130_000);
});
});
@@ -4,12 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
/** @deprecated Compatibility value; internal capacity policies are independent. */
export const MAX_DAEMON_WORKSPACES = 25;
export const CHANNEL_WORKER_STARTUP_TIMEOUT_MS = 30_000;
export const CHANNEL_WORKER_STOP_GRACE_MS = 10_000;
export const CHANNEL_WORKER_KILL_GRACE_MS = 2_000;
export const CHANNEL_CONTROL_CLIENT_HEADROOM_MS = 30_000;
const MAX_CHANNEL_CONTROL_WORKSPACES = 25;
const CHANNEL_WORKER_STOP_TIMEOUT_MS =
CHANNEL_WORKER_STOP_GRACE_MS + CHANNEL_WORKER_KILL_GRACE_MS;
@@ -19,6 +21,6 @@ const CHANNEL_WORKER_STOP_TIMEOUT_MS =
// non-cancellable mutation continues in the daemon.
export const CHANNEL_CONTROL_DEFAULT_TIMEOUT_MS =
2 *
MAX_DAEMON_WORKSPACES *
MAX_CHANNEL_CONTROL_WORKSPACES *
(CHANNEL_WORKER_STOP_TIMEOUT_MS + CHANNEL_WORKER_STARTUP_TIMEOUT_MS) +
CHANNEL_CONTROL_CLIENT_HEADROOM_MS;
@@ -4,13 +4,17 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { createChildHeapPolicy } from './child-heap-policy.js';
import {
MIN_CHILD_HEAP_MB,
resolveDaemonMemoryBudget,
} from './daemon-memory-budget.js';
vi.mock('./channel-control-timeouts.js', () => ({
MAX_DAEMON_WORKSPACES: 256,
}));
describe('createChildHeapPolicy', () => {
it.each([2_048, 8_192, 32_768, 262_144])(
'models a partition whose total fits the pool (%i MB host)',
@@ -107,9 +111,9 @@ describe('createChildHeapPolicy', () => {
).toMatchObject({ maxConcurrentChildren: 1, perChildCeilingMb: 512 });
});
it('sizes an 8 GB host for seven children, and a large host by the workspace cap', () => {
it('sizes an 8 GB host for seven children, and a large host independently of the legacy workspace cap', () => {
// Pinned: these are the numbers an operator plans against. The large host
// divides by MAX_DAEMON_WORKSPACES rather than pool/512, so the ceiling is
// divides by the modeled child cap rather than pool/512, so the ceiling is
// 614 MB and not the floor.
expect(
createChildHeapPolicy({
+4 -3
View File
@@ -8,7 +8,8 @@ import {
MIN_CHILD_HEAP_MB,
type DaemonMemoryBudget,
} from './daemon-memory-budget.js';
import { MAX_DAEMON_WORKSPACES } from './channel-control-timeouts.js';
const MAX_MODELED_ACP_CHILDREN = 25;
/**
* Whether the daemon models a per-child heap partition.
@@ -73,7 +74,7 @@ export interface ChildHeapPolicySnapshot {
* `committedProcessCount`, which counts a terminating child until it
* actually exits — deliberately, since its memory is still resident. So a
* replacement spawned before the old child exits transiently makes the
* count one higher than steady state. Where `MAX_DAEMON_WORKSPACES` is the
* count one higher than steady state. Where `MAX_MODELED_ACP_CHILDREN` is the
* binding term (`childPoolMb >= 12800`, i.e. a ~32 GB host and up), a
* daemon at 25 live children books a refusal on every channel replacement,
* with no memory pressure involved. Do not net this out by giving the
@@ -116,7 +117,7 @@ export function createChildHeapPolicy(options: {
// reads as its *default* heap, roughly 4 GB, against a pool of nothing.
const admissible = Math.min(
Math.floor(budget.childPoolMb / MIN_CHILD_HEAP_MB),
MAX_DAEMON_WORKSPACES,
MAX_MODELED_ACP_CHILDREN,
);
// `floor(pool / admissible) >= MIN_CHILD_HEAP_MB` by construction, but the
// legacy cap is `floor(available / 2)` and is under the floor whenever
@@ -80,6 +80,54 @@ function goalCardRecord(
}
describe('createTranscriptReplayMachine', () => {
it('projects the daemon identity on every user block before a turn result', () => {
const projected = updates(
createTranscriptReplayMachine(),
record('user-1', 'user', {
daemonPromptId: 'daemon-prompt-1',
message: {
role: 'user',
parts: [
{ text: 'model input' },
{ inlineData: { mimeType: 'image/png', data: 'AQID' } },
],
},
systemPayload: {
displayText: 'visible input',
hookContext: '',
attachmentReferences: [
{
type: 'resource',
attachmentId: 'notes.txt',
mimeType: 'text/plain',
size: 3,
},
],
},
}),
);
expect(projected).toHaveLength(3);
for (const update of projected) {
expect(update.sessionUpdate).toBe('user_message_chunk');
expect(update._meta).not.toHaveProperty('daemonPromptId');
expect(update._meta).toMatchObject({
promptId: 'daemon-prompt-1',
qwenTranscript: { sourceRecordIds: ['user-1'] },
});
}
});
it('does not infer a prompt identity for legacy user records', () => {
const projected = updates(
createTranscriptReplayMachine(),
record('legacy', 'user', {
message: { role: 'user', parts: [{ text: 'same prompt' }] },
}),
);
expect(projected).toHaveLength(1);
expect(projected[0]?._meta?.['promptId']).toBeUndefined();
});
it('stamps stable segment identity across replayed text parts', () => {
const projected = updates(
createTranscriptReplayMachine(),
@@ -511,6 +559,94 @@ describe('createTranscriptReplayMachine', () => {
expect(machine.snapshot().goalState?.goal).toEqual(recommitted);
});
it.each([
undefined,
null,
'invalid',
{},
{ callId: '', subagentSessionReady: true },
{ callId: 1, subagentSessionReady: true },
{ callId: 'agent-1', subagentSessionReady: 'false' },
])('reports and skips malformed readiness payload %j', (systemPayload) => {
const onDiagnostic = vi.fn();
const machine = createTranscriptReplayMachine({ onDiagnostic });
expect(
updates(
machine,
record('ready-malformed', 'system', {
subtype: 'agent_session_ready',
systemPayload,
}),
),
).toEqual([]);
expect(onDiagnostic).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
code: 'malformed_agent_session_ready',
recordId: 'ready-malformed',
path: 'systemPayload',
}),
);
});
it.each([false, true])(
'replays valid readiness %s without a diagnostic',
(subagentSessionReady) => {
const onDiagnostic = vi.fn();
const machine = createTranscriptReplayMachine({ onDiagnostic });
updates(
machine,
record('start', 'assistant', {
message: {
role: 'model',
parts: [
{ functionCall: { id: 'agent-1', name: 'agent', args: {} } },
],
},
}),
);
expect(
updates(
machine,
record('ready', 'system', {
subtype: 'agent_session_ready',
systemPayload: { callId: 'agent-1', subagentSessionReady },
}),
),
).toEqual([
expect.objectContaining({
sessionUpdate: 'tool_call_update',
toolCallId: 'agent-1',
_meta: expect.objectContaining({ subagentSessionReady }),
}),
]);
expect(onDiagnostic).not.toHaveBeenCalled();
},
);
it.each([false, true])(
'reports and skips readiness %s without a matching tool start',
(subagentSessionReady) => {
const onDiagnostic = vi.fn();
const machine = createTranscriptReplayMachine({ onDiagnostic });
expect(
updates(
machine,
record('orphan-ready', 'system', {
subtype: 'agent_session_ready',
systemPayload: { callId: 'missing-start', subagentSessionReady },
}),
),
).toEqual([]);
expect(onDiagnostic).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
code: 'orphan_agent_session_ready',
recordId: 'orphan-ready',
path: 'systemPayload.callId',
}),
);
},
);
it('reports and skips a malformed goal_state record', () => {
const onDiagnostic = vi.fn();
const machine = createTranscriptReplayMachine({ onDiagnostic });
+47 -3
View File
@@ -644,20 +644,25 @@ class DefaultTranscriptReplayMachine implements TranscriptReplayMachine {
emit: (update: SessionUpdate) => TranscriptReplayEmission,
meta: UpdateMetaOptions,
): Iterable<TranscriptReplayEmission> {
const userMeta: UpdateMetaOptions =
typeof record.daemonPromptId === 'string' &&
record.daemonPromptId.trim().length > 0
? { ...meta, extra: { ...meta.extra, promptId: record.daemonPromptId } }
: meta;
const payload = isObjectRecord(record.systemPayload)
? record.systemPayload
: undefined;
const replayMeta: UpdateMetaOptions =
record.subtype === 'mid_turn_user_message'
? {
...meta,
...userMeta,
extra: {
...meta.extra,
...userMeta.extra,
source: 'mid_turn_message_injected',
qwenDiscreteMessage: true,
},
}
: meta;
: userMeta;
if (
record.subtype === 'goal_runtime' ||
record.subtype === 'notification' ||
@@ -976,6 +981,45 @@ class DefaultTranscriptReplayMachine implements TranscriptReplayMachine {
emit: (update: SessionUpdate) => TranscriptReplayEmission,
meta: UpdateMetaOptions,
): Iterable<TranscriptReplayEmission> {
if (record.subtype === 'agent_session_ready') {
const payload = isObjectRecord(record.systemPayload)
? record.systemPayload
: undefined;
if (
typeof payload?.['callId'] !== 'string' ||
payload['callId'].length === 0 ||
typeof payload['subagentSessionReady'] !== 'boolean'
) {
this.report(
'malformed_agent_session_ready',
'Skipped a malformed subagent session readiness record.',
record.uuid,
'systemPayload',
);
return;
}
const callId = payload['callId'];
if (!this.pendingToolCalls.has(callId)) {
this.report(
'orphan_agent_session_ready',
'Skipped subagent readiness without a matching pending tool call.',
record.uuid,
'systemPayload.callId',
);
return;
}
yield emit({
sessionUpdate: 'tool_call_update',
toolCallId: callId,
_meta: buildUpdateMeta({
...meta,
extra: {
subagentSessionReady: payload['subagentSessionReady'],
},
}),
});
return;
}
if (record.subtype === 'goal_state') {
const payload = parseGoalStateRecordPayloadV2(record.systemPayload);
if (!payload) {
+3 -23
View File
@@ -89,7 +89,6 @@ Inbound: Platform message
→ ChannelAgentBridge.prompt() → agent
Outbound: Agent response
→ BlockStreamer (if enabled: split into blocks at paragraph boundaries)
→ sendMessage() → platform
```
@@ -103,7 +102,6 @@ Everything between `handleInbound()` and `sendMessage()` is handled by the base
| --------------- | ------------------------------------------------------------------------------------ |
| `ChannelBase` | Abstract base class — extend this to build a channel adapter |
| `AcpBridge` | Current standalone `qwen channel start` bridge implementation over `qwen-code --acp` |
| `BlockStreamer` | Progressive multi-message delivery for block streaming |
| `SessionRouter` | Maps senders to agent sessions with configurable scoping |
| `SenderGate` | DM access control (allowlist / pairing / open) |
| `GroupGate` | Group chat policy and @mention gating |
@@ -154,8 +152,6 @@ constructor(name: string, config: ChannelConfig, bridge: ChannelAgentBridge, opt
| `onResponseChunk(chatId, chunk, sessionId, segment)` | Hook called per streaming text chunk — override for progressive display while preserving immutable `segment.sourceLabel` attribution (default: no-op) |
| `onResponseComplete(chatId, fullText, sessionId, segment)` | Hook called when full response is ready — override to customize delivery (default: attributes delivery with `segment.sourceLabel` in named-task mode) |
**Block streaming:** When `blockStreaming: "on"` is set in the channel config, the base class automatically splits the agent's streaming response into multiple messages at paragraph boundaries. See [Block Streaming](#block-streaming) below.
**Built-in slash commands:** `/clear` (`/reset`, `/new`), `/help`, `/status`
**ChannelBaseOptions:**
@@ -363,27 +359,11 @@ interface Attachment {
`handleInbound()` automatically resolves attachments: images with `data` are sent to the model as vision input, files with `filePath` get their path appended to the prompt text so the agent can read them with its tools.
## Block Streaming
## Response delivery
When `blockStreaming: "on"` is set in a channel's config, the agent's response is delivered as multiple separate messages instead of one large wall of text. The `BlockStreamer` accumulates streaming chunks and emits completed blocks based on paragraph boundaries and size heuristics.
For an inbound turn the base class forwards streaming chunks to adapter callbacks and routes the completed response through `onResponseComplete`. Scheduled loop runs, webhook-triggered tasks, and background replies forward chunks the same way but deliver through `pushProactive` / `deliverBackgroundReply`, so an adapter that enables proactive send must not treat `onResponseComplete` as its only delivery seam. Adapters may provide native progressive display, such as updating an existing card in place. Platform length splitting is unchanged.
**Config fields** (on `ChannelConfig`):
| Field | Type | Default | Description |
| ------------------------ | ------------------------ | --------------- | --------------------------------------------------------------------------- |
| `blockStreaming` | `'on' \| 'off'` | `'off'` | Enable/disable block streaming |
| `blockStreamingChunk` | `{ minChars, maxChars }` | `{ 400, 1000 }` | `minChars`: don't emit until this size. `maxChars`: force-emit at this size |
| `blockStreamingCoalesce` | `{ idleMs }` | `{ 1500 }` | Emit buffered text after this many ms of silence from the agent |
**How it works:**
1. Text accumulates as the agent streams its response
2. When the buffer reaches `minChars` and hits a paragraph break (`\n\n`), that block is sent as a separate message
3. If the buffer reaches `maxChars` without a paragraph break, it force-splits at the best break point (newline > space)
4. If the agent goes quiet for `idleMs`, the buffer is flushed (as long as it's past `minChars`)
5. When the agent finishes, any remaining text is sent immediately regardless of `minChars`
Block streaming and `onResponseChunk` work independently — plugins can override `onResponseChunk` for their own purposes while block streaming handles delivery.
The obsolete `blockStreaming`, `blockStreamingChunk`, and `blockStreamingCoalesce` settings have been removed. The base class no longer sends separate messages at paragraph boundaries or after an idle timer.
## Further reading
@@ -1,249 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { BlockStreamer } from './BlockStreamer.js';
describe('BlockStreamer', () => {
let sent: string[];
let send: (text: string) => Promise<void>;
beforeEach(() => {
vi.useFakeTimers();
sent = [];
send = async (text: string) => {
sent.push(text);
};
});
afterEach(() => {
vi.useRealTimers();
});
function createStreamer(
overrides: Partial<{
minChars: number;
maxChars: number;
idleMs: number;
}> = {},
) {
return new BlockStreamer({
minChars: overrides.minChars ?? 20,
maxChars: overrides.maxChars ?? 60,
idleMs: overrides.idleMs ?? 500,
send,
});
}
it('does not emit below minChars', () => {
const s = createStreamer();
s.push('short');
expect(sent).toEqual([]);
expect(s.blockCount).toBe(0);
});
it('emits at paragraph boundary when buffer >= minChars', async () => {
const s = createStreamer({ minChars: 10 });
s.push('Hello world, this is a paragraph.\n\nSecond part');
// Should have emitted the first paragraph, keeping the line terminator
// trim removed so consumers can see the real line boundary.
await s.flush();
expect(sent).toEqual([
'Hello world, this is a paragraph.\n',
'Second part',
]);
expect(s.blockCount).toBe(2);
});
it('does not split at paragraph boundary when text before it < minChars', async () => {
const s = createStreamer({ minChars: 100 });
s.push('Short.\n\nAlso short.');
// Neither section exceeds minChars, and total < maxChars
expect(sent).toEqual([]);
await s.flush();
expect(sent).toEqual(['Short.\n\nAlso short.']);
});
it('force-splits at maxChars', async () => {
const s = createStreamer({ minChars: 10, maxChars: 30 });
// 40 chars, no newlines — should force-split at space near 30
s.push('aaaa bbbb cccc dddd eeee ffff gggg hhhh');
await s.flush();
// First block splits around 30 chars at a space boundary
expect(sent.length).toBe(2);
expect(sent[0]!.length).toBeLessThanOrEqual(30);
expect(sent[0]! + ' ' + sent[1]!).toBe(
'aaaa bbbb cccc dddd eeee ffff gggg hhhh',
);
});
it('force-splits at maxChars with no break points', async () => {
const s = createStreamer({ minChars: 5, maxChars: 10 });
s.push('abcdefghijklmnop'); // 16 chars, no spaces
await s.flush();
expect(sent).toEqual(['abcdefghij', 'klmnop']);
});
it('prefers paragraph break over newline when force-splitting', async () => {
const s = createStreamer({ minChars: 5, maxChars: 30 });
s.push('line one\n\nline two\nline three xx');
await s.flush();
// Should split at \n\n (pos 10) since it's within maxChars
expect(sent[0]).toBe('line one\n');
expect(sent.length).toBe(2);
});
it('emits on idle timer when buffer >= minChars', async () => {
const s = createStreamer({ minChars: 5, idleMs: 500 });
s.push('Hello world'); // 11 chars, no boundary
expect(sent).toEqual([]);
vi.advanceTimersByTime(500);
// idle timer should have fired
await s.flush();
expect(sent).toEqual(['Hello world']);
});
it('does not emit on idle timer when buffer < minChars', async () => {
const s = createStreamer({ minChars: 100, idleMs: 500 });
s.push('tiny');
vi.advanceTimersByTime(500);
expect(sent).toEqual([]);
// flush still sends remaining
await s.flush();
expect(sent).toEqual(['tiny']);
});
it('resets idle timer on each push', async () => {
const s = createStreamer({ minChars: 20, idleMs: 500 });
s.push('Hello ');
vi.advanceTimersByTime(400);
s.push('world, how are you?'); // total 25 chars
vi.advanceTimersByTime(400);
// Only 400ms since last push, shouldn't fire yet
expect(sent).toEqual([]);
vi.advanceTimersByTime(100);
// Now 500ms since last push
await s.flush();
expect(sent).toEqual(['Hello world, how are you?']);
});
it('flush sends everything remaining', async () => {
const s = createStreamer({ minChars: 1000 }); // very high min
s.push('some text that will never hit minChars');
await s.flush();
expect(sent).toEqual(['some text that will never hit minChars']);
});
it('flush with empty buffer is a no-op', async () => {
const s = createStreamer();
await s.flush();
expect(sent).toEqual([]);
expect(s.blockCount).toBe(0);
});
it('stop clears the idle timer and drops buffered text', async () => {
const s = createStreamer({ minChars: 5, idleMs: 500 });
s.push('Hello world');
s.stop();
vi.advanceTimersByTime(500);
await s.flush();
expect(sent).toEqual([]);
expect(s.blockCount).toBe(0);
});
it('trims whitespace from emitted blocks', async () => {
const s = createStreamer({ minChars: 5 });
s.push(' \n Hello world \n\n Next ');
await s.flush();
// Leading whitespace and trailing spaces are trimmed; a block ending at
// a line break keeps exactly one restored newline.
expect(sent).toEqual(['Hello world\n', 'Next']);
});
it('does not emit empty blocks after trimming', async () => {
const s = createStreamer({ minChars: 1 });
s.push('\n\n\n\n');
await s.flush();
// All whitespace — nothing to emit after trim
expect(sent).toEqual([]);
expect(s.blockCount).toBe(0);
});
it('serializes sends', async () => {
vi.useRealTimers();
const order: string[] = [];
let callIndex = 0;
const slowSend = async (text: string) => {
const idx = callIndex++;
// Simulate async delay
await new Promise<void>((r) => setTimeout(r, 10));
order.push(`${idx}:${text}`);
};
const s = new BlockStreamer({
minChars: 5,
maxChars: 20,
idleMs: 0,
send: slowSend,
});
s.push('aaaa bbbb cccc dddd eeee ffff');
await s.flush();
// All sends completed in order
expect(order.length).toBeGreaterThanOrEqual(2);
// Verify sequential ordering
for (let i = 0; i < order.length; i++) {
expect(order[i]).toMatch(new RegExp(`^${i}:`));
}
});
it('handles multiple paragraph boundaries', async () => {
const s = createStreamer({ minChars: 5, maxChars: 200 });
s.push('Para one.\n\nPara two.\n\nPara three.');
await s.flush();
// Should emit paras 1+2 as one block (last \n\n boundary), then para 3
expect(sent).toEqual(['Para one.\n\nPara two.\n', 'Para three.']);
});
it('restores one trailing newline when a block ends at a line break', async () => {
const s = createStreamer({ minChars: 10 });
s.push('First paragraph of the answer.\n\nSecond part');
await s.flush();
expect(sent).toEqual(['First paragraph of the answer.\n', 'Second part']);
});
it('adds no newline when a block is force-split mid-line', async () => {
const s = createStreamer({ minChars: 5, maxChars: 12 });
s.push('word one word two word three');
await s.flush();
expect(sent[0]).not.toMatch(/\n$/);
});
it('drain awaits queued sends without emitting buffered text', async () => {
vi.useRealTimers();
const delivered: string[] = [];
const s = new BlockStreamer({
minChars: 5,
maxChars: 10,
idleMs: 0,
send: async (text) => {
await new Promise((r) => setTimeout(r, 5));
delivered.push(text);
},
});
s.push('aaaa bbbb cccc dddd eeee');
await s.drain();
expect(delivered).toEqual(['aaaa bbbb', 'cccc dddd']);
await s.flush();
expect(delivered).toEqual(['aaaa bbbb', 'cccc dddd', 'eeee']);
});
it('works with idleMs=0 (idle timer disabled)', async () => {
const s = createStreamer({ minChars: 10, idleMs: 0 });
s.push('Hello world, no timer');
vi.advanceTimersByTime(10000);
expect(sent).toEqual([]);
await s.flush();
expect(sent).toEqual(['Hello world, no timer']);
});
});
-152
View File
@@ -1,152 +0,0 @@
/**
* BlockStreamer — progressive multi-message delivery for channels.
*
* Accumulates text chunks from the agent's streaming response and emits
* completed "blocks" (paragraphs / sections) as separate channel messages
* while the agent is still working. This gives users a natural conversation
* flow instead of waiting 30–120 seconds for a single wall of text.
*
* Emission triggers:
* 1. Buffer ≥ maxChars → force-split at best break point
* 2. Buffer ≥ minChars AND a paragraph boundary (\n\n) exists → emit up to boundary
* 3. Idle timer fires (no chunk for idleMs) AND buffer ≥ minChars → emit buffer
* 4. flush() called (response complete) → emit everything remaining
*
* All sends are serialized — the next block waits for the previous send to complete.
*/
export interface BlockStreamerOptions {
/** Minimum characters before emitting a block. Default: 400. */
minChars: number;
/** Force-emit when buffer exceeds this size. Default: 1000. */
maxChars: number;
/** Emit buffered text after this many ms of inactivity. Default: 1500. */
idleMs: number;
/**
* Callback to deliver a completed block. Called with trimmed text, plus
* one restored trailing newline when the block ended at a line break.
*/
send: (text: string) => Promise<void>;
}
export class BlockStreamer {
private buffer = '';
private idleTimer: ReturnType<typeof setTimeout> | null = null;
private sending: Promise<void> = Promise.resolve();
private opts: BlockStreamerOptions;
/** Number of blocks emitted so far. */
blockCount = 0;
constructor(opts: BlockStreamerOptions) {
this.opts = opts;
}
/** Feed a new text chunk from the agent stream. */
push(chunk: string): void {
this.buffer += chunk;
this.clearIdleTimer();
this.checkEmit();
if (this.buffer.length > 0 && this.opts.idleMs > 0) {
this.idleTimer = setTimeout(() => this.onIdle(), this.opts.idleMs);
}
}
/** Flush all remaining buffered text. Awaits all pending sends. */
async flush(): Promise<void> {
this.clearIdleTimer();
if (this.buffer.length > 0) {
this.emitBlock(this.buffer);
this.buffer = '';
}
await this.sending;
}
/** Drop buffered text and cancel future idle emission. */
stop(): void {
this.clearIdleTimer();
this.buffer = '';
}
/** Await already-queued sends without emitting the buffered text. */
async drain(): Promise<void> {
await this.sending;
}
// ---------------------------------------------------------------------------
// Internal
// ---------------------------------------------------------------------------
private checkEmit(): void {
// 1. Force-split if buffer exceeds maxChars
while (this.buffer.length >= this.opts.maxChars) {
const bp = this.findBreakPoint(this.buffer, this.opts.maxChars);
this.emitBlock(this.buffer.slice(0, bp));
this.buffer = this.buffer.slice(bp);
}
// 2. Emit at paragraph boundary if we have enough text
if (this.buffer.length >= this.opts.minChars) {
const bp = this.findBlockBoundary(this.buffer);
if (bp > 0) {
this.emitBlock(this.buffer.slice(0, bp));
this.buffer = this.buffer.slice(bp);
}
}
}
private onIdle(): void {
this.idleTimer = null;
if (this.buffer.length >= this.opts.minChars) {
this.emitBlock(this.buffer);
this.buffer = '';
}
}
private emitBlock(text: string): void {
const trimmed = text.trim();
if (!trimmed) return;
// Restore the line terminator trim removed when the block ended at one:
// consumers that track line boundaries (redaction projectors) must be
// able to close the line the real newline ended.
const block = /\n[^\S\n]*$/.test(text) ? `${trimmed}\n` : trimmed;
this.blockCount++;
this.sending = this.sending
.then(() => this.opts.send(block))
.catch(() => {});
}
/**
* Find the last paragraph boundary (\n\n) in the buffer.
* Returns the position after the boundary, or -1 if no suitable boundary
* exists at or after minChars.
*/
private findBlockBoundary(text: string): number {
const last = text.lastIndexOf('\n\n');
if (last < 0 || last < this.opts.minChars) return -1;
return last + 2;
}
/**
* Find the best break point at or before maxPos.
* Prefers paragraph break > newline > space > maxPos.
*/
private findBreakPoint(text: string, maxPos: number): number {
const sub = text.slice(0, maxPos);
const para = sub.lastIndexOf('\n\n');
if (para > 0) return para + 2;
const nl = sub.lastIndexOf('\n');
if (nl > 0) return nl + 1;
const sp = sub.lastIndexOf(' ');
if (sp > 0) return sp + 1;
return maxPos;
}
private clearIdleTimer(): void {
if (this.idleTimer !== null) {
clearTimeout(this.idleTimer);
this.idleTimer = null;
}
}
}
File diff suppressed because it is too large. Load diff
+275 -84
View File
@@ -8,6 +8,8 @@ import type {
ChannelMemoryTarget,
ChannelOutputSegmentContext,
ChannelOutputSegmentEndReason,
ChannelPermissionDecision,
ChannelPermissionRequestContext,
ChannelPromptOwner,
ChannelProactiveTarget,
ChannelRuntimeIdentity,
@@ -27,7 +29,6 @@ import type {
UserInputPresentationResult,
UserInputSettlementReason,
} from './types.js';
import { BlockStreamer } from './BlockStreamer.js';
import {
ChannelProactiveDeliveryError,
isChannelProactiveDeliveryError,
@@ -229,6 +230,7 @@ interface ChannelMemoryRecallSelection {
}
export interface ChannelBaseOptions {
locale?: 'en' | 'zh';
router?: SessionRouter;
proxy?: string;
/** Qwen UI language used by adapter-owned presentation. */
@@ -259,6 +261,54 @@ export interface ChannelBaseOptions {
};
}
const PERMISSION_COPY = {
en: {
toolUse: 'Tool use',
allowOnce: 'Allow once',
allowAlwaysProject: 'always allow for this project',
allowAlwaysUser: 'always allow for this user',
allowAlways: 'always allow',
deny: 'Deny',
required: 'Permission required to run a tool',
request: 'Request:',
tool: 'Tool:',
action: 'Action:',
parameters: 'Parameters:',
replyWith: 'Reply with:',
},
zh: {
toolUse: '工具调用',
allowOnce: '仅允许本次',
allowAlwaysProject: '始终允许此项目',
allowAlwaysUser: '始终允许此用户',
allowAlways: '始终允许',
deny: '拒绝',
required: '运行工具需要授权',
request: '请求:',
tool: '工具:',
action: '操作:',
parameters: '参数:',
replyWith: '回复以下命令:',
},
} as const;
/**
* Translate the stock prefix of a scoped always-allow label, keeping the
* command/tool scope that exec and mcp confirmations append to it.
*/
function localizedScopedAlwaysLabel(
label: string,
stockLabel: string,
localizedLabel: string,
): string | undefined {
if (label === stockLabel) return localizedLabel;
const scopeSeparator = ': ';
if (!label.startsWith(stockLabel + scopeSeparator)) return undefined;
return `${localizedLabel}:${label.slice(
stockLabel.length + scopeSeparator.length,
)}`;
}
export interface ChannelLoopController {
create(input: ChannelLoopInput): Promise<ChannelLoop>;
createForTarget?(
@@ -289,6 +339,7 @@ type PendingPermission = {
sourceLabel?: string;
taskName?: string;
userInputPresented?: boolean;
permissionPresented?: boolean;
settlementListeners: Set<(reason: UserInputSettlementReason) => void>;
settled?: UserInputSettlementReason;
responsePromise?: Promise<boolean>;
@@ -324,7 +375,6 @@ type ActivePrompt = {
loopPrompt?: boolean;
done: Promise<void>;
resolve: () => void;
stopStreaming?: () => void;
/** The originating turn's chat/message, so a clear-time eviction can run this
* turn's own onPromptEnd (its finally may settle long after — or never). */
chatId: string;
@@ -422,6 +472,7 @@ export abstract class ChannelBase {
protected proxy?: string;
/** Adapter-owned persistent state directory, when supplied by the runtime. */
protected readonly stateDir?: string;
protected readonly locale: 'en' | 'zh';
private readonly channelMemory?: ChannelMemoryCallbacks;
private readonly memoryIntentClassifier?: ChannelMemoryIntentClassifier;
private readonly channelMemoryRecallObserver?: (
@@ -904,6 +955,10 @@ export abstract class ChannelBase {
if (presentation && (await presentation)) {
return;
}
const permissionPresentation = this.tryPresentPermission(pending);
if (permissionPresentation && (await permissionPresentation)) {
return;
}
const text = this.formatPermissionRequest(pending);
if (
target.threadId !== undefined &&
@@ -1019,6 +1074,150 @@ export abstract class ChannelBase {
})();
}
private tryPresentPermission(
pending: PendingPermission,
): Promise<boolean> | undefined {
const active = this.activePrompts.get(pending.sessionId);
const toolCall = pending.request.toolCall as unknown as Record<
string,
unknown
>;
const meta = isRecord(toolCall['_meta']) ? toolCall['_meta'] : undefined;
const isUserQuestion =
meta?.['qwenInteractionKind'] === 'user_question' ||
meta?.['toolName'] === 'ask_user_question' ||
toolCall['kind'] === 'ask_user_question';
const decisions = this.permissionPresentationDecisions(pending);
if (
!active ||
active.loopPrompt ||
!active.owner ||
isUserQuestion ||
!decisions
) {
return undefined;
}
const precedingSegment = this.closeOutputSegment(
pending.sessionId,
active,
pending.target,
);
let respondInvoked = false;
const context: ChannelPermissionRequestContext = {
requestId: pending.requestId,
sessionId: pending.sessionId,
runId: active.runId,
owner: active.owner,
target: pending.target,
...(precedingSegment
? { precedingSegmentId: precedingSegment.segmentId }
: {}),
title: this.permissionTitle(pending.request.toolCall),
decisions,
onSettled: (listener) => {
if (pending.settled) {
listener(pending.settled);
return () => {};
}
pending.settlementListeners.add(listener);
return () => {
pending.settlementListeners.delete(listener);
};
},
respond: (decision) => {
const response = this.permissionPresentationResponse(pending, decision);
if (!response) return Promise.resolve(false);
respondInvoked = true;
return this.respondToUserInput(pending, response);
},
};
pending.permissionPresented = true;
return (async () => {
try {
if (precedingSegment) {
await this.notifyOutputSegmentEnd(
pending.target.chatId,
pending.sessionId,
precedingSegment,
'input_requested',
);
}
if (this.pendingPermissions.get(pending.requestId) !== pending) {
return true;
}
const result = await this.presentPermissionRequest(context);
if (this.pendingPermissions.get(pending.requestId) !== pending) {
return true;
}
if (
result.kind === 'presented' ||
(result.kind === 'handled' && respondInvoked)
) {
return true;
}
pending.permissionPresented = false;
return false;
} catch (err) {
process.stderr.write(
`[${this.name}] permission presentation failed for request ${sanitizeLogText(pending.requestId, 128)}: ${this.lifecycleError(err)}\n`,
);
if (this.pendingPermissions.get(pending.requestId) !== pending) {
return true;
}
pending.permissionPresented = false;
return false;
}
})();
}
private permissionPresentationDecisions(
pending: PendingPermission,
): ChannelPermissionRequestContext['decisions'] | undefined {
const allowOnce = this.approvalOption(pending);
if (!allowOnce) return undefined;
const copy = PERMISSION_COPY[this.locale];
const allowAlways = this.approvalAlwaysOption(pending);
return [
{
kind: 'allow_once',
label: sanitizeQuotedText(
this.permissionOptionLabel(allowOnce, copy.allowOnce),
80,
),
},
...(allowAlways
? [
{
kind: 'allow_always' as const,
label: sanitizeQuotedText(allowAlways.label, 80),
},
]
: []),
{
kind: 'deny',
label: sanitizeQuotedText(
this.permissionOptionLabel(this.denialOption(pending), copy.deny),
80,
),
},
];
}
private permissionPresentationResponse(
pending: PendingPermission,
decision: ChannelPermissionDecision,
): ChannelUserInputResponse | undefined {
if (decision === 'deny') return this.denialResponse(pending);
const optionId =
decision === 'allow_once'
? this.approvalOptionId(pending)
: this.approvalAlwaysOption(pending)?.optionId;
return optionId
? { outcome: { outcome: 'selected', optionId } }
: undefined;
}
private normalizeUserQuestions(
pending: PendingPermission,
): ChannelUserQuestion[] | undefined {
@@ -1099,7 +1298,8 @@ export abstract class ChannelBase {
response: ChannelUserInputResponse,
): Promise<boolean> {
if (pending.responsePromise) {
return pending.responsePromise;
await pending.responsePromise;
return false;
}
if (
this.pendingPermissions.get(pending.requestId) !== pending ||
@@ -1183,6 +1383,7 @@ export abstract class ChannelBase {
this.config = config;
this.messagePrefix = config.messagePrefix?.trim() || undefined;
this.bridge = bridge;
this.locale = options?.locale ?? 'en';
this.proxy = options?.proxy;
this.stateDir = options?.stateDir;
this.identity = Object.freeze(this.resolveIdentity(name, config));
@@ -1297,6 +1498,12 @@ export abstract class ChannelBase {
return { kind: 'unsupported' };
}
protected async presentPermissionRequest(
_context: ChannelPermissionRequestContext,
): Promise<UserInputPresentationResult> {
return { kind: 'unsupported' };
}
private emitTaskLifecycle(event: ChannelTaskLifecycleEvent): void {
try {
const result = this.onTaskLifecycle(event);
@@ -2608,7 +2815,6 @@ export abstract class ChannelBase {
return false;
}
active.cancelled = true;
this.stopActiveStreaming(active, sessionId, reason);
this.dropCollectBuffer(sessionId);
this.removePendingPermissionsForSession(sessionId, 'run_cancelled');
this.emitTaskCancellation(active, sessionId, reason);
@@ -3087,7 +3293,7 @@ export abstract class ChannelBase {
return (
pending.target.chatId === envelope.chatId &&
pending.target.threadId === envelope.threadId &&
(!pending.userInputPresented ||
((!pending.userInputPresented && !pending.permissionPresented) ||
pending.target.senderId === envelope.senderId) &&
(this.isSharedSessionTarget(pending.target) ||
pending.target.senderId === envelope.senderId)
@@ -3096,15 +3302,16 @@ export abstract class ChannelBase {
private formatPermissionRequest(pending: PendingPermission): string {
const { toolCall } = pending.request;
const copy = PERMISSION_COPY[this.locale];
const parameters = this.permissionParameterSummary(toolCall);
const approveLabel = this.permissionOptionLabel(
this.approvalOption(pending),
'allow once',
copy.allowOnce.toLocaleLowerCase(this.locale),
);
const alwaysOption = this.approvalAlwaysOption(pending);
const denyLabel = this.permissionOptionLabel(
this.denialOption(pending),
'deny',
copy.deny.toLocaleLowerCase(this.locale),
);
const requestSuffix = pending.taskName ? ` ${pending.requestId}` : '';
const replyPadding = pending.taskName
@@ -3120,14 +3327,14 @@ export abstract class ChannelBase {
`${this.prefixedCommand(`/deny${requestSuffix}`)}${replyPadding.deny}${denyLabel}`,
];
return [
'Permission required to run a tool',
...(pending.taskName ? [`Request: ${pending.requestId}`] : []),
copy.required,
...(pending.taskName ? [`${copy.request} ${pending.requestId}`] : []),
'',
`Tool: ${this.permissionToolName(toolCall)}`,
`Action: ${this.permissionTitle(toolCall)}`,
...(parameters ? [`Parameters: ${parameters}`] : []),
`${copy.tool} ${this.permissionToolName(toolCall)}`,
`${copy.action} ${this.permissionTitle(toolCall)}`,
...(parameters ? [`${copy.parameters} ${parameters}`] : []),
'',
'Reply with:',
copy.replyWith,
...replies,
].join('\n');
}
@@ -3145,7 +3352,10 @@ export abstract class ChannelBase {
): string {
const rawTitle =
typeof toolCall.title === 'string' ? toolCall.title : undefined;
return sanitizeQuotedText(rawTitle || '', 160).trim() || 'Tool use';
return (
sanitizeQuotedText(rawTitle || '', 160).trim() ||
PERMISSION_COPY[this.locale].toolUse
);
}
private permissionToolName(
@@ -3196,7 +3406,39 @@ export abstract class ChannelBase {
): string {
const rawLabel = typeof option?.name === 'string' ? option.name : '';
const label = sanitizeQuotedText(rawLabel, 160).trim();
return label || fallback;
if (!label) return fallback;
return this.localizedPermissionOptionLabel(option, label) ?? label;
}
private localizedPermissionOptionLabel(
option: PermissionOption | undefined,
label: string,
): string | undefined {
if (this.locale !== 'zh') return undefined;
const copy = PERMISSION_COPY[this.locale];
if (option?.kind === 'allow_always') {
if (option.optionId === 'proceed_always_project') {
return localizedScopedAlwaysLabel(
label,
'Always Allow in project',
copy.allowAlwaysProject,
);
}
if (option.optionId === 'proceed_always_user') {
return localizedScopedAlwaysLabel(
label,
'Always Allow for user',
copy.allowAlwaysUser,
);
}
if (option.optionId === 'proceed_always' && label === 'Allow All Edits') {
return copy.allowAlways;
}
return undefined;
}
if (label === 'Allow' || label === 'Allow once') return copy.allowOnce;
if (label === 'Deny' || label === 'Reject') return copy.deny;
return undefined;
}
private approvalOption(
@@ -3249,13 +3491,14 @@ export abstract class ChannelBase {
}
private approvalAlwaysLabel(option: PermissionOption): string {
const copy = PERMISSION_COPY[this.locale];
if (option.optionId === 'proceed_always_project') {
return 'always allow for this project';
return copy.allowAlwaysProject;
}
if (option.optionId === 'proceed_always_user') {
return 'always allow for this user';
return copy.allowAlwaysUser;
}
return 'always allow';
return copy.allowAlways;
}
private denialResponse(pending: PendingPermission): {
@@ -3395,9 +3638,10 @@ export abstract class ChannelBase {
let accepted: boolean;
try {
accepted = pending.userInputPresented
? await this.respondToUserInput(pending, response)
: await this.bridge.respondToPermission(pending.requestId, response);
accepted =
pending.userInputPresented || pending.permissionPresented
? await this.respondToUserInput(pending, response)
: await this.bridge.respondToPermission(pending.requestId, response);
} catch (err) {
this.removePendingPermission(pending.requestId);
process.stderr.write(
@@ -5622,23 +5866,8 @@ export abstract class ChannelBase {
return active?.senderName || active?.senderId || target.senderId || 'agent';
}
private stopActiveStreaming(
active: ActivePrompt,
sessionId: string,
reason: string,
): void {
try {
active.stopStreaming?.();
} catch (err) {
process.stderr.write(
`[${this.name}] stopStreaming threw during ${reason} for session ${sessionId}: ${err instanceof Error ? err.message : err}\n`,
);
}
}
/**
* Cancel the active turn and wait (bounded) for it to wind down. Stops the
* BlockStreamer so buffered text can't leak via the idle timer, then fires a
* Cancel the active turn and wait (bounded) for it to wind down. Fires a
* best-effort cancelSession (NOT awaited — a wedged child/daemon can leave the
* request pending forever). Returns true if active.done settled first, false
* if the CLEAR_CANCEL_TIMEOUT_MS bound won (the turn never wound down). Used by
@@ -5652,7 +5881,6 @@ export abstract class ChannelBase {
sessionId: string,
): Promise<boolean> {
active.cancelled = true;
this.stopActiveStreaming(active, sessionId, 'cancel');
// Fire-and-forget, but LOG the IPC failure: a swallowed reason leaves a
// wedged turn undiagnosable (operator sees only the wind-down timeout below
// with no cause).
@@ -6806,7 +7034,6 @@ export abstract class ChannelBase {
process.stderr.write(
`[${this.name}] steer: cancelled active turn for ${envelope.senderId} in session ${sessionId}\n`,
);
this.stopActiveStreaming(active, sessionId, 'steer');
// Fire-and-forget, but LOG the IPC failure rather than swallow it, so a
// best-effort cancel that fails isn't silently invisible to operators.
void this.bridge.cancelSession(sessionId).catch((err) => {
@@ -6866,7 +7093,6 @@ export abstract class ChannelBase {
// resurrect it while preprocessing runs before this queue.
const generation =
namedTurn?.generation ?? this.sessionGenerations.get(sessionId) ?? 0;
const useBlockStreaming = this.config.blockStreaming === 'on';
if (namedTurn) {
namedTurn.claimed = true;
} else {
@@ -7015,30 +7241,12 @@ export abstract class ChannelBase {
);
}
const streamer = useBlockStreaming
? new BlockStreamer({
minChars: this.config.blockStreamingChunk?.minChars ?? 400,
maxChars: this.config.blockStreamingChunk?.maxChars ?? 1000,
idleMs: this.config.blockStreamingCoalesce?.idleMs ?? 1500,
send: (text) =>
this.sendResponseMessage(
envelope.chatId,
text,
sessionId,
sourceLabel,
),
})
: null;
promptState.stopStreaming = () => streamer?.stop();
// Chunks arriving while a cancel is PENDING are held here: pushing them
// to any visible sink could send output the cancel can't recall. On a
// failed cancel they're replayed; on success, discarded.
const heldChunks: string[] = [];
let hasStreamedText = false;
const releaseHeldChunks = () => {
for (const held of heldChunks.splice(0)) {
hasStreamedText = true;
const segment = this.ensureOutputSegment(sessionId, promptState);
this.emitTaskLifecycle({
...this.lifecycleBase(
@@ -7050,7 +7258,6 @@ export abstract class ChannelBase {
chunk: held,
});
this.onResponseChunk(envelope.chatId, held, sessionId, segment);
streamer?.push(held);
}
};
const onChunk = (sid: string, chunk: string) => {
@@ -7071,7 +7278,6 @@ export abstract class ChannelBase {
return;
}
heldChunks.length = 0;
hasStreamedText = false;
const segment = this.closeOutputSegment(sessionId, promptState);
void this.notifyOutputSegmentEnd(
envelope.chatId,
@@ -7079,7 +7285,6 @@ export abstract class ChannelBase {
segment,
'response_boundary',
);
streamer?.stop();
};
// Queue wait and memory recall can outlive a bridge crash. Capture the
// bridge only after the latest recovery has restored session routing.
@@ -7104,22 +7309,15 @@ export abstract class ChannelBase {
// If cancelled, skip sending the response
if (!promptState.cancelled && response) {
promptState.deliveryStarted = true;
if (streamer) {
if (!hasStreamedText) {
streamer.push(response);
}
await streamer.flush();
} else {
const segment = this.ensureOutputSegment(sessionId, promptState);
await this.onResponseComplete(
envelope.chatId,
response,
sessionId,
segment,
);
if (segment && promptState.activeSegmentId === segment.segmentId) {
promptState.activeSegmentId = undefined;
}
const segment = this.ensureOutputSegment(sessionId, promptState);
await this.onResponseComplete(
envelope.chatId,
response,
sessionId,
segment,
);
if (segment && promptState.activeSegmentId === segment.segmentId) {
promptState.activeSegmentId = undefined;
}
}
// Once delivery started the turn's outcome is fixed — don't let a
@@ -7188,13 +7386,6 @@ export abstract class ChannelBase {
} finally {
promptBridge.off('textChunk', onChunk);
promptBridge.off('responseBoundary', onResponseBoundary);
if (streamer) {
streamer.stop();
// Queued block sends belong to this turn: let them land before
// onPromptEnd settles turn-scoped adapter state, or a send racing
// the settle can recreate discarded state and leak unredacted text.
await streamer.drain();
}
// Identity guard: a turn that wedged past /clear's bounded wait gets
// EVICTED — /clear gives up on active.done, deletes activePrompts, and a
// turn the user starts AFTER the clear can re-seed activePrompts (and own
+2 -4
View File
@@ -39,8 +39,6 @@ export type {
DaemonPermissionRequestEvent,
DaemonPermissionResolvedEvent,
} from './DaemonChannelBridge.js';
export { BlockStreamer } from './BlockStreamer.js';
export type { BlockStreamerOptions } from './BlockStreamer.js';
export { ChannelBase, CLEAR_CANCEL_TIMEOUT_MS } from './ChannelBase.js';
export {
startsWithMessagePrefix,
@@ -108,8 +106,6 @@ export {
export { isTerminalTaskLifecycleType } from './types.js';
export type {
Attachment,
BlockStreamingChunkConfig,
BlockStreamingCoalesceConfig,
ChannelConfig,
ChannelConfigEnumFieldDescriptor,
ChannelConfigFieldDescriptor,
@@ -127,6 +123,8 @@ export type {
ChannelMemoryScopeMode,
ChannelOutputSegmentContext,
ChannelOutputSegmentEndReason,
ChannelPermissionDecision,
ChannelPermissionRequestContext,
ChannelPlugin,
ChannelPromptOwner,
ChannelProactiveTarget,
+18 -19
View File
@@ -40,18 +40,6 @@ export interface GroupConfig {
groupHistoryLimit?: number;
}
export interface BlockStreamingChunkConfig {
/** Minimum characters before emitting a block. Default: 400. */
minChars?: number;
/** Force-emit when buffer exceeds this size. Default: 1000. */
maxChars?: number;
}
export interface BlockStreamingCoalesceConfig {
/** Emit buffered text after this many ms of inactivity. Default: 1500. */
idleMs?: number;
}
export interface ChannelConfig {
type: ChannelType;
token: string;
@@ -82,13 +70,6 @@ export interface ChannelConfig {
/** Poll interval in ms for polling adapters. Default: 60000. */
pollInterval?: number;
/** Enable block streaming — emit completed blocks as separate messages. */
blockStreaming?: 'on' | 'off';
/** Chunk size bounds for block streaming. */
blockStreamingChunk?: BlockStreamingChunkConfig;
/** Idle coalescing for block streaming. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
}
export interface Attachment {
@@ -274,6 +255,24 @@ export interface ChannelUserInputRequestContext {
respond(response: ChannelUserInputResponse): Promise<boolean>;
}
export type ChannelPermissionDecision = 'allow_once' | 'allow_always' | 'deny';
export interface ChannelPermissionRequestContext {
requestId: string;
sessionId: string;
runId: string;
owner: ChannelPromptOwner;
target: SessionTarget;
precedingSegmentId?: string;
title: string;
decisions: Array<{
kind: ChannelPermissionDecision;
label: string;
}>;
onSettled(listener: (reason: UserInputSettlementReason) => void): () => void;
respond(decision: ChannelPermissionDecision): Promise<boolean>;
}
export interface ChannelOutputSegmentContext {
channelName: string;
sessionId: string;
@@ -1,245 +0,0 @@
import { EventEmitter } from 'node:events';
import type { DWClientDownStream } from 'dingtalk-stream-sdk-nodejs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
CLEAR_CANCEL_TIMEOUT_MS,
type ChannelAgentBridge,
type Envelope,
} from '@qwen-code/channel-base';
import { DingtalkChannel } from './DingtalkAdapter.js';
// The adapter's main test file replaces ChannelBase with a stub, so the
// /clear eviction and session-died races can only be witnessed here, against
// the REAL ChannelBase machinery.
const dingtalkSdkMock = vi.hoisted(() => ({
instances: [] as unknown[],
}));
vi.mock('dingtalk-stream-sdk-nodejs', () => ({
DWClient: class {
debug = true;
connected = true;
registered = true;
config = { autoReconnect: true };
socket = new (class {
readyState = 1;
ping = vi.fn();
private listeners = new Map<string, Set<(...args: unknown[]) => void>>();
on(event: string, listener: (...args: unknown[]) => void): void {
const listeners = this.listeners.get(event) ?? new Set();
listeners.add(listener);
this.listeners.set(event, listeners);
}
off(event: string, listener: (...args: unknown[]) => void): void {
this.listeners.get(event)?.delete(listener);
}
emit(event: string, ...args: unknown[]): void {
for (const listener of this.listeners.get(event) ?? []) {
listener(...args);
}
}
})();
callback?: (msg: DWClientDownStream) => void;
callbacks = new Map<string, (msg: DWClientDownStream) => void>();
disconnect = vi.fn();
getConfig = vi.fn(() => ({ access_token: 'token' }));
registerCallbackListener = vi.fn(
(topic: string, callback: (msg: DWClientDownStream) => void) => {
this.callbacks.set(topic, callback);
if (topic === 'robot') this.callback = callback;
},
);
send = vi.fn();
connect = vi.fn(() => Promise.resolve());
onSystem = vi.fn();
onEvent = vi.fn();
onCallback = vi.fn();
onDownStream = vi.fn();
constructor(readonly options: Record<string, unknown>) {
dingtalkSdkMock.instances.push(this);
}
},
TOPIC_ROBOT: 'robot',
TOPIC_CARD: 'card',
EventAck: { SUCCESS: 'success' },
}));
function createBridge(promptImpl: (sessionId: string) => Promise<string>) {
const emitter = new EventEmitter();
const bridge = Object.assign(emitter, {
newSession: vi.fn().mockReturnValue('session-1'),
loadSession: vi.fn(),
prompt: vi.fn().mockImplementation(promptImpl),
cancelSession: vi.fn().mockResolvedValue(undefined),
discardSession: vi.fn().mockResolvedValue(undefined),
stop: vi.fn(),
start: vi.fn(),
isConnected: true,
availableCommands: [],
setBridge: vi.fn(),
respondToPermission: vi.fn().mockResolvedValue(true),
registerChannelLoopToolHandler: vi.fn(),
});
return bridge as unknown as ChannelAgentBridge & {
prompt: ReturnType<typeof vi.fn>;
};
}
function envelope(overrides: Partial<Envelope> = {}): Envelope {
return {
channelName: 'test-dingtalk',
senderId: 'user1',
senderName: 'User 1',
chatId: 'cid123',
text: 'hello',
isGroup: false,
isMentioned: false,
isReplyToBot: false,
...overrides,
};
}
function createChannel(bridge: ChannelAgentBridge): DingtalkChannel {
return new DingtalkChannel(
'test-dingtalk',
{
type: 'dingtalk',
token: 'tok',
clientId: 'client-id',
clientSecret: 'client-secret',
senderPolicy: 'open',
allowedUsers: [],
sessionScope: 'user',
cwd: '/tmp',
groupPolicy: 'disabled',
dmPolicy: 'open',
groups: {},
blockStreaming: 'on',
blockStreamingChunk: { minChars: 5, maxChars: 10 },
blockStreamingCoalesce: { idleMs: 60_000 },
} as never,
bridge,
);
}
function seedWebhook(channel: DingtalkChannel, chatId: string): void {
(channel as unknown as { webhooks: Map<string, string> }).webhooks.set(
chatId,
'https://oapi.dingtalk.com/robot/send?access_token=token',
);
}
// maxChars=10 splits this into 'aaaa\n', '[FILE:', '/workspace',
// '/secret.tx', 't] more'. Hanging the SECOND send leaves the opener
// projected into the turn's projector (reserved line held) with the path
// bytes still queued — exactly the blocks that race the settle.
const MARKER_TEXT = 'aaaa\n\n[FILE: /workspace/secret.txt] more more';
describe('DingtalkChannel block projection under eviction', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it('/clear on a wedged turn drops the late blocks instead of leaking the marker tail', async () => {
const bridge = createBridge((sessionId) => {
(bridge as unknown as EventEmitter).emit(
'textChunk',
sessionId,
MARKER_TEXT,
);
return new Promise<string>(() => {});
});
const channel = createChannel(bridge);
seedWebhook(channel, 'cid123');
const bodies: string[] = [];
let fetchCalls = 0;
const pendingFetch: Array<(response: Response) => void> = [];
vi.spyOn(globalThis, 'fetch').mockImplementation((_url, init) => {
bodies.push(String((init as RequestInit | undefined)?.body ?? ''));
fetchCalls += 1;
// Hang exactly the opener block's send: it is projected (reserved line
// held) before settle, while the path bytes wait behind it in the
// serialized send chain.
if (fetchCalls === 2) {
return new Promise<Response>((resolve) => pendingFetch.push(resolve));
}
return Promise.resolve(new Response('{}'));
});
void channel.handleInbound(envelope({ messageId: 'm1' }));
// Block 1 landed; the opener block's send hangs in fetch with the path
// blocks queued behind it.
await vi.waitFor(() => expect(bodies).toHaveLength(2));
vi.useFakeTimers();
const clearPromise = channel.handleInbound(
envelope({ messageId: 'm2', text: '/clear' }),
);
// Drive the bounded wind-down wait to eviction: settle runs while the
// path blocks are still queued in the turn's send chain.
await vi.advanceTimersByTimeAsync(CLEAR_CANCEL_TIMEOUT_MS);
for (const resolve of pendingFetch.splice(0)) {
resolve(new Response('{}'));
}
vi.useRealTimers();
await clearPromise;
// Let the detached send chain drain its now-unblocked sends.
await new Promise((resolve) => setTimeout(resolve, 20));
const all = bodies.join('\n');
expect(all).toContain('File delivery unavailable');
expect(all).not.toContain('secret');
expect(all).not.toContain('[FILE:');
expect(
(channel as unknown as { blockFileProjectors: Map<string, unknown> })
.blockFileProjectors.size,
).toBe(0);
});
it('drops the late blocks when the session dies mid-send', async () => {
const bridge = createBridge((sessionId) => {
(bridge as unknown as EventEmitter).emit(
'textChunk',
sessionId,
MARKER_TEXT,
);
return new Promise<string>(() => {});
});
const channel = createChannel(bridge);
seedWebhook(channel, 'cid123');
const bodies: string[] = [];
let fetchCalls = 0;
const pendingFetch: Array<(response: Response) => void> = [];
vi.spyOn(globalThis, 'fetch').mockImplementation((_url, init) => {
bodies.push(String((init as RequestInit | undefined)?.body ?? ''));
fetchCalls += 1;
if (fetchCalls === 2) {
return new Promise<Response>((resolve) => pendingFetch.push(resolve));
}
return Promise.resolve(new Response('{}'));
});
void channel.handleInbound(envelope({ messageId: 'm1' }));
await vi.waitFor(() => expect(bodies).toHaveLength(2));
channel.onSessionDied('session-1');
for (const resolve of pendingFetch.splice(0)) {
resolve(new Response('{}'));
}
await new Promise((resolve) => setTimeout(resolve, 20));
const all = bodies.join('\n');
expect(all).toContain('File delivery unavailable');
expect(all).not.toContain('secret');
expect(all).not.toContain('[FILE:');
});
});
File diff suppressed because it is too large. Load diff
+76 -140
View File
@@ -39,7 +39,6 @@ import {
readValidatedFile,
safeFileName,
uploadDingTalkFile,
withFileUnavailableNotice,
type ValidatedFile,
} from './outbound-file.js';
import {
@@ -66,6 +65,7 @@ import {
type DingtalkPresentationPhase,
} from './presentation-phase.js';
import { QuestionCardController } from './question-card-controller.js';
import { PermissionCardController } from './permission-card-controller.js';
import { DingtalkInteractionPresenter } from './interaction-presenter.js';
import type {
BackgroundResponseContext,
@@ -75,6 +75,7 @@ import type {
ChannelAgentBridge,
ChannelOutputSegmentContext,
ChannelOutputSegmentEndReason,
ChannelPermissionRequestContext,
ChannelTaskLifecycleEvent,
ChannelUserInputRequestContext,
SessionTarget,
@@ -1050,6 +1051,7 @@ export class DingtalkChannel extends ChannelBase {
protected readonly interactiveCardClient?: DingtalkInteractiveCardClient;
private statusCardController?: StatusCardController;
private questionCardController?: QuestionCardController;
private permissionCardController?: PermissionCardController;
private interactionPresenter?: DingtalkInteractionPresenter;
private readonly inboundCardOwners = new Map<string, CardRunCorrelation>();
private readonly cardRunBySession = new Map<string, string>();
@@ -1061,17 +1063,6 @@ export class DingtalkChannel extends ChannelBase {
string,
{ sessionId: string; projector: OutboundFileProjector }
>();
private readonly blockFileProjectors = new Map<
string,
{ projector: OutboundFileProjector; reportedMarkers: number }
>();
// Sessions armed for block projection by onPromptStart and disarmed when
// the turn settles (or the session dies). A block send that finds NO
// projector state is only legitimate as a turn's FIRST block, which always
// lands while armed: late sends from an evicted (/clear) or dead session
// must be dropped, because recreating state would post the tail of a
// force-split [FILE: ...] marker verbatim.
private readonly blockProjectionArmed = new Set<string>();
private readonly backgroundResponseAggregations = new Map<
string,
BackgroundResponseAggregation
@@ -1106,10 +1097,7 @@ export class DingtalkChannel extends ChannelBase {
} else if (!this.config.instructions.includes('[IMAGE:')) {
this.config.instructions += IMAGE_INSTRUCTIONS;
}
if (
config.blockStreaming !== 'on' &&
!this.config.instructions.includes('[FILE:')
) {
if (!this.config.instructions.includes('[FILE:')) {
this.config.instructions += FILE_INSTRUCTIONS;
}
this.interactiveCardConfig = parseDingtalkInteractiveCardConfig(
@@ -1158,10 +1146,7 @@ export class DingtalkChannel extends ChannelBase {
}
},
});
if (
this.interactiveCardConfig.statusCard.enabled &&
config.blockStreaming !== 'on'
) {
if (this.interactiveCardConfig.statusCard.enabled) {
this.statusCardController = new StatusCardController({
client: this.interactiveCardClient,
cancelRun: (sessionId, runId) =>
@@ -1192,24 +1177,34 @@ export class DingtalkChannel extends ChannelBase {
},
});
}
if (this.statusCardController || this.questionCardController) {
if (this.interactiveCardConfig.permissionCard.enabled) {
this.permissionCardController = new PermissionCardController({
client: this.interactiveCardClient,
timeoutMs: this.interactiveCardConfig.permissionCard.timeoutMs,
locale: this.locale,
reserveRunProjection: (runId) =>
this.interactionPresenter?.reserveProjection(runId),
onError: (operation, error) => {
process.stderr.write(
`[DingTalk:${this.name}] ${operation} failed: ${sanitizeLogText(String(error), 300)}\n`,
);
},
});
}
if (
this.statusCardController ||
this.questionCardController ||
this.permissionCardController
) {
this.interactionPresenter = new DingtalkInteractionPresenter({
statusCards: this.statusCardController,
questionCards: this.questionCardController,
permissionCards: this.permissionCardController,
...(options?.displayLanguage
? { language: options.displayLanguage }
: {}),
...(config.blockStreaming !== 'on'
? {
sendFallback: (
chatId: string,
text: string,
sessionId: string,
sourceLabel?: string,
) =>
this.sendFallbackReply(chatId, text, sessionId, sourceLabel),
}
: {}),
sendFallback: (chatId, text, sessionId, sourceLabel) =>
this.sendFallbackReply(chatId, text, sessionId, sourceLabel),
});
}
}
@@ -1320,8 +1315,13 @@ export class DingtalkChannel extends ChannelBase {
) ?? { kind: 'ignored', actorId: callback.actorId }
);
}
const permissionResult = this.permissionCardController?.claim(callback);
if (permissionResult && permissionResult.kind !== 'ignored') {
return permissionResult;
}
return (
this.questionCardController?.claim(callback) ?? {
this.questionCardController?.claim(callback) ??
permissionResult ?? {
kind: 'ignored',
actorId: callback.actorId,
}
@@ -1615,15 +1615,6 @@ export class DingtalkChannel extends ChannelBase {
);
}
if (
this.config.blockStreaming === 'on' &&
(projection.markerCount > 0 || streamedMarkers > 0)
) {
return this.prepareOutgoingText(
withFileUnavailableNotice(projection.text),
);
}
const notices: string[] = [];
if (projection.invalidMarkers > 0) {
notices.push('[File delivery failed: invalid marker]');
@@ -2006,6 +1997,20 @@ export class DingtalkChannel extends ChannelBase {
actorId: string,
target?: { chatId: string; isGroup: boolean },
): Promise<void> {
const copy =
this.locale === 'zh'
? {
title: '卡片操作',
group: '仅任务发起人可以操作这张卡片,本次操作未生效。',
direct: '你无权操作这张卡片,仅任务发起人可以提交或停止。',
}
: {
title: 'Card interaction',
group:
'Only the task initiator can operate this card. This action had no effect.',
direct:
'You cannot operate this card. Only the task initiator can submit or stop it.',
};
if (target?.isGroup) {
return this.sendProactiveChunk(
{
@@ -2014,8 +2019,8 @@ export class DingtalkChannel extends ChannelBase {
chatId: target.chatId,
isGroup: true,
},
'卡片操作',
'仅任务发起人可以操作这张卡片,本次操作未生效。',
copy.title,
copy.group,
'card interaction feedback',
);
}
@@ -2026,8 +2031,8 @@ export class DingtalkChannel extends ChannelBase {
chatId: actorId,
isGroup: false,
},
'卡片操作',
'你无权操作这张卡片,仅任务发起人可以提交或停止。',
copy.title,
copy.direct,
'card interaction feedback',
);
}
@@ -2600,8 +2605,6 @@ export class DingtalkChannel extends ChannelBase {
/** Recall reactions left behind when a session dies without terminal lifecycle events. */
override onSessionDied(sessionId: string): void {
this.blockProjectionArmed.delete(sessionId);
this.blockFileProjectors.delete(sessionId);
for (const [runId, state] of this.fileProjectors) {
if (state.sessionId === sessionId) this.fileProjectors.delete(runId);
}
@@ -2782,7 +2785,6 @@ export class DingtalkChannel extends ChannelBase {
sessionId: string,
messageId?: string,
): void {
this.blockProjectionArmed.add(sessionId);
if (messageId) {
this.bufferedMentionTargets.delete(messageId);
this.untrackBufferedMentionTarget(sessionId, messageId);
@@ -2857,45 +2859,10 @@ export class DingtalkChannel extends ChannelBase {
sessionId: string,
messageId?: string,
): void {
this.settleBlockFileProjector(chatId, sessionId);
this.sessionMentionTargets.delete(sessionId);
this.stopReaction(chatId, messageId, sessionId);
}
/**
* Turn end is the only point where the block projector's held state can be
* settled: ChannelBase drains the turn's queued block sends before calling
* onPromptEnd, so everything already appended belongs to this turn. Flush
* the held candidate bytes (a trailing `[FILE:` prefix the stream never
* completed) before deleting the entry — a later delete-without-settle
* would silently drop them from the delivered answer. Also disarm the
* session: /clear eviction and session death settle WITHOUT draining the
* turn's send chain, and any block that lands afterwards must be dropped
* rather than recreating projector state.
*/
private settleBlockFileProjector(chatId: string, sessionId: string): void {
this.blockProjectionArmed.delete(sessionId);
const state = this.blockFileProjectors.get(sessionId);
if (!state) return;
this.blockFileProjectors.delete(sessionId);
const tail = state.projector.complete();
if (!tail.trim()) return;
// Deliberately fire-and-forget: complete() can only return a strict
// prefix of '[FILE:' (at most 5 chars, no path bytes), so the worst case
// is a stray fragment landing out of order with the next turn — not a
// leak — and blocking settle on a delivery that may hang is worse.
void this.sendReply(
chatId,
tail,
undefined,
this.getResponseSourceLabel(sessionId),
).catch((err) => {
process.stderr.write(
`[DingTalk:${this.name}] projector tail delivery failed for session ${sessionId}: ${err instanceof Error ? err.message : String(err)}\n`,
);
});
}
/** Deliver every Agent segment immediately unless aggregation is enabled. */
override async dispatchBackgroundResponse(
sessionId: string,
@@ -3722,9 +3689,12 @@ export class DingtalkChannel extends ChannelBase {
}
/**
* Out-of-turn one-shot sends (background responses) must not flow through
* the session's block-streaming projector: a second sender interleaving with
* mid-projection state can swallow the send or split a held marker.
* Body-identical to the base implementation — this override exists only to
* widen the signature, which the base seam does not declare. The background
* aggregation flush passes `prepared` so the body it already projected isn't
* projected a second time (that would re-upload its files), and
* `failOnHttpError` so a failed send throws instead of being swallowed,
* letting the flush capture the delivery plan for the next retry.
*/
protected override async deliverBackgroundReply(
chatId: string,
@@ -3734,20 +3704,10 @@ export class DingtalkChannel extends ChannelBase {
prepared = false,
failOnHttpError = false,
): Promise<void> {
if (this.config.blockStreaming !== 'on') {
return this.sendResponseMessage(
chatId,
text,
sessionId,
sourceLabel,
prepared,
failOnHttpError,
);
}
await this.sendReply(
await this.sendResponseMessage(
chatId,
text,
undefined,
sessionId,
sourceLabel,
prepared,
failOnHttpError,
@@ -3762,24 +3722,13 @@ export class DingtalkChannel extends ChannelBase {
prepared = false,
failOnHttpError = false,
): Promise<void> {
let outgoingText = text;
let consumesMention = true;
if (this.config.blockStreaming === 'on') {
const projected = this.projectBlockStreamChunk(text, sessionId);
if (!projected.text.trim()) return;
outgoingText = projected.text;
// A notice-only block must not consume the prompt's mention target:
// the @mention belongs to the block carrying the actual answer.
consumesMention = projected.hasContent;
}
const atUserId =
consumesMention && this.atSender
? this.sessionMentionTargets.get(sessionId)
: undefined;
const atUserId = this.atSender
? this.sessionMentionTargets.get(sessionId)
: undefined;
if (atUserId) this.sessionMentionTargets.delete(sessionId);
await this.sendReply(
chatId,
outgoingText,
text,
atUserId,
sourceLabel ?? this.getResponseSourceLabel(sessionId),
prepared,
@@ -3787,32 +3736,6 @@ export class DingtalkChannel extends ChannelBase {
);
}
private projectBlockStreamChunk(
text: string,
sessionId: string,
): { text: string; hasContent: boolean } {
let state = this.blockFileProjectors.get(sessionId);
if (!state) {
if (!this.blockProjectionArmed.has(sessionId)) {
return { text: '', hasContent: false };
}
state = { projector: new OutboundFileProjector(), reportedMarkers: 0 };
this.blockFileProjectors.set(sessionId, state);
}
const safe = state.projector.append(text);
const hasContent = safe.trim().length > 0;
const result = state.projector.result(safe);
let outgoingText = safe;
if (result.markerCount > state.reportedMarkers) {
outgoingText = withFileUnavailableNotice(safe);
state.reportedMarkers = result.markerCount;
process.stderr.write(
`[DingTalk:${this.name}] file markers redacted in block stream (session ${sessionId}, markers=${result.markerCount})\n`,
);
}
return { text: outgoingText, hasContent };
}
private async sendFallbackReply(
chatId: string,
text: string,
@@ -3914,6 +3837,19 @@ export class DingtalkChannel extends ChannelBase {
return this.interactionPresenter.presentInput(context);
}
protected override async presentPermissionRequest(
context: ChannelPermissionRequestContext,
): Promise<UserInputPresentationResult> {
const run = this.cardRuns.get(context.runId);
if (!run || run.ownerId !== context.owner.id) {
return { kind: 'unsupported' };
}
if (!this.permissionCardController || !this.interactionPresenter) {
return { kind: 'unsupported' };
}
return this.interactionPresenter.presentPermission(context);
}
/**
* Extract quoted/referenced message context from a reply.
* DingTalk provides this via text.repliedMsg (newer) or quoteMessage (legacy).
+19
View File
@@ -73,6 +73,25 @@ export const plugin: ChannelPlugin = {
},
],
},
{
key: 'permissionCard',
label: 'Permission Card',
kind: 'object',
properties: [
{
key: 'enabled',
label: 'Enabled',
kind: 'boolean',
},
{
key: 'timeoutMs',
label: 'Timeout (ms)',
kind: 'number',
exclusiveMinimum:
DINGTALK_INTERACTIVE_CARD_TIMEOUT_EXCLUSIVE_MINIMUM,
},
],
},
],
},
],
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type {
ChannelOutputSegmentContext,
ChannelPermissionRequestContext,
ChannelUserInputRequestContext,
UserInputSettlementReason,
} from '@qwen-code/channel-base';
@@ -11,6 +12,7 @@ import {
type DingtalkInteractiveCardClient,
} from './interactive-card-client.js';
import { DingtalkInteractionPresenter } from './interaction-presenter.js';
import { PermissionCardController } from './permission-card-controller.js';
import { QuestionCardController } from './question-card-controller.js';
import { StatusCardController } from './status-card-controller.js';
@@ -100,6 +102,28 @@ function questionContext(
};
}
function permissionContext(): ChannelPermissionRequestContext {
return {
requestId: 'permission-1',
sessionId: 'session-1',
runId: 'run-1',
owner: { kind: 'channel_user', id: 'owner-1' },
target: {
channelName: 'dingtalk',
chatId: 'cid-1',
senderId: 'owner-1',
isGroup: true,
},
title: 'Run tests',
decisions: [
{ kind: 'allow_once', label: 'Allow once' },
{ kind: 'deny', label: 'Deny' },
],
onSettled: () => () => {},
respond: vi.fn().mockResolvedValue(true),
};
}
function createHarness(options: { language?: string } = {}) {
const projectionOrder: string[] = [];
const client = {
@@ -137,9 +161,16 @@ function createHarness(options: { language?: string } = {}) {
reserveRunProjection: (runId) =>
presenterRef.current?.reserveProjection(runId),
});
const permissionCards = new PermissionCardController({
client,
timeoutMs: 300_000,
reserveRunProjection: (runId) =>
presenterRef.current?.reserveProjection(runId),
});
const presenter = new DingtalkInteractionPresenter({
statusCards,
questionCards,
permissionCards,
...(options.language ? { language: options.language } : {}),
sendFallback,
});
@@ -150,6 +181,7 @@ function createHarness(options: { language?: string } = {}) {
presenter,
projectionOrder,
questionCards,
permissionCards,
statusCards,
cancelRun,
sendFallback,
@@ -449,6 +481,41 @@ describe('DingtalkInteractionPresenter', () => {
).toEqual([QUESTION_CARD_TEMPLATE_ID]);
});
it('presents a permission card through the matching attended run', async () => {
const { client, presenter } = createHarness();
await expect(
presenter.presentPermission(permissionContext()),
).resolves.toEqual({ kind: 'presented' });
expect(client.createAndDeliver).toHaveBeenCalledWith(
expect.objectContaining({
templateId: QUESTION_CARD_TEMPLATE_ID,
outTrackId: expect.stringMatching(/^qwen-permission-/),
}),
);
});
it('rejects permission presentation outside the owning run target', async () => {
const { client, presenter } = createHarness();
const context = permissionContext();
context.target = { ...context.target, chatId: 'another-chat' };
await expect(presenter.presentPermission(context)).resolves.toEqual({
kind: 'unsupported',
});
expect(client.createAndDeliver).not.toHaveBeenCalled();
});
it('cancels permission cards when their run terminalizes', () => {
const { presenter, permissionCards } = createHarness();
const cancelRun = vi.spyOn(permissionCards, 'cancelRun');
presenter.terminalizeRun('run-1', 'cancelled', 'cancel_command');
expect(cancelRun).toHaveBeenCalledWith('run-1');
});
it('correlates direct runs by conversation and delivers cards to the user', async () => {
const { client, presenter } = createHarness();
presenter.registerRun('run-1', 'owner-1', {
@@ -3,6 +3,7 @@ import { sanitizeSenderName } from '@qwen-code/channel-base';
import type {
ChannelOutputSegmentContext,
ChannelOutputSegmentEndReason,
ChannelPermissionRequestContext,
ChannelUserInputRequestContext,
SessionTarget,
UserInputPresentationResult,
@@ -10,6 +11,7 @@ import type {
import { escapeDingTalkMarkdown } from './markdown.js';
import { stripPartialImageMarker } from './outbound-image.js';
import type { QuestionCardController } from './question-card-controller.js';
import type { PermissionCardController } from './permission-card-controller.js';
import type { DingtalkPresentationPhase } from './presentation-phase.js';
import { isChinesePresentationLanguage } from './presentation-phase.js';
import {
@@ -42,6 +44,7 @@ interface SegmentPresentation {
export interface DingtalkInteractionPresenterOptions {
statusCards?: StatusCardController;
questionCards?: QuestionCardController;
permissionCards?: PermissionCardController;
/**
* Effective Qwen display language. When set to a non-Chinese language the
* terminal card copy renders in English; unset keeps the historical
@@ -297,6 +300,24 @@ export class DingtalkInteractionPresenter {
return questionCards.present(context, this.cardTarget(context.target));
}
presentPermission(
context: ChannelPermissionRequestContext,
): Promise<UserInputPresentationResult> {
const run = this.runs.get(context.runId);
if (
!run ||
run.terminal ||
run.ownerId !== context.owner.id ||
run.target.chatId !== context.target.chatId ||
run.target.isGroup !== context.target.isGroup
) {
return Promise.resolve({ kind: 'unsupported' });
}
const permissionCards = this.options.permissionCards;
if (!permissionCards) return Promise.resolve({ kind: 'unsupported' });
return permissionCards.present(context, this.cardTarget(context.target));
}
private terminalCopy(): {
failed: string;
stopped: string;
@@ -333,6 +354,7 @@ export class DingtalkInteractionPresenter {
? 'cancelled'
: 'expired',
);
this.options.permissionCards?.cancelRun(runId);
run.terminal = true;
const activeSegmentId = run.activeSegmentId;
run.activeSegmentId = undefined;
@@ -16,6 +16,9 @@ describe('interactive card config', () => {
const questionCard = interactiveCards?.properties?.find(
(field) => field.key === 'questionCard',
);
const permissionCard = interactiveCards?.properties?.find(
(field) => field.key === 'permissionCard',
);
const timeout = questionCard?.properties?.find(
(field) => field.key === 'timeoutMs',
);
@@ -24,18 +27,33 @@ describe('interactive card config', () => {
expect(
timeout?.kind === 'number' ? timeout.exclusiveMinimum : undefined,
).toBe(DINGTALK_INTERACTIVE_CARD_TIMEOUT_EXCLUSIVE_MINIMUM);
const permissionTimeout = permissionCard?.properties?.find(
(field) => field.key === 'timeoutMs',
);
expect(
permissionTimeout?.kind === 'number'
? permissionTimeout.exclusiveMinimum
: undefined,
).toBe(DINGTALK_INTERACTIVE_CARD_TIMEOUT_EXCLUSIVE_MINIMUM);
const descriptorAdmittedSamples = [
{},
{ enabled: false },
{ statusCard: {} },
{ statusCard: { enabled: false } },
{ questionCard: {} },
{ permissionCard: {} },
{
questionCard: {
enabled: false,
timeoutMs: DINGTALK_INTERACTIVE_CARD_TIMEOUT_EXCLUSIVE_MINIMUM + 1,
},
},
{
permissionCard: {
enabled: false,
timeoutMs: DINGTALK_INTERACTIVE_CARD_TIMEOUT_EXCLUSIVE_MINIMUM + 1,
},
},
];
for (const sample of descriptorAdmittedSamples) {
expect(() => parseDingtalkInteractiveCardConfig(sample)).not.toThrow();
@@ -47,11 +65,13 @@ describe('interactive card config', () => {
enabled: false,
statusCard: { enabled: true },
questionCard: { enabled: true, timeoutMs: 270_000 },
permissionCard: { enabled: true, timeoutMs: 270_000 },
});
expect(parseDingtalkInteractiveCardConfig({})).toEqual({
enabled: true,
statusCard: { enabled: true },
questionCard: { enabled: true, timeoutMs: 270_000 },
permissionCard: { enabled: true, timeoutMs: 270_000 },
});
});
@@ -61,11 +81,13 @@ describe('interactive card config', () => {
enabled: true,
statusCard: { enabled: false },
questionCard: { enabled: true, timeoutMs: 1_000 },
permissionCard: { enabled: false, timeoutMs: 2_000 },
}),
).toEqual({
enabled: true,
statusCard: { enabled: false },
questionCard: { enabled: true, timeoutMs: 1_000 },
permissionCard: { enabled: false, timeoutMs: 2_000 },
});
});
@@ -85,6 +107,14 @@ describe('interactive card config', () => {
expect(() =>
parseDingtalkInteractiveCardConfig({ statusCard: { enabled: 'yes' } }),
).toThrow('statusCard.enabled');
expect(() =>
parseDingtalkInteractiveCardConfig({
permissionCard: { timeoutMs: 0 },
}),
).toThrow('permissionCard.timeoutMs');
expect(() =>
parseDingtalkInteractiveCardConfig({ permissionCard: 'yes' }),
).toThrow('permissionCard must be an object');
});
it('clamps question timeouts at the setTimeout maximum delay', () => {
@@ -96,6 +126,16 @@ describe('interactive card config', () => {
}).questionCard.timeoutMs,
).toBe(DINGTALK_INTERACTIVE_CARD_TIMEOUT_MAXIMUM_MS);
});
it('clamps permission timeouts at the setTimeout maximum delay', () => {
expect(
parseDingtalkInteractiveCardConfig({
permissionCard: {
timeoutMs: DINGTALK_INTERACTIVE_CARD_TIMEOUT_MAXIMUM_MS + 1,
},
}).permissionCard.timeoutMs,
).toBe(DINGTALK_INTERACTIVE_CARD_TIMEOUT_MAXIMUM_MS);
});
});
describe('card callback parser', () => {
@@ -2,6 +2,7 @@ export interface DingtalkInteractiveCardConfig {
enabled: boolean;
statusCard: { enabled: boolean };
questionCard: { enabled: boolean; timeoutMs: number };
permissionCard: { enabled: boolean; timeoutMs: number };
}
export interface DingtalkCardCallback {
@@ -73,6 +74,7 @@ export function parseDingtalkInteractiveCardConfig(
const root = asRecord(value) ?? {};
const status = asRecord(root['statusCard']);
const question = asRecord(root['questionCard']);
const permission = asRecord(root['permissionCard']);
if (root['statusCard'] !== undefined && !status) {
throw new Error('DingTalk interactiveCards.statusCard must be an object.');
}
@@ -81,19 +83,37 @@ export function parseDingtalkInteractiveCardConfig(
'DingTalk interactiveCards.questionCard must be an object.',
);
}
const timeoutMs =
if (root['permissionCard'] !== undefined && !permission) {
throw new Error(
'DingTalk interactiveCards.permissionCard must be an object.',
);
}
const questionTimeoutMs =
question?.['timeoutMs'] === undefined
? DEFAULT_QUESTION_TIMEOUT_MS
: question['timeoutMs'];
if (
typeof timeoutMs !== 'number' ||
!Number.isFinite(timeoutMs) ||
timeoutMs <= DINGTALK_INTERACTIVE_CARD_TIMEOUT_EXCLUSIVE_MINIMUM
typeof questionTimeoutMs !== 'number' ||
!Number.isFinite(questionTimeoutMs) ||
questionTimeoutMs <= DINGTALK_INTERACTIVE_CARD_TIMEOUT_EXCLUSIVE_MINIMUM
) {
throw new Error(
'DingTalk interactiveCards.questionCard.timeoutMs must be a finite positive number.',
);
}
const permissionTimeoutMs =
permission?.['timeoutMs'] === undefined
? DEFAULT_QUESTION_TIMEOUT_MS
: permission['timeoutMs'];
if (
typeof permissionTimeoutMs !== 'number' ||
!Number.isFinite(permissionTimeoutMs) ||
permissionTimeoutMs <= DINGTALK_INTERACTIVE_CARD_TIMEOUT_EXCLUSIVE_MINIMUM
) {
throw new Error(
'DingTalk interactiveCards.permissionCard.timeoutMs must be a finite positive number.',
);
}
return {
enabled: optionalBoolean(root['enabled'], 'enabled', configured),
statusCard: {
@@ -106,7 +126,18 @@ export function parseDingtalkInteractiveCardConfig(
true,
),
timeoutMs: Math.min(
timeoutMs,
questionTimeoutMs,
DINGTALK_INTERACTIVE_CARD_TIMEOUT_MAXIMUM_MS,
),
},
permissionCard: {
enabled: optionalBoolean(
permission?.['enabled'],
'permissionCard.enabled',
true,
),
timeoutMs: Math.min(
permissionTimeoutMs,
DINGTALK_INTERACTIVE_CARD_TIMEOUT_MAXIMUM_MS,
),
},
@@ -63,6 +63,18 @@ describe('OutboundFileProjector', () => {
paths: [],
invalidMarkers: 1,
},
{
input: 'answer ends here [',
text: 'answer ends here [',
paths: [],
invalidMarkers: 0,
},
{
input: 'answer ends here [FI',
text: 'answer ends here [FI',
paths: [],
invalidMarkers: 0,
},
])('projects $input without repairing it', ({ input, ...expected }) => {
expect(projectFileText(input)).toMatchObject(expected);
});
@@ -138,11 +138,6 @@ export function projectFileText(text: string): FileProjection {
return projector.result(safe);
}
export function withFileUnavailableNotice(text: string): string {
const safe = text.trimEnd();
return `${safe}${safe ? '\n' : ''}${FILE_UNAVAILABLE_NOTICE}`;
}
export function safeFileName(filePath: string): string {
return (
basename(filePath)
@@ -0,0 +1,548 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type {
ChannelPermissionRequestContext,
UserInputSettlementReason,
} from '@qwen-code/channel-base';
import {
QUESTION_CARD_TEMPLATE_ID,
type DingtalkInteractiveCardClient,
} from './interactive-card-client.js';
import { PermissionCardController } from './permission-card-controller.js';
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, reject, resolve };
}
function createContext(requestId = 'permission-1', includeAlways = true) {
const listeners = new Set<(reason: UserInputSettlementReason) => void>();
const respond = vi.fn().mockResolvedValue(true);
const context: ChannelPermissionRequestContext = {
requestId,
sessionId: 'session-1',
runId: 'run-1',
owner: { kind: 'channel_user', id: 'owner-1' },
target: {
channelName: 'dingtalk',
chatId: 'cid-1',
senderId: 'owner-1',
isGroup: true,
},
title: 'Run tests',
decisions: [
{ kind: 'allow_once', label: 'Allow once' },
...(includeAlways
? [
{
kind: 'allow_always' as const,
label: 'Always allow for this project',
},
]
: []),
{ kind: 'deny', label: 'Deny' },
],
onSettled(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
respond,
};
return {
context,
respond,
settle(reason: UserInputSettlementReason) {
for (const listener of [...listeners]) listener(reason);
},
};
}
function createHarness(timeoutMs = 300_000, locale: 'en' | 'zh' = 'en') {
const client = {
createAndDeliver: vi.fn().mockResolvedValue(undefined),
openOrUpdateStream: vi.fn().mockResolvedValue(undefined),
updateInstance: vi.fn().mockResolvedValue(undefined),
} as unknown as DingtalkInteractiveCardClient;
const onError = vi.fn();
const controller = new PermissionCardController({
client,
timeoutMs,
locale,
onError,
});
return { client, controller, onError };
}
function callback(
outTrackId: string,
decision: unknown = 'allow_once',
overrides: Record<string, unknown> = {},
) {
return {
outTrackId,
actionId: 'submit',
actorId: 'owner-1',
formData: { permission_decision: decision },
hasBusinessPayload: true,
...overrides,
};
}
function deliveredOutTrackId(client: DingtalkInteractiveCardClient): string {
const request = vi.mocked(client.createAndDeliver).mock.calls[0]![0];
return request.outTrackId;
}
function acceptedExecution(
result: ReturnType<PermissionCardController['claim']>,
) {
expect(result.kind).toBe('accepted');
if (result.kind !== 'accepted') {
throw new Error(`Expected accepted callback, received ${result.kind}`);
}
return result.execute;
}
describe('PermissionCardController', () => {
beforeEach(() => {
vi.useRealTimers();
});
it('renders only the permission decisions advertised by ChannelBase', async () => {
const { client, controller } = createHarness();
const { context } = createContext('permission-once', false);
await expect(
controller.present(context, { chatId: 'cid-1', isGroup: true }),
).resolves.toEqual({ kind: 'presented' });
expect(client.createAndDeliver).toHaveBeenCalledWith({
templateId: QUESTION_CARD_TEMPLATE_ID,
outTrackId: expect.stringMatching(/^qwen-permission-/),
target: { chatId: 'cid-1', isGroup: true },
cardParamMap: expect.objectContaining({
question_id: 'permission-once',
question_title: 'Permission required',
question_desc: 'Run tests',
card_status: 'pending',
form: {
fields: [
{
name: 'permission_decision',
label: 'Choose how to continue',
type: 'CHECKBOX_GROUP',
required: true,
options: [
{ value: 'allow_once', text: 'Allow once' },
{ value: 'deny', text: 'Deny' },
],
},
],
},
}),
});
});
it('renders permission cards in Chinese', async () => {
const { client, controller } = createHarness(300_000, 'zh');
const { context } = createContext('permission-zh', false);
context.decisions = [
{ kind: 'allow_once', label: '仅允许本次' },
{ kind: 'deny', label: '拒绝' },
];
await controller.present(context, { chatId: 'cid-1', isGroup: true });
expect(client.createAndDeliver).toHaveBeenCalledWith(
expect.objectContaining({
cardParamMap: expect.objectContaining({
question_title: '需要授权',
form_btn_text: '提交',
form: {
fields: [expect.objectContaining({ label: '请选择后续操作' })],
},
}),
}),
);
});
it.each([
['approved', 'allow_once', '已授权。', '已授权'],
['denied', 'deny', '已拒绝授权。', '已拒绝'],
['expired', undefined, '此授权请求已失效。', '已失效'],
['cancelled', undefined, '授权请求已取消。', '已取消'],
] as const)(
'projects the %s terminal state in Chinese',
async (cardStatus, decision, description, button) => {
const { client, controller } = createHarness(300_000, 'zh');
const { context, settle } = createContext(`permission-zh-${cardStatus}`);
await controller.present(context, { chatId: 'cid-1', isGroup: true });
const outTrackId = deliveredOutTrackId(client);
if (decision) {
await acceptedExecution(
controller.claim(callback(outTrackId, decision)),
)();
} else if (cardStatus === 'expired') {
settle('resolved_outside_presenter');
} else {
controller.cancelRun('run-1');
}
await vi.waitFor(() =>
expect(client.updateInstance).toHaveBeenCalledWith({
outTrackId,
cardParamMap: {
card_status: cardStatus,
question_desc: description,
form_btn_text: button,
},
}),
);
},
);
it.each([
['allow_once', 'approved'],
['allow_always', 'approved'],
['deny', 'denied'],
] as const)(
'settles %s once and projects %s',
async (decision, cardStatus) => {
const { client, controller } = createHarness();
const { context, respond } = createContext();
await controller.present(context, { chatId: 'cid-1', isGroup: true });
const outTrackId = deliveredOutTrackId(client);
const first = controller.claim(callback(outTrackId, decision));
const duplicate = controller.claim(callback(outTrackId, decision));
await acceptedExecution(first)();
expect(duplicate).toEqual({ kind: 'ignored', actorId: 'owner-1' });
expect(respond).toHaveBeenCalledOnce();
expect(respond).toHaveBeenCalledWith(decision);
expect(client.updateInstance).toHaveBeenCalledWith({
outTrackId,
cardParamMap: expect.objectContaining({ card_status: cardStatus }),
});
},
);
it('cancels and denies the permission through the template cancel action', async () => {
const { client, controller } = createHarness();
const { context, respond } = createContext();
await controller.present(context, { chatId: 'cid-1', isGroup: true });
const outTrackId = deliveredOutTrackId(client);
const result = controller.claim(
callback(outTrackId, undefined, {
actionId: 'cancel',
formData: {},
isCancel: true,
}),
);
await acceptedExecution(result)();
expect(respond).toHaveBeenCalledOnce();
expect(respond).toHaveBeenCalledWith('deny');
expect(client.updateInstance).toHaveBeenCalledWith({
outTrackId,
cardParamMap: expect.objectContaining({ card_status: 'cancelled' }),
});
});
it('rejects foreign actors without exposing a reusable feedback response', async () => {
const { client, controller } = createHarness();
const { context, respond } = createContext();
await controller.present(context, { chatId: 'cid-1', isGroup: true });
const outTrackId = deliveredOutTrackId(client);
expect(
controller.claim(
callback(outTrackId, 'allow_once', { actorId: 'intruder' }),
),
).toEqual({
kind: 'forbidden',
actorId: 'intruder',
target: { chatId: 'cid-1', isGroup: true },
});
expect(
controller.claim(
callback(outTrackId, 'allow_once', { actorId: 'intruder' }),
),
).toEqual({ kind: 'ignored' });
expect(
controller.claim(
callback(outTrackId, 'allow_once', { actorId: 'intruder-2' }),
),
).toEqual({
kind: 'forbidden',
actorId: 'intruder-2',
target: { chatId: 'cid-1', isGroup: true },
});
expect(respond).not.toHaveBeenCalled();
});
it.each([
['unknown decision', 'not-advertised', {}],
['multiple decisions', ['allow_once', 'deny'], {}],
['unknown form field', 'allow_once', { extra: 'forged' }],
])('ignores %s', async (_name, decision, extraForm) => {
const { client, controller } = createHarness();
const { context, respond } = createContext();
await controller.present(context, { chatId: 'cid-1', isGroup: true });
const outTrackId = deliveredOutTrackId(client);
expect(
controller.claim(
callback(outTrackId, decision, {
formData: { permission_decision: decision, ...extraForm },
}),
),
).toEqual({ kind: 'ignored', actorId: 'owner-1' });
expect(respond).not.toHaveBeenCalled();
});
it('returns unsupported without settling when card delivery fails', async () => {
const { client, controller } = createHarness();
vi.mocked(client.createAndDeliver).mockRejectedValue(
new Error('template unavailable'),
);
const { context, respond } = createContext();
await expect(
controller.present(context, { chatId: 'cid-1', isGroup: true }),
).resolves.toEqual({ kind: 'unsupported' });
expect(respond).not.toHaveBeenCalled();
expect(client.updateInstance).not.toHaveBeenCalled();
});
it('expires and denies a timed-out permission', async () => {
vi.useFakeTimers();
const { client, controller } = createHarness(1_000);
const { context, respond } = createContext();
await controller.present(context, { chatId: 'cid-1', isGroup: true });
await vi.advanceTimersByTimeAsync(1_000);
expect(respond).toHaveBeenCalledWith('deny');
expect(client.updateInstance).toHaveBeenCalledWith(
expect.objectContaining({
cardParamMap: expect.objectContaining({ card_status: 'expired' }),
}),
);
});
it('claims the timeout denial before terminal card projection settles', async () => {
vi.useFakeTimers();
const projection = deferred<void>();
const { client, controller } = createHarness(1_000);
vi.mocked(client.updateInstance).mockReturnValue(projection.promise);
const { context, respond } = createContext();
await controller.present(context, { chatId: 'cid-1', isGroup: true });
vi.advanceTimersByTime(1_000);
expect(respond).toHaveBeenCalledOnce();
expect(respond).toHaveBeenCalledWith('deny');
projection.resolve();
await vi.runAllTimersAsync();
});
it('terminalizes when ChannelBase settles the request elsewhere', async () => {
const { client, controller } = createHarness();
const { context, respond, settle } = createContext();
await controller.present(context, { chatId: 'cid-1', isGroup: true });
const outTrackId = deliveredOutTrackId(client);
settle('resolved_outside_presenter');
await vi.waitFor(() =>
expect(client.updateInstance).toHaveBeenCalledWith({
outTrackId,
cardParamMap: expect.objectContaining({ card_status: 'expired' }),
}),
);
expect(respond).not.toHaveBeenCalled();
expect(controller.claim(callback(outTrackId))).toEqual({
kind: 'ignored',
actorId: 'owner-1',
});
});
it('does not reactivate a request settled during delivery', async () => {
const delivery = deferred<void>();
const { client, controller } = createHarness();
vi.mocked(client.createAndDeliver).mockReturnValue(delivery.promise);
const { context, settle, respond } = createContext();
const presenting = controller.present(context, {
chatId: 'cid-1',
isGroup: true,
});
settle('resolved_outside_presenter');
delivery.resolve();
await expect(presenting).resolves.toEqual({ kind: 'presented' });
expect(respond).not.toHaveBeenCalled();
expect(client.updateInstance).toHaveBeenCalledWith(
expect.objectContaining({
cardParamMap: expect.objectContaining({ card_status: 'expired' }),
}),
);
});
it('cancels every pending permission owned by a terminal run', async () => {
const { client, controller } = createHarness();
const first = createContext('permission-1');
const second = createContext('permission-2');
await controller.present(first.context, {
chatId: 'cid-1',
isGroup: true,
});
await controller.present(second.context, {
chatId: 'cid-1',
isGroup: true,
});
controller.cancelRun('run-1');
await vi.waitFor(() =>
expect(client.updateInstance).toHaveBeenCalledTimes(2),
);
expect(
vi
.mocked(client.updateInstance)
.mock.calls.map(([request]) => request.cardParamMap['card_status']),
).toEqual(['cancelled', 'cancelled']);
expect(first.respond).not.toHaveBeenCalled();
expect(second.respond).not.toHaveBeenCalled();
});
it('accepts checkbox-shaped decision submissions', async () => {
const { client, controller } = createHarness();
const { context, respond } = createContext();
await controller.present(context, { chatId: 'cid-1', isGroup: true });
const outTrackId = deliveredOutTrackId(client);
await acceptedExecution(
controller.claim(callback(outTrackId, ['allow_once'])),
)();
expect(respond).toHaveBeenCalledOnce();
expect(respond).toHaveBeenCalledWith('allow_once');
const second = createContext('permission-2');
await controller.present(second.context, {
chatId: 'cid-1',
isGroup: true,
});
const secondOutTrackId = vi.mocked(client.createAndDeliver).mock
.calls[1]![0].outTrackId;
await acceptedExecution(
controller.claim(callback(secondOutTrackId, { value: 'allow_once' })),
)();
expect(second.respond).toHaveBeenCalledOnce();
expect(second.respond).toHaveBeenCalledWith('allow_once');
});
it('ignores a payload-less callback that only carries the cancel action', async () => {
const { client, controller } = createHarness();
const { context, respond } = createContext();
await controller.present(context, { chatId: 'cid-1', isGroup: true });
const outTrackId = deliveredOutTrackId(client);
expect(
controller.claim(
callback(outTrackId, undefined, {
actionId: 'cancel',
formData: {},
hasBusinessPayload: false,
isCancel: false,
}),
),
).toEqual({ kind: 'ignored', actorId: 'owner-1' });
expect(respond).not.toHaveBeenCalled();
});
it('accepts a submit delivered with the requestId action id', async () => {
const { client, controller } = createHarness();
const { context, respond } = createContext();
await controller.present(context, { chatId: 'cid-1', isGroup: true });
const outTrackId = deliveredOutTrackId(client);
await acceptedExecution(
controller.claim(
callback(outTrackId, 'allow_once', { actionId: 'permission-1' }),
),
)();
expect(respond).toHaveBeenCalledOnce();
expect(respond).toHaveBeenCalledWith('allow_once');
});
it('denies through the template built-in cancel flag alone', async () => {
const { client, controller } = createHarness();
const { context, respond } = createContext();
await controller.present(context, { chatId: 'cid-1', isGroup: true });
const outTrackId = deliveredOutTrackId(client);
await acceptedExecution(
controller.claim(
callback(outTrackId, undefined, {
actionId: 'permission-1',
formData: {},
isCancel: true,
}),
),
)();
expect(respond).toHaveBeenCalledOnce();
expect(respond).toHaveBeenCalledWith('deny');
expect(client.updateInstance).toHaveBeenCalledWith(
expect.objectContaining({
cardParamMap: expect.objectContaining({ card_status: 'cancelled' }),
}),
);
});
it('keeps a rejecting timeout denial off the unhandled rejection queue', async () => {
const rejections: string[] = [];
const onUnhandled = (reason: unknown) =>
rejections.push(String((reason as Error).message));
process.on('unhandledRejection', onUnhandled);
try {
const projection = deferred<void>();
const { client, controller, onError } = createHarness(1);
vi.mocked(client.updateInstance).mockReturnValue(projection.promise);
const { context } = createContext();
const decisions: string[] = [];
context.respond = async (decision) => {
decisions.push(decision);
throw new Error('bridge offline');
};
await controller.present(context, { chatId: 'cid-1', isGroup: true });
await vi.waitFor(() =>
expect(client.updateInstance).toHaveBeenCalledOnce(),
);
expect(decisions).toEqual(['deny']);
expect(rejections).toEqual([]);
expect(onError).toHaveBeenCalledWith(
'expired permission cancellation',
expect.any(Error),
);
projection.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(onError).toHaveBeenCalledOnce();
expect(rejections).toEqual([]);
} finally {
process.off('unhandledRejection', onUnhandled);
}
});
});
@@ -0,0 +1,368 @@
import { randomUUID } from 'node:crypto';
import type {
ChannelPermissionDecision,
ChannelPermissionRequestContext,
UserInputPresentationResult,
} from '@qwen-code/channel-base';
import {
QUESTION_CARD_TEMPLATE_ID,
type DingtalkInteractiveCardClient,
} from './interactive-card-client.js';
import type {
DingtalkCardCallback,
DingtalkCardCallbackResult,
} from './interactive-card-types.js';
type PermissionCardState = 'reserved' | 'pending' | 'claimed' | 'terminal';
type PermissionCardTerminalState =
| 'approved'
| 'denied'
| 'cancelled'
| 'expired';
interface PermissionCardRecord {
context: ChannelPermissionRequestContext;
target: { chatId: string; isGroup: boolean };
outTrackId: string;
state: PermissionCardState;
delivered: boolean;
forbiddenActors: Set<string>;
terminalState?: PermissionCardTerminalState;
finishTerminalProjection?: (operation: () => Promise<void>) => Promise<void>;
timer?: ReturnType<typeof setTimeout>;
unsubscribe?: () => void;
}
export interface PermissionCardControllerOptions {
client: DingtalkInteractiveCardClient;
timeoutMs: number;
locale?: 'en' | 'zh';
reserveRunProjection?(
runId: string,
): ((operation: () => Promise<void>) => Promise<void>) | undefined;
onError?(operation: string, error: unknown): void;
}
const DECISION_FIELD = 'permission_decision';
const PERMISSION_CARD_COPY = {
en: {
title: 'Permission required',
submit: 'Submit',
choose: 'Choose how to continue',
approved: { description: 'Permission approved.', button: 'Approved' },
denied: { description: 'Permission denied.', button: 'Denied' },
expired: {
description: 'This permission request is no longer available.',
button: 'Expired',
},
cancelled: {
description: 'Permission request cancelled.',
button: 'Cancelled',
},
},
zh: {
title: '需要授权',
submit: '提交',
choose: '请选择后续操作',
approved: { description: '已授权。', button: '已授权' },
denied: { description: '已拒绝授权。', button: '已拒绝' },
expired: { description: '此授权请求已失效。', button: '已失效' },
cancelled: { description: '授权请求已取消。', button: '已取消' },
},
} as const;
export class PermissionCardController {
private readonly byRequest = new Map<string, PermissionCardRecord>();
private readonly byOutTrack = new Map<string, PermissionCardRecord>();
private readonly pendingByRun = new Map<string, Set<string>>();
constructor(private readonly options: PermissionCardControllerOptions) {}
async present(
context: ChannelPermissionRequestContext,
target: { chatId: string; isGroup: boolean },
): Promise<UserInputPresentationResult> {
const previous = this.byRequest.get(context.requestId);
if (previous) {
this.reserveTerminalProjection(previous);
await this.finalize(previous, 'expired');
}
const record: PermissionCardRecord = {
context,
target,
outTrackId: `qwen-permission-${randomUUID()}`,
state: 'reserved',
delivered: false,
forbiddenActors: new Set(),
};
this.byRequest.set(context.requestId, record);
this.byOutTrack.set(record.outTrackId, record);
const unsubscribe = context.onSettled((reason) => {
if (record.state === 'claimed') return;
if (record.state === 'pending') this.reserveTerminalProjection(record);
void this.finalize(
record,
reason === 'resolved_outside_presenter' ? 'expired' : 'cancelled',
);
});
record.unsubscribe = unsubscribe;
if (record.state === 'terminal') unsubscribe();
try {
await this.options.client.createAndDeliver({
templateId: QUESTION_CARD_TEMPLATE_ID,
outTrackId: record.outTrackId,
target,
cardParamMap: this.cardData(context),
});
record.delivered = true;
} catch (error) {
this.options.onError?.('permission card creation', error);
if (record.state === 'terminal') return { kind: 'presented' };
this.discard(record);
return { kind: 'unsupported' };
}
if (record.state !== 'reserved') {
await this.projectTerminal(record);
return { kind: 'presented' };
}
record.state = 'pending';
const pending = this.pendingByRun.get(context.runId) ?? new Set<string>();
pending.add(context.requestId);
this.pendingByRun.set(context.runId, pending);
record.timer = setTimeout(() => {
void this.expire(record);
}, this.options.timeoutMs);
record.timer.unref?.();
return { kind: 'presented' };
}
claim(callback: DingtalkCardCallback): DingtalkCardCallbackResult {
const record = this.byOutTrack.get(callback.outTrackId);
if (!record || record.state !== 'pending') {
return { kind: 'ignored', actorId: callback.actorId };
}
if (record.context.owner.id !== callback.actorId) {
if (record.forbiddenActors.has(callback.actorId)) {
return { kind: 'ignored' };
}
record.forbiddenActors.add(callback.actorId);
return {
kind: 'forbidden',
actorId: callback.actorId,
target: record.target,
};
}
if (
callback.hasBusinessPayload === false ||
(!callback.isCancel &&
callback.actionId !== 'cancel' &&
callback.actionId !== 'submit' &&
callback.actionId !== record.context.requestId)
) {
return { kind: 'ignored', actorId: callback.actorId };
}
if (callback.isCancel || callback.actionId === 'cancel') {
this.reserveTerminalProjection(record);
record.state = 'claimed';
return {
kind: 'accepted',
execute: () => this.respond(record, 'deny', 'cancelled'),
};
}
const decision = this.parseDecision(record, callback.formData);
if (!decision) return { kind: 'ignored', actorId: callback.actorId };
this.reserveTerminalProjection(record);
record.state = 'claimed';
return {
kind: 'accepted',
execute: () => this.respond(record, decision),
};
}
cancelRun(runId: string): void {
const requestIds = [...(this.pendingByRun.get(runId) ?? [])];
for (const requestId of requestIds) {
const record = this.byRequest.get(requestId);
if (record) {
this.reserveTerminalProjection(record);
void this.finalize(record, 'cancelled');
}
}
}
private async respond(
record: PermissionCardRecord,
decision: ChannelPermissionDecision,
terminalState?: PermissionCardTerminalState,
): Promise<void> {
try {
const accepted = await record.context.respond(decision);
await this.finalize(
record,
accepted
? (terminalState ?? (decision === 'deny' ? 'denied' : 'approved'))
: 'expired',
);
} catch (error) {
this.options.onError?.('permission response', error);
await this.finalize(record, 'expired');
}
}
private async expire(record: PermissionCardRecord): Promise<void> {
if (record.state !== 'pending') return;
this.reserveTerminalProjection(record);
record.state = 'claimed';
const response = record.context.respond('deny').catch((error) => {
this.options.onError?.('expired permission cancellation', error);
});
await this.finalize(record, 'expired');
await response;
}
private async finalize(
record: PermissionCardRecord,
terminalState: PermissionCardTerminalState,
): Promise<void> {
if (record.state === 'terminal') return;
record.state = 'terminal';
record.terminalState = terminalState;
this.removeRecord(record);
if (!record.delivered) return;
const finishTerminalProjection = record.finishTerminalProjection;
record.finishTerminalProjection = undefined;
if (finishTerminalProjection) {
await finishTerminalProjection(() => this.projectTerminal(record));
} else {
await this.projectTerminal(record);
}
}
private discard(record: PermissionCardRecord): void {
if (record.state === 'terminal') return;
record.state = 'terminal';
this.removeRecord(record);
}
private removeRecord(record: PermissionCardRecord): void {
if (record.timer) clearTimeout(record.timer);
record.timer = undefined;
record.unsubscribe?.();
record.unsubscribe = undefined;
this.byRequest.delete(record.context.requestId);
this.byOutTrack.delete(record.outTrackId);
const pending = this.pendingByRun.get(record.context.runId);
pending?.delete(record.context.requestId);
if (pending?.size === 0) this.pendingByRun.delete(record.context.runId);
}
private reserveTerminalProjection(record: PermissionCardRecord): void {
record.finishTerminalProjection ??= this.options.reserveRunProjection?.(
record.context.runId,
);
}
private async projectTerminal(record: PermissionCardRecord): Promise<void> {
if (!record.terminalState) return;
const copy = PERMISSION_CARD_COPY[this.options.locale ?? 'en'];
const cardParamMap: Record<
PermissionCardTerminalState,
Record<string, string>
> = {
approved: {
card_status: 'approved',
question_desc: copy.approved.description,
form_btn_text: copy.approved.button,
},
denied: {
card_status: 'denied',
question_desc: copy.denied.description,
form_btn_text: copy.denied.button,
},
expired: {
card_status: 'expired',
question_desc: copy.expired.description,
form_btn_text: copy.expired.button,
},
cancelled: {
card_status: 'cancelled',
question_desc: copy.cancelled.description,
form_btn_text: copy.cancelled.button,
},
};
try {
await this.options.client.updateInstance({
outTrackId: record.outTrackId,
cardParamMap: cardParamMap[record.terminalState],
});
} catch (error) {
this.options.onError?.('permission card finalization', error);
}
}
private parseDecision(
record: PermissionCardRecord,
formData: Record<string, unknown>,
): ChannelPermissionDecision | undefined {
if (
Object.keys(formData).length !== 1 ||
!Object.prototype.hasOwnProperty.call(formData, DECISION_FIELD)
) {
return undefined;
}
const values = this.readValues(formData[DECISION_FIELD]);
if (values.length !== 1) return undefined;
const decision = values[0] as ChannelPermissionDecision;
return record.context.decisions.some(
(candidate) => candidate.kind === decision,
)
? decision
: undefined;
}
private readValues(value: unknown): string[] {
if (typeof value === 'string') return value.trim() ? [value.trim()] : [];
if (Array.isArray(value)) {
return value
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim())
.filter(Boolean);
}
if (value !== null && typeof value === 'object') {
return this.readValues((value as Record<string, unknown>)['value']);
}
return [];
}
private cardData(
context: ChannelPermissionRequestContext,
): Record<string, unknown> {
const copy = PERMISSION_CARD_COPY[this.options.locale ?? 'en'];
return {
question_id: context.requestId,
question_title: copy.title,
question_desc: context.title,
card_status: 'pending',
form_btn_text: copy.submit,
selected_text: '',
selected_values: '[]',
form: {
fields: [
{
name: DECISION_FIELD,
label: copy.choose,
type: 'CHECKBOX_GROUP',
required: true,
options: context.decisions.map((decision) => ({
value: decision.kind,
text: decision.label,
})),
},
],
},
};
}
}
-1
View File
@@ -632,7 +632,6 @@ export class DwsChannel extends PollingChannelBase<DwsCursor> {
config.approvalMode ??= 'default';
const userInstructions = config.instructions?.trim() || undefined;
config.blockStreaming = 'off';
config.instructions = channelInstructions(userInstructions, profile);
super(name, config, bridge, options);
this.router.setChannelApprovalMode(name, config.approvalMode);
@@ -18,7 +18,7 @@ describe('DWS process environment', () => {
NODE_EXTRA_CA_CERTS: '/etc/ssl/corp.pem',
DWS_DISABLE_KEYCHAIN: '1',
DWS_AGENT_PRODUCT: 'openclaw',
AONE_SANDBOX_ID: 'sandbox-secret',
AONE_SANDBOX_ID: 'sandbox-id',
XDG_DATA_HOME: '/srv/qwen/data',
OPENAI_API_KEY: 'model-secret',
QWEN_SERVER_TOKEN: 'daemon-secret',
@@ -32,6 +32,7 @@ describe('DWS process environment', () => {
NODE_EXTRA_CA_CERTS: '/etc/ssl/corp.pem',
DWS_DISABLE_KEYCHAIN: '1',
DWS_AGENT_PRODUCT: 'qwen-code',
AONE_SANDBOX_ID: 'sandbox-id',
XDG_DATA_HOME: '/srv/qwen/data',
NO_COLOR: '1',
});
@@ -7,6 +7,7 @@
import process from 'node:process';
const SAFE_KEYS = new Set([
'AONE_SANDBOX_ID',
'APPDATA',
'COMSPEC',
'HOME',
+5 -13
View File
@@ -1442,11 +1442,6 @@ export class FeishuChannel extends ChannelBase {
sessionId: string,
segment?: ChannelOutputSegmentContext,
): void {
// In blockStreaming mode, the BlockStreamer delivers text as plain messages.
// Skip card creation/updates to avoid duplicate content and a misleading
// "已取消" card at the end.
if (this.config.blockStreaming === 'on') return;
const inboundMsgId = this.sessionToInboundMsg.get(sessionId);
if (!inboundMsgId) {
process.stderr.write(
@@ -1632,7 +1627,6 @@ export class FeishuChannel extends ChannelBase {
_chatId: string,
sessionId: string,
): void {
if (this.config.blockStreaming === 'on') return;
const inboundMsgId = this.sessionToInboundMsg.get(sessionId);
if (!inboundMsgId) return;
const cardState = this.cardSessions.get(inboundMsgId);
@@ -1660,7 +1654,7 @@ export class FeishuChannel extends ChannelBase {
this.onResponseBoundary(chatId, sessionId);
return;
}
if (reason !== 'input_requested' || this.config.blockStreaming === 'on') {
if (reason !== 'input_requested') {
return;
}
@@ -2112,10 +2106,7 @@ export class FeishuChannel extends ChannelBase {
const sourceLabel = this.getResponseSourceLabel(sessionId);
this.sessionToInboundMsg.set(sessionId, inboundMsgId);
this.addReaction(inboundMsgId, 'OnIt').catch(() => {});
if (
this.config.blockStreaming !== 'on' &&
!this.cardSessions.has(inboundMsgId)
) {
if (!this.cardSessions.has(inboundMsgId)) {
this.cardSessions.set(inboundMsgId, {
messageId: '',
created: false,
@@ -2221,8 +2212,9 @@ export class FeishuChannel extends ChannelBase {
// to avoid leaking state if onResponseComplete was skipped.
this.cleanupCard(inboundMsgId);
} else if (!cs) {
// No card session created (blockStreaming mode or gate rejection) —
// clean up auxiliary maps populated by processMessage.
// onPromptStart's isKnownInboundMessageId gate rejected this message, so
// no card session exists — clean up the auxiliary maps processMessage
// populated.
this.msgToQuestion.delete(inboundMsgId);
this.msgToSenderName.delete(inboundMsgId);
this.msgToSenderId.delete(inboundMsgId);
@@ -110,7 +110,6 @@ function createObservedContactChannel(
const channel = new ObservedContactFeishuChannel(
'test',
createConfig({
blockStreaming: 'on',
groupPolicy: 'open',
groups: { '*': { requireMention: false } },
}),
@@ -1113,14 +1113,12 @@ describe('GithubChannel', () => {
expect(config.allowedUsers).toEqual(['alice']);
});
it('forces final-only delivery and appends the publication policy', () => {
it('appends the publication policy', () => {
const config = makeConfig({
blockStreaming: 'on',
instructions: 'Respond in Chinese.',
});
new TestableGithubChannel('test-github', config, makeBridge());
expect(config.blockStreaming).toBe('off');
expect(config.instructions).toContain('GitHub publication policy:');
expect(config.instructions).toContain('<no-reply/>');
expect(config.instructions).toContain('Respond in Chinese.');
@@ -526,7 +526,6 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
bridge: ChannelAgentBridge,
options?: ChannelBaseOptions,
) {
config.blockStreaming = 'off';
config.instructions = [
config.instructions?.trim(),
GITHUB_PUBLICATION_INSTRUCTIONS,
@@ -112,7 +112,6 @@ Existing TypeScript plugins that explicitly type the adapter constructor or fact
### Features you get for free
- **Block streaming** — enable `blockStreaming: "on"` in config and the agent's response is automatically split into multiple messages at paragraph boundaries
- **Attachments** — populate `envelope.attachments` with images/files and `handleInbound()` routes them to the agent (images as vision input, files as paths in the prompt)
- **Streaming hooks** — override `onResponseChunk()` for progressive display (e.g., editing a message in-place)
- Access control (allowlist, pairing, open), session routing, slash commands, crash recovery
+3 -9
View File
@@ -225,7 +225,6 @@ export class QQChannel extends ChannelBase {
private flushingSessions: Set<string> = new Set();
private pendingStreamDelete: Set<string> = new Set();
private _reconnectId: number = 0;
private blockStreaming: boolean = false;
private flushedSessions: Set<string> = new Set();
/**
* Sessions with a prompt turn currently in flight, tracked via
@@ -233,9 +232,7 @@ export class QQChannel extends ChannelBase {
*
* This is the discriminator the cron textChunk handler uses to tell
* "prompt-response chunk" from "cron/non-prompt chunk". streamState
* cannot serve that role (#6094): it is never populated when
* blockStreaming is 'on' (onResponseChunk early-returns), so prompt
* chunks leak into cronBuffer; and a residual entry from a finished
* cannot serve that role (#6094): a residual entry from a finished
* turn's unsettled flush silently blocks cron delivery. This set is
* reliable because ChannelBase always brackets a prompt turn with
* onPromptStart and onPromptEnd (onPromptEnd runs in the prompt path's
@@ -298,7 +295,6 @@ export class QQChannel extends ChannelBase {
);
this.qqConfig.bufferFlushLength = QQChannel.MAX_BUFFER_LENGTH;
}
this.blockStreaming = this.config.blockStreaming === 'on';
this.qqStatePath = join(stateDir, `${safeName}-state.json`);
// In standalone mode (no external router), use the per-channel
// sessions path so the channel owns its own session file.
@@ -333,9 +329,8 @@ export class QQChannel extends ChannelBase {
// Sessions with an active prompt turn belong to the prompt path
// (which delivers the response itself) — never capture their chunks
// into the cron buffer. Keyed on activePromptSessions rather than
// streamState (#6094): streamState is empty under blockStreaming:'on'
// (prompt chunks would be duplicated) and can linger after a turn
// ends (cron chunks would be silently dropped).
// streamState (#6094): streamState can linger after a turn ends,
// which would silently drop cron chunks.
if (this.activePromptSessions.has(sessionId)) return;
let entry = this.cronBuffer.get(sessionId);
if (!entry) {
@@ -1110,7 +1105,6 @@ export class QQChannel extends ChannelBase {
sessionId: string,
segment?: ChannelOutputSegmentContext,
): void {
if (this.blockStreaming) return;
let state = this.streamState.get(sessionId);
if (!state) {
const messageId =
+5 -15
View File
@@ -619,13 +619,8 @@ describe('prompt/cron textChunk discriminator (#6094)', () => {
};
}
// #6094 item 1: with blockStreaming:'on', onResponseChunk early-returns
// without populating streamState, so the streamState.has(sessionId) guard
// cannot tell prompt chunks from cron chunks. While a cron flow is active,
// every prompt-response chunk leaks into cronBuffer and is re-sent by the
// 2s idle flush on top of the BlockStreamer delivery.
it('item 1: blockStreaming=on prompt chunks during a cron flow are not duplicated into cronBuffer', async () => {
const ch = makeChannel({ blockStreaming: 'on' });
it('prompt chunks during a cron flow are not duplicated into cronBuffer', async () => {
const ch = makeChannel();
const pvt = ch as unknown as Record<string, unknown>;
pvt['_ready'] = true;
@@ -635,14 +630,12 @@ describe('prompt/cron textChunk discriminator (#6094)', () => {
// A cron flow is active concurrently (scheduled-message flow in flight).
pvt['_inCronFlow'] = 1;
// Prompt response chunk arrives. With blockStreaming:'on' the streaming
// path early-returns, so no streamState entry exists for this session.
triggerTextChunk('sess-prompt', 'prompt response text');
await flushSetImmediate();
const cronBuffer = pvt['cronBuffer'] as Map<string, { buffer: string }>;
// The prompt text belongs to an active prompt — it must not be captured
// by the cron buffer (BlockStreamer already delivers it).
// by the cron buffer.
expect(cronBuffer.has('sess-prompt')).toBe(false);
await vi.advanceTimersByTimeAsync(2000);
@@ -651,11 +644,8 @@ describe('prompt/cron textChunk discriminator (#6094)', () => {
promptHooks(ch).onPromptEnd('test-chat', 'sess-prompt');
});
// Regression guard for item 1 fix: genuine cron chunks (no active prompt
// for the session) must still be buffered and delivered with
// blockStreaming:'on'.
it('item 1 regression: cron chunks without an active prompt are still delivered (blockStreaming=on)', async () => {
const ch = makeChannel({ blockStreaming: 'on' });
it('cron chunks without an active prompt are still delivered', async () => {
const ch = makeChannel();
const pvt = ch as unknown as Record<string, unknown>;
pvt['_ready'] = true;
pvt['_inCronFlow'] = 1;
@@ -740,13 +740,6 @@ describe('streaming guards', () => {
expect(mockSendQQMessage).not.toHaveBeenCalled();
});
it('blockStreaming=on prevents streamState accumulation', () => {
const ch = makeChannel({ blockStreaming: 'on' });
onResponseChunk(ch, 'test-chat', 'blocked', 'sess-1');
expect(streamState(ch).has('sess-1')).toBe(false);
});
it('flushingSessions guard prevents double-send', async () => {
const ch = makeChannel();
let resolveSend: (v: MockResponse) => void;
File diff suppressed because it is too large. Load diff
+123 -24
View File
@@ -5,6 +5,7 @@
*/
import {
type ContentGeneratorConfig,
APPROVAL_MODE_INFO,
APPROVAL_MODES,
AuthType,
@@ -282,6 +283,8 @@ import {
buildModelReasoningConfigPreview,
clearReasoningRequestOverrides,
getConfiguredModelReasoning,
getDefaultReasoningConfig,
getGptReasoningOverrideState,
isReasoningSelectionSupported,
PERSIST_REASONING_SELECTION_META_KEY,
parseReasoningSelection,
@@ -5078,7 +5081,10 @@ class QwenAgent implements Agent {
sessionId: session.getId(),
models: this.buildAvailableModels(config),
modes: this.buildModesData(config),
configOptions: this.buildConfigOptions(config),
configOptions: this.buildConfigOptions(
config,
session.getDefaultReasoningConfig(),
),
}));
},
parentContext ? { parentContext } : {},
@@ -5170,7 +5176,10 @@ class QwenAgent implements Agent {
({
modes: this.buildModesData(config),
models: this.buildAvailableModels(config),
configOptions: this.buildConfigOptions(config),
configOptions: this.buildConfigOptions(
config,
liveSession.getDefaultReasoningConfig(),
),
...(projection?.artifactSnapshot
? { artifactSnapshot: projection.artifactSnapshot }
: {}),
@@ -5324,7 +5333,10 @@ class QwenAgent implements Agent {
profiler.timeSync('response_build', () => ({
modes: this.buildModesData(config),
models: this.buildAvailableModels(config),
configOptions: this.buildConfigOptions(config),
configOptions: this.buildConfigOptions(
config,
getDefaultReasoningConfig(config, settings),
),
...(projection?.runtime.artifactSnapshot
? { artifactSnapshot: projection.runtime.artifactSnapshot }
: {}),
@@ -5648,7 +5660,10 @@ class QwenAgent implements Agent {
({
modes: this.buildModesData(config),
models: this.buildAvailableModels(config),
configOptions: this.buildConfigOptions(config),
configOptions: this.buildConfigOptions(
config,
liveSession.getDefaultReasoningConfig(),
),
...(projection?.artifactSnapshot
? { artifactSnapshot: projection.artifactSnapshot }
: {}),
@@ -5737,7 +5752,10 @@ class QwenAgent implements Agent {
response = profiler.timeSync('response_build', () => ({
modes: this.buildModesData(config),
models: this.buildAvailableModels(config),
configOptions: this.buildConfigOptions(config),
configOptions: this.buildConfigOptions(
config,
getDefaultReasoningConfig(config, settings),
),
...(projection?.runtime.artifactSnapshot
? { artifactSnapshot: projection.runtime.artifactSnapshot }
: {}),
@@ -5935,7 +5953,8 @@ class QwenAgent implements Agent {
case 'reasoning_effort': {
const config = session.getConfig();
const generation = config.getContentGeneratorConfig();
const option = this.buildConfigOptions(config).find(
const defaultReasoning = session.getDefaultReasoningConfig();
const option = this.buildConfigOptions(config, defaultReasoning).find(
(candidate) => candidate.id === 'reasoning_effort',
);
const modelReasoning = this.getModelReasoningConfiguration(config);
@@ -5983,7 +6002,6 @@ class QwenAgent implements Agent {
'Reasoning effort cannot be applied while thinking is disabled',
);
}
const defaultReasoning = session.getDefaultReasoningConfig();
const previous = {
reasoning: generation.reasoning,
extra_body: generation.extra_body,
@@ -6007,7 +6025,10 @@ class QwenAgent implements Agent {
: selected,
);
}
const configOptions = this.buildConfigOptions(config);
const configOptions = this.buildConfigOptions(
config,
defaultReasoning,
);
const confirmedValue = configOptions.find(
(candidate) => candidate.id === 'reasoning_effort',
)?.currentValue;
@@ -6015,7 +6036,12 @@ class QwenAgent implements Agent {
selected === REASONING_EFFORT_DEFAULT
? confirmedValue !== undefined
: confirmedValue === selected;
if (!confirmed) {
if (
!confirmed ||
(tierSelected &&
getGptReasoningOverrideState(generation, modelReasoning)
?.blocksTierChange)
) {
throw RequestError.invalidParams(
undefined,
modelReasoning
@@ -6050,7 +6076,10 @@ class QwenAgent implements Agent {
}
return {
configOptions: this.buildConfigOptions(session.getConfig()),
configOptions: this.buildConfigOptions(
session.getConfig(),
session.getDefaultReasoningConfig(),
),
};
});
}
@@ -7292,6 +7321,7 @@ class QwenAgent implements Agent {
): ServeWorkspaceProvidersStatus {
try {
const workspaceCwd = this.workspaceCwd(config);
const settings = loadSettingsCached(workspaceCwd);
const currentAuthType = config.getAuthType?.();
const activeRuntimeSnapshot = config.getActiveRuntimeModelSnapshot?.();
const currentModelId = activeRuntimeSnapshot
@@ -7331,6 +7361,14 @@ class QwenAgent implements Agent {
const isCurrent =
currentAuth === model.authType && currentAcpModelId === modelId;
const resolved =
!model.isRuntimeModel && !modelId.startsWith(ACP_ROUTE_ID_PREFIX)
? config.getResolvedModelConfig?.(
model.authType,
model.id,
model.registryBaseUrl ?? model.baseUrl,
)
: undefined;
const configOptions =
model.isRuntimeModel || modelId.startsWith(ACP_ROUTE_ID_PREFIX)
? undefined
@@ -7338,15 +7376,18 @@ class QwenAgent implements Agent {
model.id,
resolvePersistedReasoningConfigState(
model.id,
this.settings.merged.model?.reasoningEffort,
config.getResolvedModelConfig?.(
model.authType,
model.id,
model.registryBaseUrl ?? model.baseUrl,
)?.generationConfig.thinkingMandatory === true,
settings.merged.model?.reasoningEffort,
resolved?.generationConfig.thinkingMandatory === true,
model.capabilities?.reasoning,
),
model.capabilities?.reasoning,
resolved
? {
...resolved.generationConfig,
model: model.id,
baseUrl: resolved.baseUrl,
}
: undefined,
);
const providerModel: ServeWorkspaceProviderModel = {
modelId,
@@ -7843,7 +7884,10 @@ class QwenAgent implements Agent {
state: {
models: this.buildAvailableModels(config),
modes: this.buildModesData(config),
configOptions: this.buildConfigOptions(config),
configOptions: this.buildConfigOptions(
config,
session.getDefaultReasoningConfig(),
),
},
};
}
@@ -11121,6 +11165,7 @@ class QwenAgent implements Agent {
case SERVE_CONTROL_EXT_METHODS.sessionApprovalMode: {
const sessionId = params['sessionId'];
const mode = params['mode'];
const planMode = params['planMode'];
if (typeof sessionId !== 'string' || sessionId.length === 0) {
throw RequestError.invalidParams(
undefined,
@@ -11136,11 +11181,24 @@ class QwenAgent implements Agent {
`Invalid approval mode; allowed: ${APPROVAL_MODES.join(', ')}`,
);
}
if (
planMode !== undefined &&
(typeof planMode !== 'boolean' || mode === ApprovalMode.PLAN)
) {
throw RequestError.invalidParams(
undefined,
'planMode must be a boolean with a non-plan execution mode',
);
}
const session = this.sessionOrThrow(sessionId);
const config = session.getConfig();
const previous = config.getApprovalMode();
try {
config.setApprovalMode(mode as ApprovalMode);
if (typeof planMode === 'boolean') {
config.setPlanMode(planMode, mode as ApprovalMode);
} else {
config.setApprovalMode(mode as ApprovalMode);
}
} catch (err) {
// `TrustGateError` is the core's structured rejection for
// untrusted-folder + privileged-mode. We re-raise it as a
@@ -11164,7 +11222,16 @@ class QwenAgent implements Agent {
} else if (previous === 'plan') {
session.clearActiveTodoPlanRevision();
}
return { previous, current };
return {
previous,
current,
...(current === ApprovalMode.PLAN
? {
planExecutionMode:
config.getPlanExecutionMode?.() ?? config.getPrePlanMode?.(),
}
: {}),
};
}
case SERVE_CONTROL_EXT_METHODS.workspaceSessionWorkflow: {
const enabled = params['enabled'];
@@ -14587,11 +14654,22 @@ class QwenAgent implements Agent {
return {
currentModeId: currentApprovalMode as ApprovalModeValue,
...(currentApprovalMode === ApprovalMode.PLAN
? {
_meta: {
planExecutionMode:
config.getPlanExecutionMode?.() ?? config.getPrePlanMode?.(),
},
}
: {}),
availableModes,
};
}
private buildConfigOptions(config: Config): SessionConfigOption[] {
private buildConfigOptions(
config: Config,
defaultReasoning: ContentGeneratorConfig['reasoning'],
): SessionConfigOption[] {
const currentApprovalMode = config.getApprovalMode();
const modelOptions = this.buildSelectableModelOptions(config);
const rawCurrentModelId = (config.getModel() || '').trim();
@@ -14694,6 +14772,17 @@ class QwenAgent implements Agent {
reasoningOverrideValue === false) ||
(reasoningOverride?.field === 'reasoning_effort' &&
reasoningOverrideValue === REASONING_EFFORT_NONE);
const gptOverride = getGptReasoningOverrideState(
generation,
modelReasoning,
);
const gptEnableOverride =
generation.reasoning === false
? getGptReasoningOverrideState(
{ ...generation, reasoning: undefined },
modelReasoning,
)
: gptOverride;
const mandatoryUsesDefaultEffort =
generation.thinkingMandatory === true &&
(overrideDisablesReasoning ||
@@ -14701,17 +14790,22 @@ class QwenAgent implements Agent {
reasoningOverride?.field === 'reasoning_effort'));
const effectiveModelEffort =
modelReasoning && !modelReasoning.toggleOnly
? mandatoryUsesDefaultEffort
? mandatoryUsesDefaultEffort || gptOverride?.useDefaultEffort
? modelReasoning.defaultEffort
: normalizedOverrideEffort
? (modelReasoning.efforts?.find(
(effort) => effort === normalizedOverrideEffort,
) ?? modelReasoning.defaultEffort)
: currentModelEffort
: (gptOverride?.effort ?? currentModelEffort)
: currentModelEffort;
const reasoningEnabled =
generation.reasoning !== false &&
(!reasoningOverride || !overrideDisablesReasoning);
generation.reasoning === undefined &&
!reasoningOverride &&
gptOverride?.enabled === undefined
? undefined
: generation.reasoning !== false &&
(!reasoningOverride || !overrideDisablesReasoning) &&
gptOverride?.enabled !== false;
const canDisableReasoning = generation.thinkingMandatory !== true;
const reasoningEffortConfigOption: SessionConfigOption = (modelReasoning
? buildModelReasoningConfigOption(
@@ -14719,6 +14813,11 @@ class QwenAgent implements Agent {
{
enabled: reasoningEnabled,
effort: effectiveModelEffort,
...(gptEnableOverride?.blocksTierChange
? gptEnableOverride.enabled && defaultReasoning !== false
? { enableValue: REASONING_EFFORT_DEFAULT }
: { canEnable: false }
: {}),
thinkingMandatory: generation.thinkingMandatory === true,
},
modelReasoning,
@@ -137,6 +137,18 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({
return config.getReasoningEffort() === effort;
},
REASONING_EFFORT_TIERS: ['low', 'medium', 'high', 'xhigh', 'max'],
clampReasoningEffort: (
await importOriginal<typeof import('@qwen-code/qwen-code-core')>()
).clampReasoningEffort,
getGptReasoningCapabilities: (
await importOriginal<typeof import('@qwen-code/qwen-code-core')>()
).getGptReasoningCapabilities,
isReasoningEffortPlaceholder: (
await importOriginal<typeof import('@qwen-code/qwen-code-core')>()
).isReasoningEffortPlaceholder,
isOpenRouterHostname: (
await importOriginal<typeof import('@qwen-code/qwen-code-core')>()
).isOpenRouterHostname,
// The real parser: `model-configuration` gates every reasoning control on it,
// and a stand-in would decide capability validity differently from the wire.
parseModelReasoningCapabilities: (
@@ -5,18 +5,272 @@
*/
import type { Config, ContentGeneratorConfig } from '@qwen-code/qwen-code-core';
import { describe, expect, it } from 'vitest';
import {
buildInstallPlan,
findProviderById,
resolveBaseUrl,
} from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../config/settings.js';
import { describe, expect, it, vi } from 'vitest';
import {
applyReasoningSelection,
buildModelReasoningConfigOption,
buildModelReasoningConfigPreview,
clearReasoningRequestOverrides,
getConfiguredModelReasoning,
getDefaultReasoningConfig,
getModelConfiguration,
getGptReasoningOverrideState,
type ModelReasoningConfiguration,
isReasoningSelectionSupported,
resolvePersistedReasoningConfigState,
} from './model-configuration.js';
describe('default reasoning configuration', () => {
it.each([
[{ reasoning: false }, { effort: 'high' }, false, false],
[{}, false, false, undefined],
[undefined, false, false, false],
[{ reasoning: { effort: 'max' } }, false, true, false],
] as const)(
'resolves model defaults %j with legacy default %j and runtime %s',
(generationConfig, legacyDefault, runtime, expected) => {
const resolve = vi.fn(() =>
generationConfig ? { generationConfig } : undefined,
);
const config = {
getAuthType: () => 'openai',
getModel: () => 'gpt-5.5',
getCurrentModelRegistryBaseUrl: () => 'https://selected.example/v1',
getActiveRuntimeModelSnapshot: () => (runtime ? {} : undefined),
getResolvedModelConfig: resolve,
} as unknown as Config;
const settings = {
merged: { model: { generationConfig: { reasoning: legacyDefault } } },
} as unknown as LoadedSettings;
expect(getDefaultReasoningConfig(config, settings)).toEqual(expected);
if (runtime) {
expect(resolve).not.toHaveBeenCalled();
} else {
expect(resolve).toHaveBeenCalledWith(
'openai',
'gpt-5.5',
'https://selected.example/v1',
);
}
},
);
});
describe('model configuration manifest', () => {
it.each([
['moonshot', 'kimi-k3', ['low', 'high', 'max'], 'max'],
['moonshot', 'kimi-k2.7-code', ['default'], 'default'],
['moonshot', 'kimi-k2.7-code-highspeed', ['default'], 'default'],
['moonshot', 'kimi-k2.6', ['none', 'default'], 'default'],
['deepseek', 'deepseek-v4-pro', ['none', 'low', 'high', 'max'], 'high'],
['deepseek', 'deepseek-v4-flash', ['none', 'low', 'high', 'max'], 'high'],
[
'alibabaStandard',
'qwen3.8-max',
['none', 'low', 'medium', 'xhigh'],
'xhigh',
],
[
'alibabaStandard',
'qwen3.8-max-0902',
['none', 'low', 'medium', 'xhigh'],
'xhigh',
],
[
'alibabaStandard',
'qwen3.8-flash',
['none', 'low', 'medium', 'xhigh'],
'xhigh',
],
['alibabaStandard', 'deepseek-v4-pro', ['none', 'high', 'max'], 'high'],
[
'alibabaStandard',
'deepseek-v4-pro-0813',
['none', 'low', 'high', 'max'],
'high',
],
['alibabaStandard', 'kimi-k3', ['low', 'high', 'max'], 'max'],
['token-plan', 'qwen3.8-max', ['low', 'medium', 'xhigh'], 'xhigh'],
['token-plan', 'qwen3.8-max-preview', ['low', 'medium', 'xhigh'], 'xhigh'],
[
'token-plan',
'qwen3.8-flash',
['none', 'low', 'medium', 'xhigh'],
'xhigh',
],
[
'token-plan',
'deepseek-v4-flash-0731',
['none', 'low', 'high', 'max'],
'high',
],
['coding-plan', 'qwen3.5-plus', ['none', 'default'], 'default'],
['coding-plan', 'kimi-k2.5', ['none', 'default'], 'default'],
] as const)(
'projects installed %s / %s native choices',
(providerId, model, values, currentValue) => {
const preset = findProviderById(providerId)!;
const installed = buildInstallPlan(preset, {
baseUrl: resolveBaseUrl(preset),
apiKey: 'test-key',
modelIds: [model],
}).modelProviders![0].models[0];
const reasoning = installed.capabilities?.reasoning;
expect(reasoning).toBeDefined();
const option = buildModelReasoningConfigOption(model, {}, reasoning);
expect(
option?.options.map((item) =>
'value' in item ? item.value : undefined,
),
).toEqual(values);
expect(option?.currentValue).toBe(currentValue);
const restored = buildModelReasoningConfigOption(
model,
{ effort: 'high' },
reasoning,
);
expect(restored?.currentValue).toBe(
values.some((value) => value === 'high') ? 'high' : currentValue,
);
},
);
it.each([
['gpt-5.1', ['none', 'low', 'medium', 'high'], 'none'],
['gpt-5.4', ['none', 'low', 'medium', 'high', 'xhigh'], 'none'],
['gpt-5.5', ['none', 'low', 'medium', 'high', 'xhigh'], 'medium'],
['gpt-5.6', ['none', 'low', 'medium', 'high', 'xhigh', 'max'], 'medium'],
['gpt-6-astra', ['low', 'medium', 'high', 'xhigh', 'max'], 'medium'],
['gpt-5.3-codex', ['low', 'medium', 'high', 'xhigh'], 'medium'],
['gpt-5-pro', ['high'], 'high'],
] as const)(
'previews the supported efforts and default for %s',
(model, values, currentValue) => {
const option = buildModelReasoningConfigOption(model);
expect(option?.currentValue).toBe(currentValue);
expect(
option?.options.map((choice) =>
'value' in choice ? choice.value : undefined,
),
).toEqual(values);
expect(buildModelReasoningConfigPreview(model)).toEqual([option]);
},
);
it.each([
['gpt-5.4', 'max', 'xhigh'],
['gpt-5.1', 'max', 'high'],
['gpt-5-pro', 'low', 'high'],
] as const)(
'displays the same clamped %s tier as the provider',
(model, effort, expected) => {
expect(buildModelReasoningConfigOption(model, { effort })).toMatchObject({
currentValue: expected,
});
expect(resolvePersistedReasoningConfigState(model, effort)).toEqual({
enabled: true,
effort: expected,
thinkingMandatory: model === 'gpt-5-pro',
});
},
);
it('shows a selected effort when a GPT model defaults to thinking off', () => {
expect(
buildModelReasoningConfigOption('gpt-5.4', { effort: 'high' }),
).toMatchObject({ currentValue: 'high' });
expect(
buildModelReasoningConfigOption('gpt-5.4', {
enabled: false,
effort: 'high',
}),
).toMatchObject({ currentValue: 'none' });
});
it.each(['gpt-5.3-codex', 'gpt-6-astra'])(
'advertises mandatory thinking for %s even when the state does not require it',
(model) => {
expect(
buildModelReasoningConfigOption(model, {
enabled: false,
thinkingMandatory: false,
}),
).toMatchObject({
currentValue: 'medium',
_meta: { 'qwenCode/reasoning': { thinkingMandatory: true } },
});
expect(isReasoningSelectionSupported(model, 'none')).toBe(false);
expect(resolvePersistedReasoningConfigState(model, 'none')).toEqual({
thinkingMandatory: true,
});
},
);
it('validates persisted GPT tiers against model capabilities', () => {
expect(isReasoningSelectionSupported('gpt-5.1', 'xhigh')).toBe(false);
expect(isReasoningSelectionSupported('gpt-5.4', 'max')).toBe(false);
expect(isReasoningSelectionSupported('gpt-5.6', 'max')).toBe(true);
expect(isReasoningSelectionSupported('gpt-6-astra', 'max')).toBe(true);
expect(resolvePersistedReasoningConfigState('gpt-5.4', 'high')).toEqual({
thinkingMandatory: false,
enabled: true,
effort: 'high',
});
});
it.each(['gpt-5.4', 'gpt-6-astra'])(
'preserves explicit %s reasoning overrides',
(model) => {
const generation = {
model,
samplingParams: {
max_completion_tokens: 1024,
reasoning_effort: 'low',
},
extra_body: { reasoning: { effort: 'high' } },
} as ContentGeneratorConfig;
const original = structuredClone(generation);
clearReasoningRequestOverrides(generation);
expect(generation).toEqual(original);
},
);
it('keeps configured GPT tiers and defaults ahead of the built-in fallback', () => {
const reasoning = {
thinking: true,
efforts: ['medium', 'max'],
defaultEffort: 'max',
disableField: 'reasoning_effort',
} as const;
expect(
buildModelReasoningConfigOption('gpt-5.4', {}, reasoning),
).toMatchObject({
currentValue: 'max',
options: [{ value: 'none' }, { value: 'medium' }, { value: 'max' }],
});
expect(
resolvePersistedReasoningConfigState('gpt-5.4', 'max', false, reasoning),
).toEqual({ thinkingMandatory: false, enabled: true, effort: 'max' });
expect(
isReasoningSelectionSupported('gpt-5.4', 'low', false, reasoning),
).toBe(false);
expect(
buildModelReasoningConfigOption(
'gpt-6-astra',
{ enabled: false },
reasoning,
),
).toMatchObject({
currentValue: 'max',
options: [{ value: 'medium' }, { value: 'max' }],
_meta: { 'qwenCode/reasoning': { thinkingMandatory: true } },
});
});
it('projects reasoning declared by the resolved provider model', () => {
const reasoning = {
thinking: true,
@@ -283,3 +537,186 @@ describe('model configuration manifest', () => {
expect(live.reasoning).toBe(false);
});
});
describe('GPT raw reasoning reporting', () => {
it.each([
[{ model: 'custom-model', extra_body: { reasoning: false } }, undefined],
[{ reasoning: false, extra_body: { reasoning: false } }, undefined],
[
{ extra_body: { reasoning: false } },
{ enabled: true, useDefaultEffort: true },
],
[
{ extra_body: { reasoning: null } },
{ enabled: true, useDefaultEffort: true },
],
[
{ model: 'gpt-5.4', extra_body: { reasoning: { effort: 'high' } } },
{ enabled: false, useDefaultEffort: true },
],
[
{ extra_body: { reasoning: { effort: 'high' } } },
{ enabled: true, useDefaultEffort: true },
],
[
{ extra_body: { reasoning: false, reasoning_effort: 'none' } },
{ enabled: false, useDefaultEffort: true },
],
[
{ extra_body: { reasoning_effort: 'high' } },
{ enabled: true, effort: 'high', useDefaultEffort: false },
],
[
{ samplingParams: { reasoning_effort: 'high' } },
{ enabled: true, effort: 'high', useDefaultEffort: false },
],
[
{
samplingParams: { reasoning_effort: 'high' },
extra_body: { reasoning_effort: 'low' },
},
{ enabled: true, effort: 'low', useDefaultEffort: false },
],
[
{
samplingParams: { reasoning_effort: 'high' },
extra_body: { reasoning_effort: null },
},
undefined,
],
[
{ model: 'gpt-6-astra', extra_body: { reasoning_effort: 'none' } },
{ enabled: true, useDefaultEffort: true },
],
[
{
model: 'gpt-5.5',
thinkingMandatory: true,
extra_body: { reasoning_effort: 'none' },
},
{ enabled: true, useDefaultEffort: true },
],
[
{
baseUrl: 'https://openrouter.ai/api/v1',
extra_body: { reasoning_effort: 'high' },
},
undefined,
],
[
{
baseUrl: 'https://openrouter.ai/api/v1',
samplingParams: { reasoning_effort: 'high' },
},
{ enabled: true, effort: 'high', useDefaultEffort: false },
],
[
{
model: 'gpt-5.4',
baseUrl: 'https://openrouter.ai/api/v1',
samplingParams: { reasoning_effort: 'high' },
extra_body: { reasoning_effort: 'low' },
},
{ enabled: true, effort: 'low', useDefaultEffort: false },
],
[
{
baseUrl: 'https://openrouter.ai/api/v1',
samplingParams: { reasoning_effort: 'none' },
},
{ enabled: false, useDefaultEffort: true },
],
[
{
model: 'gpt-6-astra',
baseUrl: 'https://openrouter.ai/api/v1',
samplingParams: { reasoning_effort: 'none' },
},
{ enabled: true, useDefaultEffort: true },
],
[
{
baseUrl: 'https://openrouter.ai/api/v1',
samplingParams: { reasoning_effort: 'high' },
extra_body: { reasoning_effort: '' },
},
undefined,
],
] as const)(
'reports effective raw precedence for %j',
(overrides, expected) => {
expect(
getGptReasoningOverrideState({
model: 'gpt-5.5',
baseUrl: 'https://api.openai.com/v1',
reasoning: { effort: 'xhigh' },
...overrides,
} as ContentGeneratorConfig),
).toEqual(expected ? { ...expected, blocksTierChange: true } : undefined);
},
);
it.each(['samplingParams', 'extra_body'] as const)(
'projects OpenRouter nested overrides from %s',
(layer) => {
for (const [model, reasoning, expected] of [
['gpt-5.5', null, { enabled: true, useDefaultEffort: true }],
['gpt-5.5', false, { enabled: false, useDefaultEffort: true }],
[
'gpt-5.5',
{ enabled: false },
{ enabled: false, useDefaultEffort: true },
],
[
'gpt-5.5',
{ effort: 'none' },
{ enabled: false, useDefaultEffort: true },
],
['gpt-5.5', {}, { enabled: true, useDefaultEffort: true }],
[
'gpt-5.4',
{ enabled: true },
{ enabled: true, useDefaultEffort: true },
],
[
'gpt-5.4',
{ max_tokens: 1024 },
{ enabled: true, useDefaultEffort: true },
],
[
'gpt-5.4',
{ effort: 'high' },
{ enabled: true, effort: 'high', useDefaultEffort: false },
],
['gpt-5.4', {}, { enabled: false, useDefaultEffort: true }],
] as const) {
expect(
getGptReasoningOverrideState({
model,
baseUrl: 'https://openrouter.ai/api/v1',
reasoning: { effort: 'xhigh' },
[layer]: { reasoning, reasoning_effort: 'none' },
} as ContentGeneratorConfig),
).toEqual({ ...expected, blocksTierChange: true });
}
},
);
it('allows configured capability injection past an OpenRouter sampling flat override', () => {
expect(
getGptReasoningOverrideState(
{
model: 'gpt-5.5',
baseUrl: 'https://openrouter.ai/api/v1',
reasoning: { effort: 'high' },
samplingParams: { reasoning_effort: 'low' },
} as ContentGeneratorConfig,
{
thinking: true,
disableField: 'reasoning_effort',
efforts: ['low', 'high'],
} as ModelReasoningConfiguration,
),
).toBeUndefined();
});
});
Loaded 100 of 358 files, more files were not shown because too many files have changed in this diff. Show more