mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(settings): turn cross-session messaging on by default (#11840)
* feat(settings): turn cross-session messaging on by default The switch has been opt-in since the feature landed, which was the right posture while the pieces that make a reachable session safe were still arriving one pull request at a time: authenticated inboxes, receipts and held-message expiry, review-class parity with tighten-only repository settings, trusted controller tokens, admission control, the registry contract, daemon-managed sessions and the SDK peer endpoint. With all of that in place, off by default no longer protects anything. It only means a voice front-end joining through the SDK cannot see a session whose user never found the setting, and two sessions in one repository cannot tell each other a build finished. Flip the schema default to true. Merged settings carry only what some scope actually wrote, so the schema default never reached the readers of this key: every one of them saw `undefined` and compared it to `true`. The four readers now ask one helper, which answers on for `true` and for an unset key, and off for `false` and for anything it does not recognize — a value the reader cannot interpret must not open a socket. The workspace tighten-only ranking follows: unset now ranks with `true`, so a repository's `false` is a tightening and is kept, and a workspace `true` repeats the default and is dropped without a warning. The words change with the default. `/peers` on a session with no inbox distinguishes "turned off in settings" from "the inbox could not start" instead of telling the user to enable a setting that is already on. On a platform without an inbox transport, the startup failure line is shown only to a user who turned the switch on by hand; a bind that failed where it should have worked is still reported to everyone, because that session is unreachable and this line is the only place its user learns it. The controller-token hint, the send_message error, the settings reference, the SDK guides and the messaging section of the commands page now say what turning the feature off looks like. Suites that stage a session through partial mocks turn the switch off in their settings, or complete the config mock where registration now runs; the daemon-session and interactive-startup suites are where unset-means-on is exercised. Part of #10925 * test(cli): turn messaging off in suites that stage startup or ACP sessions through partial mocks Two suites the default flip reaches that the first commit missed. The entry-point startup test counts exit-cleanup registrations, and with messaging on the inbox arms a third. The ACP worktree-restore suite loads sessions through an exhaustive core mock, and with messaging on each loaded session registers itself through calls that mock does not stage. Neither suite is about messaging, so both say off in their settings; the on-by-default path is exercised in startInteractiveUI.test and the daemon-session suite of acpAgent.test. * refactor(cli): name the opted-in-by-hand check next to the enabled helper The startup failure notice asks two questions three lines apart: whether messaging is on, which the shared helper answers and an unset key answers yes, and whether the user wrote `true` themselves, which decides whether an unsupported platform is worth mentioning. The second read the raw setting inline, next to a helper documented as the one place the first question is answered, and looked like a missed migration. Bind it to a name so the two questions read as two. * fix(cli): address review on the default-on switch: scope-aware notices, no futile rebinds, honest docs Review round one on the default flip. Thirteen suggestions, all taken. Two readers now ask a question merged settings cannot answer, so both move next to `isCrossSessionMessagingEnabled` in peerMessaging/enabled.ts: - Whether a person opted in. The startup notice for a platform with no inbox transport is said only to someone who wrote `true`; it read the merged value, which also carries an operator's system-defaults file, so a fleet default counted as a hand opt-in. It now reads the user scope, and the workspace scope when it is trusted and active. - Which scope turned messaging off. `/peers` told everyone their settings said `false` and to set it to true. Anything but `true` or unset reads as off, so it no longer states the value, and it names the scope with a remedy that works there: a workspace `false` outranks a user `true` (a workspace may only tighten), System overrides everything, and a user `true` does override system defaults. `/peers` on a platform with no inbox transport now says so as information. It was error-styled and repeated the failure's advice to disable a setting most of those users never enabled. An ACP process no longer retries the bind for every hosted session after `unsupported_platform`. That refusal holds for every candidate path, so the settled null is kept; every other cause still retries. The design doc, in both languages, names the two senders delivered without review that the default now reaches: a process the session starts, which inherits the child token, and a same-user process that claims the receiving session's review class, which nothing authenticates. It records why they are documented rather than gated. The setting's description, the settings reference and the VS Code schema carry the same clause, and the Windows paragraph says what changes there. `commands.md` no longer promises a warning for a workspace `true` that merely repeats the default. Tests pin what was unpinned: a daemon-hosted session with the key unset registers; one bind attempt under `unsupported_platform` and a retry under any other cause; the warning when a workspace `true` would reopen a switch the user turned off; the operator-default startup case; the scoped off messages. Negative assertions that stopped being able to fail are repointed, and stale "off by default" test names and comments are gone.
This commit is contained in:
1 parent
26fba7cd45
commit
f2f10e7b39
25 files changed
+887
-84
No files matched your search
@@ -0,0 +1,111 @@
|
||||
# Cross-session messaging: on by default
|
||||
|
||||
[English](2026-09-14-cross-session-messaging-default-on.md) | [简体中文](2026-09-14-cross-session-messaging-default-on.zh-CN.md)
|
||||
|
||||
Status: implemented alongside this document.
|
||||
|
||||
## Problem
|
||||
|
||||
`agents.crossSessionMessaging` has been opt-in since the feature landed.
|
||||
Every session started with it off: invisible to the other sessions of
|
||||
the same user, unreachable by them, and unable to address them. That was
|
||||
the right posture while the pieces that make a reachable session safe
|
||||
were still arriving, one pull request at a time.
|
||||
|
||||
They have arrived. An inbox authenticates every connection; a message
|
||||
from another session is delivered only when both sessions are in the
|
||||
same review class and is held for the user otherwise; a repository can
|
||||
make a session more cautious but never less; a sender that outruns the
|
||||
session is dropped and told; a program the user trusts presents a token
|
||||
the user minted. With all of that in place, the switch being off by
|
||||
default no longer protects anything — it only means the feature is not
|
||||
there for the people it was built for. A voice front-end that joins
|
||||
through the SDK's peer endpoint cannot see a session whose user never
|
||||
found the setting, and two sessions in one repository cannot tell each
|
||||
other a build finished.
|
||||
|
||||
## Design
|
||||
|
||||
**The schema default becomes `true`.** Nothing else about the setting
|
||||
changes: `false` still turns the feature off for that session, restart is
|
||||
still required, and the workspace may still only tighten.
|
||||
|
||||
**An unset key is read as on, in one place.** Merged settings carry only
|
||||
what some scope actually wrote, so the schema default never reaches the
|
||||
code as a value — every reader saw `undefined` and compared it to `true`.
|
||||
The four readers (interactive startup, the ACP agent, `/peers`, and the
|
||||
inbox-failure notice) now ask one helper, which answers on for `true` and
|
||||
for `undefined`, and off for `false` and for anything it does not
|
||||
recognize. The last case is deliberate: a value the reader cannot
|
||||
interpret must not open a socket.
|
||||
|
||||
**The workspace ranking follows.** A workspace value is kept only when it
|
||||
is stricter than what the user or the platform set, and "what they set"
|
||||
was compared as if unset meant off. With unset meaning on, a repository's
|
||||
`false` would have ranked equal to an unset user scope and been dropped
|
||||
— a repository could never turn messaging off. Unset now ranks with
|
||||
`true`, so `false` is a tightening and is kept; a workspace `true` repeats
|
||||
the default and is dropped without a warning.
|
||||
|
||||
**The words change with the default.** `/peers` on a session with no inbox
|
||||
no longer tells the user to enable a setting that is already on; it
|
||||
distinguishes "turned off in settings" from "the inbox could not start".
|
||||
The controller-token hint, the `send_message` error, the settings
|
||||
reference, the SDK guides and the messaging section of the commands page
|
||||
say what turning the feature _off_ looks like instead of how to turn it
|
||||
on.
|
||||
|
||||
## Trade-offs
|
||||
|
||||
**No first-run notice.** A line in the transcript saying "other sessions
|
||||
can now see this one" would need a per-home marker file to show once, and
|
||||
would repeat for every scoped home. The documentation and `/peers` carry
|
||||
the explanation; the review rules hold what another session sends for the
|
||||
user to decide, with the two exceptions below.
|
||||
|
||||
**Two senders are delivered without review, and the default now reaches
|
||||
them.** A process this session starts inherits its child token and is
|
||||
delivered as the session's own: that is what lets a hook or a build script
|
||||
report back, and it is documented as such. Review-class parity compares a
|
||||
class the sender asserts, and nothing authenticates that assertion, so a
|
||||
same-user process that can read the registry can claim the receiving
|
||||
session's own class. Both were already true for anyone who turned
|
||||
messaging on; on by default extends them to users who never looked at the
|
||||
setting. Neither widens the boundary — both need code already running as
|
||||
the same user inside the `0700`/`0600` directories, and such code can do
|
||||
anything that user can — so they are recorded here, and in the setting's
|
||||
description, rather than gated. Gating the child-token export on an
|
||||
explicit opt-in would take a documented hook pattern away from default
|
||||
users. A user who wants every such message reviewed sets
|
||||
`agents.crossSessionInbound` to `hold`.
|
||||
|
||||
**Windows gets no inbox, and says so only when asked.** Automatic inbox
|
||||
paths are not available there, and the inbox degrades to "unsupported
|
||||
platform". Startup stays quiet unless someone wrote `true` in their user
|
||||
settings or this workspace's — an operator's system-defaults file does not
|
||||
count. `/peers` answers that messaging is not available on this platform,
|
||||
as information rather than an error. An ACP process stops trying to bind
|
||||
after its first hosted session learns this, instead of retrying for every
|
||||
session. Nothing else changes for those users until named pipes land.
|
||||
|
||||
**An unrecognized value is off, not on.** The previous readers treated
|
||||
anything but `true` as off, and the workspace ranking already counted an
|
||||
unrecognized value as the strict one. Keeping that for the new default
|
||||
means a typo cannot open a session by accident.
|
||||
|
||||
## Files
|
||||
|
||||
- `packages/cli/src/peerMessaging/enabled.ts` — the one reader of the
|
||||
switch.
|
||||
- `packages/cli/src/config/settingsSchema.ts` — the default and its
|
||||
description; the generated JSON schema follows.
|
||||
- `packages/cli/src/config/settingsUtils.ts` — unset ranks as on.
|
||||
- `packages/cli/src/ui/startInteractiveUI.tsx`,
|
||||
`packages/cli/src/acp-integration/acpAgent.ts`,
|
||||
`packages/cli/src/ui/commands/peers-command.ts` — read through the
|
||||
helper.
|
||||
- `packages/cli/src/commands/sessions/controllers.ts`,
|
||||
`packages/core/src/tools/send-message.ts` — wording.
|
||||
- `docs/users/features/commands.md`, `docs/users/configuration/settings.md`,
|
||||
`docs/developers/sdk-typescript.md`, `packages/sdk-typescript/README.md`
|
||||
— the default, and how to turn it off.
|
||||
@@ -0,0 +1,40 @@
|
||||
# 跨会话消息:默认开启
|
||||
|
||||
[English](2026-09-14-cross-session-messaging-default-on.md) | [简体中文](2026-09-14-cross-session-messaging-default-on.zh-CN.md)
|
||||
|
||||
状态:随本文档一起实现。
|
||||
|
||||
## 问题
|
||||
|
||||
`agents.crossSessionMessaging` 自落地以来一直是 opt-in。每个会话启动时都是关着的:同一用户的其它会话看不见它、联系不到它,它也联系不到别人。在让"可达的会话"变得安全的那些零件还在一个个 PR 地到来时,这是正确的姿态。
|
||||
|
||||
现在它们都到了。inbox 对每个连接做认证;来自另一个会话的消息只在两个会话处于同一审查类别时投递,否则 hold 给用户;仓库可以让会话更谨慎,但不能更宽松;发得比会话能接的快的发送方会被丢弃并被告知;用户信任的程序出示用户亲手铸造的令牌。这些都到位之后,默认关闭不再保护任何东西——它只意味着这个功能对它本来要服务的人不存在。通过 SDK peer 端点接入的语音前端看不到一个用户从没找到这个设置的会话;同一仓库里的两个会话也没法互相说一句"构建完成了"。
|
||||
|
||||
## 设计
|
||||
|
||||
**schema 默认值改为 `true`。** 这个设置的其它方面不变:`false` 仍然关闭该会话的功能,仍然需要重启,工作区仍然只能收紧。
|
||||
|
||||
**未设置的键读作开,只在一处。** 合并后的设置只包含某个作用域确实写了的内容,所以 schema 默认值从不会以值的形式到达代码——每个读取点看到的都是 `undefined`,然后拿它和 `true` 比较。四个读取点(交互式启动、ACP agent、`/peers`、inbox 失败提示)现在都问同一个辅助函数:`true` 和 `undefined` 答开,`false` 和任何不认识的值答关。最后一条是刻意的:读取方无法解释的值不能打开 socket。
|
||||
|
||||
**工作区排名跟着变。** 工作区的值只在严于用户或平台所设时才保留,而"他们所设"此前是按"未设置即关"来比较的。未设置改为开之后,仓库的 `false` 会和未设置的用户作用域同级而被丢弃——仓库将永远无法关闭消息。现在未设置与 `true` 同级,所以 `false` 是收紧、会保留;工作区的 `true` 只是重复默认值,无警告地丢弃。
|
||||
|
||||
**措辞随默认值改。** `/peers` 在没有 inbox 的会话上不再让用户去启用一个已经开着的设置,而是区分"在设置里关掉了"和"inbox 没能启动"。controller 令牌的提示、`send_message` 的错误、设置参考、SDK 指南和 commands 页的消息章节都改成说明"关掉"是什么样子,而不是怎么打开。
|
||||
|
||||
## 取舍
|
||||
|
||||
**不做首次提示。** 转录里一行"其它会话现在能看到本会话"需要按 home 放标记文件才能只显示一次,而且每个 scoped home 都会再来一次。文档和 `/peers` 承担解释;审查规则会把其它会话发来的内容 hold 给用户决定,下面两种情况除外。
|
||||
|
||||
**有两类发送方不经审查就投递,默认开启后它们覆盖到了所有人。** 本会话启动的进程继承它的子进程令牌,会被当作本会话自己的消息投递:hook 或构建脚本能回报结果靠的就是这一点,文档里也写明了。审查类别 parity 比较的是发送方自己声明的类别,而这个声明没有任何认证,所以能读取注册表的同用户进程可以声称自己和接收会话同类。这两点对手动开启过的用户早就成立;默认开启把它们扩展到了从没看过这个设置的用户。两者都没有扩大边界——都需要已经以同一用户身份在 `0700`/`0600` 目录内运行的代码,而这样的代码本来就能做该用户能做的任何事——所以在这里和设置描述里记录下来,而不是加闸。把子进程令牌的导出限制为显式开启,会让默认用户失去一种文档里写明的 hook 用法。想让这类消息全部经过审查的用户,把 `agents.crossSessionInbound` 设为 `hold`。
|
||||
|
||||
**Windows 没有 inbox,只在被问到时才说明。** 那里没有自动 inbox 路径,inbox 降级为"平台不支持"。除非有人在自己的用户设置或本工作区设置里写了 `true`,否则启动时不提示——运维方的 system-defaults 文件不算。`/peers` 以普通信息而非错误的形式回答"本平台不支持跨会话消息"。ACP 进程在第一个托管会话得知这一点后就不再尝试绑定,不会每个会话都重试一遍。在命名管道落地之前,这些用户没有其它变化。
|
||||
|
||||
**不认识的值是关,不是开。** 此前的读取点把 `true` 之外的一切当作关,工作区排名也已经把不认识的值算作严格的一方。新默认值下保持这一点,意味着一个笔误不会意外打开一个会话。
|
||||
|
||||
## 文件
|
||||
|
||||
- `packages/cli/src/peerMessaging/enabled.ts` — 开关的唯一读取方。
|
||||
- `packages/cli/src/config/settingsSchema.ts` — 默认值及描述;生成的 JSON schema 随之更新。
|
||||
- `packages/cli/src/config/settingsUtils.ts` — 未设置与开同级。
|
||||
- `packages/cli/src/ui/startInteractiveUI.tsx`、`packages/cli/src/acp-integration/acpAgent.ts`、`packages/cli/src/ui/commands/peers-command.ts` — 通过辅助函数读取。
|
||||
- `packages/cli/src/commands/sessions/controllers.ts`、`packages/core/src/tools/send-message.ts` — 措辞。
|
||||
- `docs/users/features/commands.md`、`docs/users/configuration/settings.md`、`docs/developers/sdk-typescript.md`、`packages/sdk-typescript/README.md` — 默认值,以及如何关闭。
|
||||
@@ -239,9 +239,9 @@ await endpoint.send({
|
||||
Things to know:
|
||||
|
||||
- A session has an inbox only while its `agents.crossSessionMessaging` setting
|
||||
is on, and the setting is off by default. Without it the session does not
|
||||
appear in `list()`, and its own `list_agents` and `send_message` cannot see
|
||||
or reach the program either. `qwen sessions ps` lists the program regardless.
|
||||
is on, which it is by default. A session that turned it off does not appear
|
||||
in `list()`, and its own `list_agents` and `send_message` cannot see or
|
||||
reach the program either. `qwen sessions ps` lists the program regardless.
|
||||
- A message is delivered without review in exactly two cases: the send
|
||||
presents a controller token (`controller: true`), or its `fromMode` names the
|
||||
receiving session's own review class. `fromMode` is a claim nothing
|
||||
|
||||
@@ -418,13 +418,13 @@ See [Memory](../features/memory) for details on how auto-memory works and how to
|
||||
|
||||
#### agents
|
||||
|
||||
| Setting | Type | Description | Default |
|
||||
| ------------------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
|
||||
| `agents.builtin.exploreModel` | string | Model selector for the built-in Explore subagent. Use `inherit` for the main session model, `fast` for `fastModel`, a model ID, or an `authType:model-id` selector. A custom same-name Explore agent keeps its own model configuration. Requires restart. | `inherit` |
|
||||
| `agents.modelGrades` | object | Maps semantic grade names exposed to the Agent tool to model selectors. Requires restart. | `undefined` |
|
||||
| `agents.allowedGrades` | array of strings | Optional whitelist of configured model grades the Agent tool may use. Requires restart. | `undefined` |
|
||||
| `agents.crossSessionMessaging` | boolean | Experimental. Let Qwen Code sessions on this machine send each other messages over a per-session local socket. Turning it on opens this session to peer messages, makes it discoverable to others, and lets its model address them from `send_message`. Requires restart. A workspace may set this to `false` only; a workspace `true` is ignored, with a warning only when it would loosen the operator-set or default value. | `false` |
|
||||
| `agents.crossSessionInbound` | enum | What happens to inbound cross-session messages: `accept` delivers them, `hold` parks them for `/peers` review without letting the model act, and `refuse` opts this session out. Unset means [user-minted controllers](../features/commands.md#trusted-controllers) and this session's own child processes auto-deliver, while other sessions use [review-class parity](../features/commands.md#6-messaging-another-running-session); other messages are held for review. A workspace may only tighten this (`hold` or `refuse`, when stricter than the operator-set value or the unset default); an effective unrecognized value holds every message. | `undefined` |
|
||||
| Setting | Type | Description | Default |
|
||||
| ------------------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| `agents.builtin.exploreModel` | string | Model selector for the built-in Explore subagent. Use `inherit` for the main session model, `fast` for `fastModel`, a model ID, or an `authType:model-id` selector. A custom same-name Explore agent keeps its own model configuration. Requires restart. | `inherit` |
|
||||
| `agents.modelGrades` | object | Maps semantic grade names exposed to the Agent tool to model selectors. Requires restart. | `undefined` |
|
||||
| `agents.allowedGrades` | array of strings | Optional whitelist of configured model grades the Agent tool may use. Requires restart. | `undefined` |
|
||||
| `agents.crossSessionMessaging` | boolean | Let Qwen Code sessions on this machine send each other messages over a per-session local socket. On by default: this session is discoverable by the others, takes peer messages under the review rules of `agents.crossSessionInbound`, and its model can address them from `send_message`. Two senders are delivered without review unless `agents.crossSessionInbound` is `hold` or `refuse`: processes this session starts, which inherit its child token, and a same-user process that claims this session's own review class, which nothing authenticates. Set to `false` to keep this session invisible and unreachable. Requires restart. A workspace may set this to `false` only; a workspace `true` is ignored, with a warning only when it would loosen an operator-set `false`. | `true` |
|
||||
| `agents.crossSessionInbound` | enum | What happens to inbound cross-session messages: `accept` delivers them, `hold` parks them for `/peers` review without letting the model act, and `refuse` opts this session out. Unset means [user-minted controllers](../features/commands.md#trusted-controllers) and this session's own child processes auto-deliver, while other sessions use [review-class parity](../features/commands.md#6-messaging-another-running-session); other messages are held for review. A workspace may only tighten this (`hold` or `refuse`, when stricter than the operator-set value or the unset default); an effective unrecognized value holds every message. | `undefined` |
|
||||
|
||||
#### permissions
|
||||
|
||||
|
||||
@@ -821,14 +821,17 @@ qwen sessions ps --json | jq -r .cwd
|
||||
## 6. Messaging Another Running Session
|
||||
|
||||
Two interactive sessions on the same machine can send each other
|
||||
messages. The feature is experimental and **off by default**; turn it on
|
||||
in `settings.json` and restart:
|
||||
messages. The feature is **on by default**: a session is discoverable by
|
||||
the other sessions of the same user on the machine, and what they send
|
||||
it goes through the review rules below before its model sees any of it.
|
||||
To keep a session invisible and unreachable, turn it off in
|
||||
`settings.json` and restart:
|
||||
|
||||
```json
|
||||
{ "agents": { "crossSessionMessaging": true } }
|
||||
{ "agents": { "crossSessionMessaging": false } }
|
||||
```
|
||||
|
||||
Once on, the model in one session can discover the others with
|
||||
With it on, the model in one session can discover the others with
|
||||
`list_agents` — each appears under `sessions` with the `name` that
|
||||
`qwen sessions ps --json` records (the table view may truncate long
|
||||
names) — and address one with `send_message` using
|
||||
@@ -860,8 +863,10 @@ A repository can make sessions opened in it more cautious, never less:
|
||||
a workspace `.qwen/settings.json` may set `agents.crossSessionInbound`
|
||||
to `hold` or `refuse`, or `agents.crossSessionMessaging` to `false`, and
|
||||
that value wins over a looser one in your user settings. A workspace
|
||||
value that would loosen your setting (`accept`, or `true` for the
|
||||
switch) is ignored with a warning, and a value the CLI does not
|
||||
value that would loosen an operator-set one (`accept`, or `true` for the
|
||||
switch when a user or system scope set it to `false`) is ignored with a
|
||||
warning; a workspace `true` where nothing else set the switch merely
|
||||
repeats the default and is dropped silently. A value the CLI does not
|
||||
recognize holds every message whenever it is the effective value.
|
||||
System settings override all of this, as they do for every setting.
|
||||
|
||||
@@ -1051,8 +1056,8 @@ driven session's behalf; a sender is told at once instead of
|
||||
waiting out an expiry. Where a held message should surface for those
|
||||
sessions is not settled yet.
|
||||
|
||||
A session registers only while its own settings have
|
||||
`agents.crossSessionMessaging` on. With it off it stays invisible,
|
||||
A session registers unless its own settings turn
|
||||
`agents.crossSessionMessaging` off. Turned off, it stays invisible,
|
||||
because the only reason to list a session nobody can message would be to
|
||||
advertise an address that never answers.
|
||||
|
||||
|
||||
@@ -49,6 +49,9 @@ const mockRegisterSession = vi.hoisted(() => {
|
||||
});
|
||||
});
|
||||
const mockPeerMessagingStart = vi.hoisted(() => vi.fn());
|
||||
const mockGetLastPeerInboxFailure = vi.hoisted(() =>
|
||||
vi.fn((): { cause: string } | null => null),
|
||||
);
|
||||
vi.mock('../peerMessaging/peer-messaging.js', () => ({
|
||||
PeerMessaging: {
|
||||
start: (...args: unknown[]) => mockPeerMessagingStart(...args),
|
||||
@@ -259,6 +262,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({
|
||||
},
|
||||
INVOCATION_CONTEXT_META_KEY: 'qwen-code/invocation',
|
||||
registerSession: mockRegisterSession,
|
||||
getLastPeerInboxFailure: () => mockGetLastPeerInboxFailure(),
|
||||
PRIVATE_ACP_CAPABILITY_ENV: 'QWEN_CODE_PRIVATE_ACP_CAPABILITY',
|
||||
PRIVATE_PARENT_CAPABILITY_META_KEY: 'qwen-code/private-parent-capability',
|
||||
parseInvocationContext: vi.fn(
|
||||
@@ -4931,8 +4935,15 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Messaging is on by default, and on it binds an inbox and registers
|
||||
// every new session through mocks most suites here do not stage. The
|
||||
// default settings turn it off; the daemon-session suite says on or off
|
||||
// explicitly, which is the only place the switch is under test.
|
||||
function makeSessionSettings(
|
||||
merged: Record<string, unknown> = { mcpServers: {} },
|
||||
merged: Record<string, unknown> = {
|
||||
mcpServers: {},
|
||||
agents: { crossSessionMessaging: false },
|
||||
},
|
||||
) {
|
||||
return {
|
||||
merged,
|
||||
@@ -5201,7 +5212,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
||||
}
|
||||
|
||||
describe('daemon-managed sessions in the session registry', () => {
|
||||
/** Settings with cross-session messaging on, which is off by default. */
|
||||
/** Settings with cross-session messaging explicitly on. */
|
||||
function messagingOn() {
|
||||
return makeSessionSettings({
|
||||
mcpServers: {},
|
||||
@@ -5209,12 +5220,34 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Off has to be said: the switch is on by default, so settings that
|
||||
// never mention the key are the *on* case.
|
||||
function messagingOff() {
|
||||
return makeSessionSettings({
|
||||
mcpServers: {},
|
||||
agents: { crossSessionMessaging: false },
|
||||
});
|
||||
}
|
||||
|
||||
// Settings that never mention the switch: the on-by-default case.
|
||||
function messagingUnset() {
|
||||
return makeSessionSettings({ mcpServers: {} });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockRegisterSession.mockClear();
|
||||
mockPeerMessagingStart.mockReset();
|
||||
mockPeerMessagingStart.mockResolvedValue({
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
mockGetLastPeerInboxFailure.mockReset();
|
||||
mockGetLastPeerInboxFailure.mockReturnValue(null);
|
||||
});
|
||||
|
||||
// Messaging is on by default, so suites after this one bind an inbox
|
||||
// too; hand them back the bare mock rather than this suite's fake.
|
||||
afterEach(() => {
|
||||
mockPeerMessagingStart.mockReset();
|
||||
});
|
||||
|
||||
it('gives each hosted session a record of its own, and the shared inbox address', async () => {
|
||||
@@ -5291,9 +5324,9 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
||||
// A record with no inbox behind it would advertise an address that
|
||||
// never answers, which is worse than not being listed at all.
|
||||
await setupSessionMocks('hosted-off');
|
||||
const { agent, agentPromise } = await bootInitializedAcpAgent(
|
||||
makeSessionSettings(),
|
||||
);
|
||||
vi.mocked(loadSettings).mockReturnValue(messagingOff());
|
||||
const { agent, agentPromise } =
|
||||
await bootInitializedAcpAgent(messagingOff());
|
||||
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
|
||||
|
||||
expect(mockPeerMessagingStart).not.toHaveBeenCalled();
|
||||
@@ -5353,6 +5386,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
||||
// contract the docs state is about the session, and a session that
|
||||
// turned messaging off must not appear in any peer's listing.
|
||||
await setupSessionMocks('hosted-off');
|
||||
vi.mocked(loadSettings).mockReturnValue(messagingOff());
|
||||
const { agent, agentPromise } =
|
||||
await bootInitializedAcpAgent(messagingOn());
|
||||
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
|
||||
@@ -5384,8 +5418,60 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it('registers a hosted session whose own settings never mention the switch', async () => {
|
||||
// On by default: an explicit true and false read the same through
|
||||
// the old comparison and the helper alike, so only an unset key
|
||||
// pins that a daemon-hosted session joins without a settings edit.
|
||||
// The gate reads the session's settings, so the key is left out of
|
||||
// those too, not only the process's boot settings.
|
||||
await setupSessionMocks('hosted-unset');
|
||||
vi.mocked(loadSettings).mockReturnValue(messagingUnset());
|
||||
const { agent, agentPromise } =
|
||||
await bootInitializedAcpAgent(messagingUnset());
|
||||
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
|
||||
|
||||
await vi.waitFor(() => expect(mockPeerMessagingStart).toHaveBeenCalled());
|
||||
expect(mockRegisterSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionId: 'hosted-unset', slot: 'own' }),
|
||||
);
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it('does not retry the bind on a platform with no inbox transport', async () => {
|
||||
// That refusal holds for every candidate path, so a second hosted
|
||||
// session could never succeed where the first did not.
|
||||
mockPeerMessagingStart.mockResolvedValue(null);
|
||||
mockGetLastPeerInboxFailure.mockReturnValue({
|
||||
cause: 'unsupported_platform',
|
||||
});
|
||||
await setupSessionMocks('hosted-platform-a');
|
||||
vi.mocked(loadSettings).mockReturnValue(messagingUnset());
|
||||
const { agent, agentPromise } =
|
||||
await bootInitializedAcpAgent(messagingUnset());
|
||||
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
|
||||
await vi.waitFor(() =>
|
||||
expect(mockPeerMessagingStart).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
// Let the settled null land before the next session asks.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
await setupSessionMocks('hosted-platform-b');
|
||||
vi.mocked(loadSettings).mockReturnValue(messagingUnset());
|
||||
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(mockPeerMessagingStart).toHaveBeenCalledTimes(1);
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it('retries the inbox bind for the next session after a failed start', async () => {
|
||||
mockPeerMessagingStart.mockResolvedValue(null);
|
||||
// Any cause but a missing transport is worth a retry: a runtime
|
||||
// directory can be fixed, or free up, while the process runs.
|
||||
mockGetLastPeerInboxFailure.mockReturnValue({ cause: 'permission' });
|
||||
await setupSessionMocks('hosted-retry-a');
|
||||
vi.mocked(loadSettings).mockReturnValue(messagingOn());
|
||||
const { agent, agentPromise } =
|
||||
@@ -23826,7 +23912,10 @@ describe('QwenAgent session-management routing (rename / delete / list / branch
|
||||
|
||||
function makeAcpSettings() {
|
||||
return {
|
||||
merged: { mcpServers: {} },
|
||||
// Messaging is on by default, and on it registers every new session
|
||||
// through a config these tests do not stage; this suite is about
|
||||
// session routing, so it turns the switch off.
|
||||
merged: { mcpServers: {}, agents: { crossSessionMessaging: false } },
|
||||
getUserHooks: vi.fn().mockReturnValue({}),
|
||||
getProjectHooks: vi.fn().mockReturnValue({}),
|
||||
} as unknown as LoadedSettings;
|
||||
@@ -30029,6 +30118,11 @@ describe('sessionLanguage multi-session propagation', () => {
|
||||
getModes: vi.fn().mockReturnValue([]),
|
||||
getApprovalMode: vi.fn().mockReturnValue('default'),
|
||||
getSessionId: vi.fn().mockReturnValue('sid'),
|
||||
// Messaging is on by default, so every published session registers
|
||||
// itself through these; the registry itself is mocked at module level.
|
||||
getCliVersion: vi.fn().mockReturnValue('9.9.9'),
|
||||
trackSessionRegistration: vi.fn(),
|
||||
updateSessionRegistryIpcPath: vi.fn().mockResolvedValue(undefined),
|
||||
getAuthType: vi.fn().mockReturnValue('api-key'),
|
||||
getAllConfiguredModels: vi.fn().mockReturnValue([]),
|
||||
getLlmClient: vi.fn().mockReturnValue({
|
||||
|
||||
@@ -152,6 +152,7 @@ import {
|
||||
qualifySkillName,
|
||||
sessionIdContext,
|
||||
registerSession,
|
||||
getLastPeerInboxFailure,
|
||||
SessionSourceService,
|
||||
SessionSourceError,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
@@ -338,6 +339,7 @@ import { ACP_ERROR_CODES } from './errorCodes.js';
|
||||
import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
|
||||
import { QWEN_CODE_SERVE_ENV } from '../config/acp-channel-fallback.js';
|
||||
import { PeerMessaging } from '../peerMessaging/peer-messaging.js';
|
||||
import { isCrossSessionMessagingEnabled } from '../peerMessaging/enabled.js';
|
||||
import { startNonInteractiveOpenAILogHousekeeping } from '../services/housekeeping/scheduler.js';
|
||||
import { appEvents, AppEvent } from '../utils/events.js';
|
||||
import {
|
||||
@@ -4717,7 +4719,16 @@ class QwenAgent implements Agent {
|
||||
});
|
||||
// A bind that could not start is not "started": the next hosted
|
||||
// session retries rather than the process staying dark until exit.
|
||||
if (messaging === null) this.peerMessagingStart = null;
|
||||
// Except for a platform with no inbox transport. That refusal is
|
||||
// decided before any filesystem call and holds for every candidate
|
||||
// path, so no later session in this process can succeed; keep the
|
||||
// settled null so the attempt, and its log line, happen once.
|
||||
if (
|
||||
messaging === null &&
|
||||
getLastPeerInboxFailure()?.cause !== 'unsupported_platform'
|
||||
) {
|
||||
this.peerMessagingStart = null;
|
||||
}
|
||||
return messaging;
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
@@ -4767,7 +4778,7 @@ class QwenAgent implements Agent {
|
||||
// from more than one workspace, and a record exists to be addressed,
|
||||
// so it is written only when that session's settings turn messaging
|
||||
// on. The process's startup settings answer for nobody else.
|
||||
if (settings.merged.agents?.crossSessionMessaging !== true) return;
|
||||
if (!isCrossSessionMessagingEnabled(settings.merged)) return;
|
||||
// Bound by the first session that needs it rather than at startup: an
|
||||
// ACP process with no session has nothing to advertise and nobody to
|
||||
// receive for, and this is also the first moment the agent exists.
|
||||
|
||||
@@ -108,8 +108,8 @@ const { mockRestoreWorktreeContext, mockWithDaemonSpan } = vi.hoisted(() => {
|
||||
});
|
||||
|
||||
// The agent imports the peer-messaging transport statically; its own core
|
||||
// imports would reach past this suite's exhaustive core mock, and nothing
|
||||
// here turns messaging on.
|
||||
// imports would reach past this suite's exhaustive core mock. Messaging is
|
||||
// on by default, so the session settings below turn it off.
|
||||
vi.mock('../peerMessaging/peer-messaging.js', () => ({
|
||||
PeerMessaging: { start: vi.fn() },
|
||||
}));
|
||||
@@ -430,7 +430,10 @@ describe('QwenAgent loadSession — Phase C worktree context restore', () => {
|
||||
|
||||
function makeSessionSettings() {
|
||||
return {
|
||||
merged: { mcpServers: {} },
|
||||
// Messaging is on by default, and on it registers every loaded
|
||||
// session through a config this suite does not stage. The suite is
|
||||
// about worktree restore, so it turns the switch off.
|
||||
merged: { mcpServers: {}, agents: { crossSessionMessaging: false } },
|
||||
getUserHooks: vi.fn().mockReturnValue({}),
|
||||
getProjectHooks: vi.fn().mockReturnValue({}),
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
@@ -103,8 +103,8 @@ describe('controllers add', () => {
|
||||
addPeerController.mockResolvedValue({ record: record(), token: TOKEN });
|
||||
await handleAdd({ label: 'voice bridge' });
|
||||
const out = stdout.join('\n');
|
||||
expect(out).toContain('agents.crossSessionMessaging enabled');
|
||||
expect(out).toContain('restarted after enabling it');
|
||||
expect(out).toContain('agents.crossSessionMessaging turned off');
|
||||
expect(out).toContain('restarted after changing that setting');
|
||||
expect(out).toContain('without per-message review');
|
||||
expect(out).toContain('agents.crossSessionInbound');
|
||||
});
|
||||
|
||||
@@ -114,7 +114,7 @@ async function handleAdd(argv: AddArgs): Promise<void> {
|
||||
);
|
||||
writeStdoutLine(` {"msgV":1,"type":"auth","token":"${token}"}`);
|
||||
writeStdoutLine(
|
||||
'The target session must have agents.crossSessionMessaging enabled and be restarted after enabling it.',
|
||||
'The target session must not have agents.crossSessionMessaging turned off (it is on by default), and must be restarted after changing that setting.',
|
||||
);
|
||||
writeStdoutLine(
|
||||
'Messages it sends are delivered without per-message review, unless agents.crossSessionInbound is "hold" or "refuse".',
|
||||
|
||||
@@ -3853,15 +3853,17 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(settings.merged.agents?.maxParallelAgents).toBe(4);
|
||||
|
||||
const warnings = getSettingsWarnings(settings);
|
||||
for (const key of [
|
||||
'agents.crossSessionMessaging',
|
||||
'agents.crossSessionInbound',
|
||||
]) {
|
||||
const warning = warnings.find((w) => w.includes(key));
|
||||
expect(warning).toBeDefined();
|
||||
expect(warning).toContain('would loosen the default value');
|
||||
expect(warning).toContain('only make this setting stricter');
|
||||
}
|
||||
const inboundWarning = warnings.find((w) =>
|
||||
w.includes('agents.crossSessionInbound'),
|
||||
);
|
||||
expect(inboundWarning).toBeDefined();
|
||||
expect(inboundWarning).toContain('would loosen the default value');
|
||||
expect(inboundWarning).toContain('only make this setting stricter');
|
||||
// The switch is on by default, so a workspace `true` only repeats
|
||||
// what is already in force: dropped, but nothing to warn about.
|
||||
expect(
|
||||
warnings.some((w) => w.includes('agents.crossSessionMessaging')),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('honors a workspace value that tightens the user value', () => {
|
||||
@@ -3934,7 +3936,9 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.agents?.crossSessionInbound).toBe('hold');
|
||||
expect(settings.merged.agents?.crossSessionMessaging).toBeUndefined();
|
||||
// The switch defaults to on, so a workspace `false` is a tightening
|
||||
// against an unset user scope and is kept — also without a warning.
|
||||
expect(settings.merged.agents?.crossSessionMessaging).toBe(false);
|
||||
expect(
|
||||
getSettingsWarnings(settings).some((w) => w.includes('crossSession')),
|
||||
).toBe(false);
|
||||
@@ -3962,6 +3966,31 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(warning).toContain('would loosen the User value');
|
||||
});
|
||||
|
||||
it('warns when a workspace true would reopen a switch the user turned off', () => {
|
||||
// The one warning path left for this key: an operator scope's false
|
||||
// outranks a workspace true. An unset operator scope would not reach
|
||||
// it — a workspace true then repeats the default and drops silently.
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify({
|
||||
agents: { crossSessionMessaging: false },
|
||||
});
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
return JSON.stringify({ agents: { crossSessionMessaging: true } });
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.agents?.crossSessionMessaging).toBe(false);
|
||||
const warning = getSettingsWarnings(settings).find((w) =>
|
||||
w.includes('agents.crossSessionMessaging'),
|
||||
);
|
||||
expect(warning).toContain('would loosen the User value');
|
||||
});
|
||||
|
||||
it('lets System scope override a stricter workspace value, with a warning', () => {
|
||||
const systemSettingsPath = '/mock/system/settings.json';
|
||||
process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH'] = systemSettingsPath;
|
||||
|
||||
@@ -360,14 +360,16 @@ describe('SettingsSchema', () => {
|
||||
expect(exploreModel.showInDialog).toBe(false);
|
||||
});
|
||||
|
||||
it('should keep cross-session messaging off by default', () => {
|
||||
// The default is the entire security posture of the feature: shipping
|
||||
// it flipped on would open every session on the box to peer messages.
|
||||
it('should keep cross-session messaging on by default', () => {
|
||||
// On by default since docs/design/2026-09-14-cross-session-messaging-default-on.md.
|
||||
// This value is not what bounds a peer: the inbound gate is (review-class
|
||||
// parity, an explicit hold or refuse), with the tighten-only workspace
|
||||
// ranking and the per-session inbox token around it.
|
||||
const crossSessionMessaging =
|
||||
getSettingsSchema().agents.properties.crossSessionMessaging;
|
||||
|
||||
expect(crossSessionMessaging.type).toBe('boolean');
|
||||
expect(crossSessionMessaging.default).toBe(false);
|
||||
expect(crossSessionMessaging.default).toBe(true);
|
||||
expect(crossSessionMessaging.requiresRestart).toBe(true);
|
||||
expect(crossSessionMessaging.showInDialog).toBe(false);
|
||||
});
|
||||
|
||||
@@ -3534,9 +3534,9 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Cross-Session Messaging',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
default: true,
|
||||
description:
|
||||
'Experimental. Let Qwen Code sessions on this machine send each other messages over a per-session local socket. Off by default; turning it on opens this session to peer messages, makes it discoverable to others, and lets its model address them from send_message.',
|
||||
'Let Qwen Code sessions on this machine send each other messages over a per-session local socket. On by default: this session is discoverable by the others, takes peer messages under the review rules of agents.crossSessionInbound, and its model can address them from send_message. Two senders are delivered without review unless agents.crossSessionInbound is "hold" or "refuse": processes this session starts, which inherit its child token, and a same-user process that claims this session\'s own review class, which nothing authenticates. Set to false to keep this session invisible and unreachable.',
|
||||
showInDialog: false,
|
||||
},
|
||||
crossSessionInbound: {
|
||||
|
||||
@@ -1278,13 +1278,15 @@ describe('WORKSPACE_TIGHTEN_ONLY_SETTINGS', () => {
|
||||
expect(messaging.strictness({})).toBe(messaging.strictness(false));
|
||||
});
|
||||
|
||||
it('ranks the switch off as stricter than on, and unset as off', () => {
|
||||
it('ranks the switch off as stricter than on, and unset as on', () => {
|
||||
// The switch defaults to on, so a workspace `false` must outrank an
|
||||
// unset user scope or a repository could never turn messaging off.
|
||||
const messaging = WORKSPACE_TIGHTEN_ONLY_SETTINGS.find(
|
||||
({ key }) => key === 'crossSessionMessaging',
|
||||
)!;
|
||||
expect(messaging.strictness(true)).toBeLessThan(
|
||||
messaging.strictness(false),
|
||||
);
|
||||
expect(messaging.strictness(undefined)).toBe(messaging.strictness(false));
|
||||
expect(messaging.strictness(undefined)).toBe(messaging.strictness(true));
|
||||
});
|
||||
});
|
||||
@@ -310,7 +310,10 @@ export const WORKSPACE_TIGHTEN_ONLY_SETTINGS = [
|
||||
{
|
||||
section: 'agents',
|
||||
key: 'crossSessionMessaging',
|
||||
strictness: (value: unknown): number => (value === true ? 0 : 1),
|
||||
// Unset means on — the default — so it ranks with `true`. Anything the
|
||||
// reader does not recognize keeps the socket closed, like `false`.
|
||||
strictness: (value: unknown): number =>
|
||||
value === true || value === undefined ? 0 : 1,
|
||||
},
|
||||
{
|
||||
section: 'agents',
|
||||
|
||||
@@ -3717,6 +3717,10 @@ describe('startInteractiveUI', () => {
|
||||
ui: {
|
||||
hideWindowTitle: false,
|
||||
},
|
||||
// Messaging is on by default, and on it binds an inbox and arms its
|
||||
// own exit cleanup. These tests are about startup order and render
|
||||
// options; the messaging path is covered in startInteractiveUI.test.
|
||||
agents: { crossSessionMessaging: false },
|
||||
},
|
||||
getUserHooks: () => undefined,
|
||||
getProjectHooks: () => undefined,
|
||||
@@ -4049,6 +4053,8 @@ describe('startInteractiveUI', () => {
|
||||
ui: {
|
||||
hideWindowTitle: false,
|
||||
},
|
||||
// Off for the reason `mockSettings` gives: this is not about messaging.
|
||||
agents: { crossSessionMessaging: false },
|
||||
},
|
||||
} as LoadedSettings;
|
||||
|
||||
@@ -4397,7 +4403,11 @@ describe('startInteractiveUI', () => {
|
||||
getMemoryPressureMonitor: () => ({ performCheck }),
|
||||
} as unknown as Config;
|
||||
const settings = {
|
||||
merged: { ui: { hideWindowTitle: true } },
|
||||
// Off for the reason `mockSettings` gives: this is not about messaging.
|
||||
merged: {
|
||||
ui: { hideWindowTitle: true },
|
||||
agents: { crossSessionMessaging: false },
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
await startInteractiveUI(
|
||||
@@ -4428,7 +4438,11 @@ describe('startInteractiveUI', () => {
|
||||
getMemoryPressureMonitor: () => ({ performCheck }),
|
||||
} as unknown as Config;
|
||||
const settings = {
|
||||
merged: { ui: { hideWindowTitle: true } },
|
||||
// Off for the reason `mockSettings` gives: this is not about messaging.
|
||||
merged: {
|
||||
ui: { hideWindowTitle: true },
|
||||
agents: { crossSessionMessaging: false },
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
// An earlier describe's vi.restoreAllMocks() wipes the shared ink
|
||||
// render mock's return value in the full run, so re-arm it here.
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
crossSessionMessagingOffScope,
|
||||
isCrossSessionMessagingEnabled,
|
||||
isCrossSessionMessagingOptedIn,
|
||||
} from './enabled.js';
|
||||
|
||||
describe('isCrossSessionMessagingEnabled', () => {
|
||||
it('is on when nothing set the key', () => {
|
||||
expect(isCrossSessionMessagingEnabled(undefined)).toBe(true);
|
||||
expect(isCrossSessionMessagingEnabled({})).toBe(true);
|
||||
expect(isCrossSessionMessagingEnabled({ agents: {} })).toBe(true);
|
||||
expect(
|
||||
isCrossSessionMessagingEnabled({
|
||||
agents: { crossSessionMessaging: undefined },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is on when set to true', () => {
|
||||
expect(
|
||||
isCrossSessionMessagingEnabled({
|
||||
agents: { crossSessionMessaging: true },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is off when set to false', () => {
|
||||
expect(
|
||||
isCrossSessionMessagingEnabled({
|
||||
agents: { crossSessionMessaging: false },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed on a value it does not recognize', () => {
|
||||
for (const value of ['yes', 'true', 1, null, {}, []]) {
|
||||
expect(
|
||||
isCrossSessionMessagingEnabled({
|
||||
agents: { crossSessionMessaging: value },
|
||||
}),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const file = (crossSessionMessaging: unknown) => ({
|
||||
settings: { agents: { crossSessionMessaging } },
|
||||
});
|
||||
|
||||
describe('isCrossSessionMessagingOptedIn', () => {
|
||||
it('counts a true the user wrote', () => {
|
||||
expect(
|
||||
isCrossSessionMessagingOptedIn({
|
||||
merged: { agents: { crossSessionMessaging: true } },
|
||||
user: file(true),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('counts a true in this workspace only while its settings are in force', () => {
|
||||
const scopes = {
|
||||
merged: {},
|
||||
workspace: file(true),
|
||||
};
|
||||
expect(isCrossSessionMessagingOptedIn(scopes)).toBe(false);
|
||||
expect(
|
||||
isCrossSessionMessagingOptedIn({
|
||||
...scopes,
|
||||
isTrusted: true,
|
||||
workspaceSettingsActive: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not count an operator-provided true', () => {
|
||||
// A fleet's system-defaults file was not written by the person at
|
||||
// this session; the merged value is true all the same.
|
||||
for (const operator of ['system', 'systemDefaults'] as const) {
|
||||
expect(
|
||||
isCrossSessionMessagingOptedIn({
|
||||
merged: { agents: { crossSessionMessaging: true } },
|
||||
[operator]: file(true),
|
||||
}),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('crossSessionMessagingOffScope', () => {
|
||||
const off = { agents: { crossSessionMessaging: false } };
|
||||
|
||||
it('is undefined while messaging is on', () => {
|
||||
expect(crossSessionMessagingOffScope({ merged: {} })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('names System whenever System sets the key', () => {
|
||||
expect(
|
||||
crossSessionMessagingOffScope({
|
||||
merged: off,
|
||||
system: file(false),
|
||||
user: file(false),
|
||||
}),
|
||||
).toBe('system');
|
||||
});
|
||||
|
||||
it('names the user scope when its value is the one in force', () => {
|
||||
expect(
|
||||
crossSessionMessagingOffScope({ merged: off, user: file(false) }),
|
||||
).toBe('user');
|
||||
// An unrecognized value is off too, and is still the user's.
|
||||
expect(
|
||||
crossSessionMessagingOffScope({
|
||||
merged: { agents: { crossSessionMessaging: 'yes' } },
|
||||
user: file('yes'),
|
||||
}),
|
||||
).toBe('user');
|
||||
});
|
||||
|
||||
it('keeps system defaults apart from System', () => {
|
||||
expect(
|
||||
crossSessionMessagingOffScope({
|
||||
merged: off,
|
||||
systemDefaults: file(false),
|
||||
}),
|
||||
).toBe('system-defaults');
|
||||
});
|
||||
|
||||
it('names the workspace when a kept workspace false outranks a user true', () => {
|
||||
expect(
|
||||
crossSessionMessagingOffScope({
|
||||
merged: off,
|
||||
user: file(true),
|
||||
workspace: file(false),
|
||||
isTrusted: true,
|
||||
workspaceSettingsActive: true,
|
||||
}),
|
||||
).toBe('workspace');
|
||||
});
|
||||
|
||||
it('cannot trace a value when only merged settings are known', () => {
|
||||
expect(crossSessionMessagingOffScope({ merged: off })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
|
||||
/**
|
||||
* Whether this session takes part in cross-session messaging.
|
||||
*
|
||||
* On unless the user turned it off. The schema defaults the setting to
|
||||
* `true`, but merged settings carry only what some scope actually wrote,
|
||||
* so an unset key reaches every reader as `undefined` and has to be read
|
||||
* as the default here rather than as "off". Anything that is not a
|
||||
* boolean fails closed: a value the reader does not recognize must not
|
||||
* open a socket, which is also the rank `WORKSPACE_TIGHTEN_ONLY_SETTINGS`
|
||||
* gives it.
|
||||
*
|
||||
* The one place this question is answered, so the interactive UI, the
|
||||
* ACP agent and `/peers` cannot drift on what "on" means.
|
||||
*/
|
||||
export function isCrossSessionMessagingEnabled(
|
||||
settings: { agents?: { crossSessionMessaging?: unknown } } | undefined,
|
||||
): boolean {
|
||||
const value = settings?.agents?.crossSessionMessaging;
|
||||
return value === undefined || value === true;
|
||||
}
|
||||
|
||||
/** One settings file, as the scope readers below see it. */
|
||||
interface CrossSessionScopeFile {
|
||||
settings: { agents?: { crossSessionMessaging?: unknown } };
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-scope view of settings these readers need. `LoadedSettings`
|
||||
* satisfies it; tests pass only the scopes they care about.
|
||||
*/
|
||||
export interface CrossSessionSettingsScopes {
|
||||
merged: { agents?: { crossSessionMessaging?: unknown } };
|
||||
system?: CrossSessionScopeFile;
|
||||
systemDefaults?: CrossSessionScopeFile;
|
||||
user?: CrossSessionScopeFile;
|
||||
workspace?: CrossSessionScopeFile;
|
||||
isTrusted?: boolean;
|
||||
workspaceSettingsActive?: boolean;
|
||||
}
|
||||
|
||||
function switchIn(file: CrossSessionScopeFile | undefined): unknown {
|
||||
return file?.settings.agents?.crossSessionMessaging;
|
||||
}
|
||||
|
||||
function workspaceCounts(settings: CrossSessionSettingsScopes): boolean {
|
||||
return (
|
||||
settings.isTrusted === true && settings.workspaceSettingsActive === true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a person wrote `true` for the switch: in their own user
|
||||
* settings, or in this workspace's settings when those are in force.
|
||||
*
|
||||
* A different question from {@link isCrossSessionMessagingEnabled}, which an
|
||||
* unset key also answers yes. Merged settings cannot answer it: they have
|
||||
* already folded in the operator scopes, and a fleet's system-defaults file
|
||||
* that says `true` was not written by the person sitting at this session.
|
||||
* Only the scopes a user edits count.
|
||||
*/
|
||||
export function isCrossSessionMessagingOptedIn(
|
||||
settings: CrossSessionSettingsScopes,
|
||||
): boolean {
|
||||
if (switchIn(settings.user) === true) return true;
|
||||
return workspaceCounts(settings) && switchIn(settings.workspace) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which scope turned messaging off, so the remedy can name the file.
|
||||
*
|
||||
* `system-defaults` is kept apart from `system` because the remedy differs:
|
||||
* a user-scope `true` overrides a system default, but nothing overrides
|
||||
* System settings, and a workspace value may only tighten, so a user `true`
|
||||
* cannot undo a workspace `false`. Scopes are checked in the order the
|
||||
* merge lets them win, and a scope counts only when its value is the one in
|
||||
* force — the same reading `inboundPolicyScope` gives the sibling setting.
|
||||
*
|
||||
* Undefined when messaging is on, or when the value in force cannot be
|
||||
* traced to a scope (a context that carries merged settings only).
|
||||
*/
|
||||
export type CrossSessionMessagingOffScope =
|
||||
| 'system'
|
||||
| 'system-defaults'
|
||||
| 'user'
|
||||
| 'workspace';
|
||||
|
||||
export function crossSessionMessagingOffScope(
|
||||
settings: CrossSessionSettingsScopes,
|
||||
): CrossSessionMessagingOffScope | undefined {
|
||||
if (isCrossSessionMessagingEnabled(settings.merged)) return undefined;
|
||||
const merged = settings.merged.agents?.crossSessionMessaging;
|
||||
if (switchIn(settings.system) !== undefined) return 'system';
|
||||
if (isDeepStrictEqual(switchIn(settings.user), merged)) return 'user';
|
||||
if (isDeepStrictEqual(switchIn(settings.systemDefaults), merged)) {
|
||||
return 'system-defaults';
|
||||
}
|
||||
if (
|
||||
workspaceCounts(settings) &&
|
||||
isDeepStrictEqual(switchIn(settings.workspace), merged)
|
||||
) {
|
||||
return 'workspace';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -129,12 +129,13 @@ interface Fake {
|
||||
|
||||
function makeContext(
|
||||
peerMessaging: Fake | null,
|
||||
crossSessionMessaging?: boolean,
|
||||
crossSessionMessaging?: unknown,
|
||||
scopes: Record<string, unknown> = {},
|
||||
): CommandContext {
|
||||
return {
|
||||
services: {
|
||||
peerMessaging,
|
||||
settings: { merged: { agents: { crossSessionMessaging } } },
|
||||
settings: { merged: { agents: { crossSessionMessaging } }, ...scopes },
|
||||
},
|
||||
} as unknown as CommandContext;
|
||||
}
|
||||
@@ -142,10 +143,11 @@ function makeContext(
|
||||
async function run(
|
||||
peerMessaging: Fake | null,
|
||||
args: string,
|
||||
crossSessionMessaging?: boolean,
|
||||
crossSessionMessaging?: unknown,
|
||||
scopes?: Record<string, unknown>,
|
||||
): Promise<{ messageType: string; content: string }> {
|
||||
const result = await peersCommand.action!(
|
||||
makeContext(peerMessaging, crossSessionMessaging),
|
||||
makeContext(peerMessaging, crossSessionMessaging, scopes),
|
||||
args,
|
||||
);
|
||||
if (!result || result.type !== 'message') {
|
||||
@@ -383,11 +385,63 @@ describe('formatHeldList', () => {
|
||||
});
|
||||
|
||||
describe('/peers', () => {
|
||||
it('explains how to turn the feature on when it is off', async () => {
|
||||
const result = await run(null, '');
|
||||
it('explains how to turn the feature back on when it is off', async () => {
|
||||
const result = await run(null, '', false);
|
||||
expect(result.messageType).toBe('info');
|
||||
expect(result.content).toContain('Cross-session messaging is off');
|
||||
expect(result.content).toContain('crossSessionMessaging');
|
||||
});
|
||||
|
||||
it('does not claim the feature is off when nothing set the key', async () => {
|
||||
// Unset means on: the null inbox is then a startup or bind problem,
|
||||
// and telling the user to enable a setting that is already on sends
|
||||
// them nowhere.
|
||||
const result = await run(null, '');
|
||||
expect(result.content).not.toContain('Cross-session messaging is off');
|
||||
expect(result.content).toContain('no inbox');
|
||||
});
|
||||
|
||||
it('names the repository when a workspace false outranks a user true', async () => {
|
||||
// A workspace may only tighten, so writing true in user settings
|
||||
// changes nothing here; the remedy has to point at the repository.
|
||||
const result = await run(null, '', false, {
|
||||
user: { settings: { agents: { crossSessionMessaging: true } } },
|
||||
workspace: { settings: { agents: { crossSessionMessaging: false } } },
|
||||
isTrusted: true,
|
||||
workspaceSettingsActive: true,
|
||||
});
|
||||
expect(result.content).toContain('.qwen/settings.json');
|
||||
expect(result.content).toContain('cannot turn it back on here');
|
||||
expect(result.content).not.toContain('Set it to true');
|
||||
});
|
||||
|
||||
it('does not claim a value it cannot see', async () => {
|
||||
const result = await run(null, '', 'yes', {
|
||||
user: { settings: { agents: { crossSessionMessaging: 'yes' } } },
|
||||
});
|
||||
expect(result.content).toContain('your user settings');
|
||||
expect(result.content).not.toMatch(/: ?false/);
|
||||
});
|
||||
|
||||
it('says an unsupported platform as information, without advice to disable', async () => {
|
||||
inboxFailure.current = {
|
||||
cause: 'unsupported_platform',
|
||||
socketPath: 'C:\\Users\\me\\qwen-socks\\1.sock',
|
||||
detail: 'automatic peer inbox paths are not supported on Windows',
|
||||
hint: 'Disable cross-session messaging for this session.',
|
||||
attempts: 1,
|
||||
};
|
||||
try {
|
||||
const result = await run(null, '');
|
||||
expect(result.messageType).toBe('info');
|
||||
expect(result.content).toContain('not available on this platform');
|
||||
expect(result.content).not.toContain('failed to bind its socket');
|
||||
expect(result.content).not.toContain('Disable');
|
||||
} finally {
|
||||
inboxFailure.current = null;
|
||||
}
|
||||
});
|
||||
|
||||
it('does not tell a user to enable a setting they already enabled', async () => {
|
||||
// Same null inbox, different cause: registration or the bind failed.
|
||||
// "Turn it on" would send them back to a setting that is already on.
|
||||
@@ -395,7 +449,8 @@ describe('/peers', () => {
|
||||
const result = await run(null, '', true);
|
||||
expect(result.messageType).toBe('error');
|
||||
expect(result.content).toContain('failed to register');
|
||||
expect(result.content).not.toContain('Enable it with');
|
||||
expect(result.content).not.toContain('Cross-session messaging is off');
|
||||
expect(result.content).not.toContain('Remove that entry');
|
||||
});
|
||||
|
||||
it('repeats the bind failure and what to change when the inbox could not bind', async () => {
|
||||
@@ -415,7 +470,8 @@ describe('/peers', () => {
|
||||
// ever existed in this file's stub.
|
||||
expect(result.content).toContain('belongs to another user');
|
||||
expect(result.content).toContain('XDG_RUNTIME_DIR');
|
||||
expect(result.content).not.toContain('Enable it with');
|
||||
expect(result.content).not.toContain('Cross-session messaging is off');
|
||||
expect(result.content).not.toContain('Remove that entry');
|
||||
} finally {
|
||||
inboxFailure.current = null;
|
||||
}
|
||||
@@ -900,7 +956,9 @@ describe('/peers controllers', () => {
|
||||
];
|
||||
const out = await run(null, 'controllers', false);
|
||||
expect(out.content).toContain('c_0123abcd');
|
||||
expect(out.content).not.toContain('Cross-session messaging is off');
|
||||
// The whole off-notice, not one phrasing of it: the listing is served
|
||||
// before the off-check and must not carry it.
|
||||
expect(out.content).not.toContain('Cross-session messaging');
|
||||
});
|
||||
|
||||
it('reports a registry it cannot read as a line, not a crash', async () => {
|
||||
|
||||
@@ -26,6 +26,11 @@ import {
|
||||
removePeerController,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
|
||||
import {
|
||||
crossSessionMessagingOffScope,
|
||||
type CrossSessionMessagingOffScope,
|
||||
isCrossSessionMessagingEnabled,
|
||||
} from '../../peerMessaging/enabled.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { CommandKind } from './types.js';
|
||||
|
||||
@@ -302,18 +307,35 @@ export const peersCommand: SlashCommand = {
|
||||
// setting they already enabled sends them nowhere: the inbox is also
|
||||
// absent when the session failed to register or the socket failed to
|
||||
// bind (path too long, unwritable runtime dir).
|
||||
const enabled =
|
||||
context.services.settings?.merged?.agents?.crossSessionMessaging ===
|
||||
true;
|
||||
const failure = enabled ? getLastPeerInboxFailure() : null;
|
||||
const settings = context.services.settings;
|
||||
if (!isCrossSessionMessagingEnabled(settings?.merged)) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: describeMessagingOff(
|
||||
settings ? crossSessionMessagingOffScope(settings) : undefined,
|
||||
),
|
||||
};
|
||||
}
|
||||
const failure = getLastPeerInboxFailure();
|
||||
// A platform with no inbox transport is not a fault in this session,
|
||||
// and the switch is on by default, so most people who see this never
|
||||
// turned anything on: say what is true, as information, without the
|
||||
// failure's own advice to disable a setting they never enabled.
|
||||
if (failure?.cause === 'unsupported_platform') {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content:
|
||||
'Cross-session messaging is not available on this platform, so this session has no inbox and other sessions cannot reach it.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: enabled ? 'error' : 'info',
|
||||
content: !enabled
|
||||
? 'Cross-session messaging is off. Enable it with "agents.crossSessionMessaging": true in settings.json, then restart.'
|
||||
: failure
|
||||
? `Cross-session messaging is on, but this session has no inbox — it failed to bind its socket: ${describePeerInboxFailure(failure)}`
|
||||
: 'Cross-session messaging is on, but this session has no inbox: it failed to register in the session registry, or the inbox is still starting. Re-run with DEBUG=1 to see the registration error.',
|
||||
messageType: 'error',
|
||||
content: failure
|
||||
? `Cross-session messaging is on, but this session has no inbox — it failed to bind its socket: ${describePeerInboxFailure(failure)}`
|
||||
: 'Cross-session messaging is on, but this session has no inbox: it failed to register in the session registry, or the inbox is still starting. Re-run with DEBUG=1 to see the registration error.',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -459,3 +481,26 @@ export const peersCommand: SlashCommand = {
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Why messaging is off, with the remedy that works for the scope that
|
||||
* turned it off. Never states the value itself: anything but `true` or an
|
||||
* unset key reads as off, so "set to false" would be a claim about a file
|
||||
* the user may open and find something else in.
|
||||
*/
|
||||
function describeMessagingOff(
|
||||
scope: CrossSessionMessagingOffScope | undefined,
|
||||
): string {
|
||||
switch (scope) {
|
||||
case 'workspace':
|
||||
return 'Cross-session messaging is off: this repository\'s .qwen/settings.json turns "agents.crossSessionMessaging" off. A workspace may only make that setting stricter, so your user settings cannot turn it back on here. Remove the entry from that file, then restart.';
|
||||
case 'system':
|
||||
return 'Cross-session messaging is off: system settings set "agents.crossSessionMessaging", and system settings override every other scope. Whoever manages them can remove the entry.';
|
||||
case 'system-defaults':
|
||||
return 'Cross-session messaging is off: the system defaults turn "agents.crossSessionMessaging" off. Set it to true in your user settings, then restart.';
|
||||
case 'user':
|
||||
return 'Cross-session messaging is off: your user settings turn "agents.crossSessionMessaging" off (only true, or leaving it unset, turns it on). Remove that entry, then restart.';
|
||||
default:
|
||||
return 'Cross-session messaging is off because of the "agents.crossSessionMessaging" setting (only true, or leaving it unset, turns it on). Remove that entry, then restart.';
|
||||
}
|
||||
}
|
||||
@@ -168,7 +168,17 @@ function makeConfig(): TestConfig {
|
||||
} as unknown as TestConfig;
|
||||
}
|
||||
|
||||
// Messaging is on by default, and on it adds its own teardown entry; these
|
||||
// suites count the registry pair alone, so they turn it off explicitly.
|
||||
// The cross-session suite below is where "unset" is exercised.
|
||||
const settings = {
|
||||
merged: {
|
||||
ui: { hideWindowTitle: true },
|
||||
agents: { crossSessionMessaging: false },
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
const settingsWithoutTheKey = {
|
||||
merged: { ui: { hideWindowTitle: true } },
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
@@ -249,11 +259,14 @@ describe('startInteractiveUI session registration', () => {
|
||||
});
|
||||
|
||||
describe('startInteractiveUI cross-session messaging', () => {
|
||||
// Turned on by hand: the user scope wrote it, so the merged value is
|
||||
// true and the opt-in reader sees it too.
|
||||
const enabledSettings = {
|
||||
merged: {
|
||||
ui: { hideWindowTitle: true },
|
||||
agents: { crossSessionMessaging: true },
|
||||
},
|
||||
user: { settings: { agents: { crossSessionMessaging: true } } },
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -399,7 +412,7 @@ describe('startInteractiveUI cross-session messaging', () => {
|
||||
expect(options.getSessionId()).toBe('session-after-clear');
|
||||
});
|
||||
|
||||
it('does not bind an inbox unless the setting is on', async () => {
|
||||
it('does not bind an inbox when the setting is off', async () => {
|
||||
const config = makeConfig();
|
||||
|
||||
await start(config);
|
||||
@@ -413,6 +426,17 @@ describe('startInteractiveUI cross-session messaging', () => {
|
||||
expect(registerCleanup).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('binds an inbox when nothing set the key: on is the default', async () => {
|
||||
// Merged settings carry only what a scope wrote, so the schema default
|
||||
// never reaches this code as a value. An unset key has to mean on here,
|
||||
// or the default would be on in the docs and off in practice.
|
||||
const config = makeConfig();
|
||||
|
||||
await start(config, settingsWithoutTheKey);
|
||||
|
||||
await vi.waitFor(() => expect(peerMessagingStart).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('waits for registration to be queued before binding', async () => {
|
||||
// The inbox advertises itself by patching the session's registry
|
||||
// record, and a patch against a record that does not exist yet is
|
||||
@@ -467,6 +491,85 @@ describe('startInteractiveUI cross-session messaging', () => {
|
||||
}
|
||||
});
|
||||
|
||||
async function observeFailureAfterStart(
|
||||
used: LoadedSettings,
|
||||
): Promise<unknown> {
|
||||
await start(makeConfig(), used);
|
||||
await vi.waitFor(() => expect(peerMessagingStart).toHaveBeenCalled());
|
||||
const appTree = inkRender.mock.calls[0]?.[0] as ReactElement;
|
||||
const mounted = renderDom(appTree);
|
||||
try {
|
||||
// Give the provider a turn to publish whatever it decided to.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
return observedPeerInboxFailure.value;
|
||||
} finally {
|
||||
mounted.unmount();
|
||||
}
|
||||
}
|
||||
|
||||
const unsupportedPlatform = {
|
||||
cause: 'unsupported_platform',
|
||||
socketPath: 'C:\\Users\\me\\qwen-socks\\1.sock',
|
||||
detail: 'automatic peer inbox paths are not supported on Windows',
|
||||
hint: 'Disable cross-session messaging for this session.',
|
||||
attempts: 1,
|
||||
};
|
||||
|
||||
it('keeps an unsupported platform quiet when nothing set the key', async () => {
|
||||
// On by default means every user of a platform without a transport
|
||||
// would otherwise be greeted with a failure about a feature they
|
||||
// never asked for.
|
||||
lastPeerInboxFailure.value = unsupportedPlatform;
|
||||
peerMessagingStart.mockResolvedValue(null);
|
||||
|
||||
expect(await observeFailureAfterStart(settingsWithoutTheKey)).toBeNull();
|
||||
});
|
||||
|
||||
it('says an unsupported platform to a user who turned the switch on', async () => {
|
||||
lastPeerInboxFailure.value = unsupportedPlatform;
|
||||
peerMessagingStart.mockResolvedValue(null);
|
||||
|
||||
expect(await observeFailureAfterStart(enabledSettings)).toEqual(
|
||||
unsupportedPlatform,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps an unsupported platform quiet when only an operator default turned the switch on', async () => {
|
||||
// The merged value is true, but nobody at this session wrote it: a
|
||||
// fleet's system-defaults file is not an opt-in by hand.
|
||||
lastPeerInboxFailure.value = unsupportedPlatform;
|
||||
peerMessagingStart.mockResolvedValue(null);
|
||||
const operatorOn = {
|
||||
merged: {
|
||||
ui: { hideWindowTitle: true },
|
||||
agents: { crossSessionMessaging: true },
|
||||
},
|
||||
systemDefaults: {
|
||||
settings: { agents: { crossSessionMessaging: true } },
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
expect(await observeFailureAfterStart(operatorOn)).toBeNull();
|
||||
});
|
||||
|
||||
it('says a real bind failure even when nothing set the key', async () => {
|
||||
// Unreachable is unreachable whoever asked for the inbox: this line
|
||||
// is the only place the user learns their session cannot be messaged.
|
||||
const failure = {
|
||||
cause: 'permission',
|
||||
socketPath: '/run/user/1000/qwen-socks/1.sock',
|
||||
detail: 'EACCES',
|
||||
hint: 'Choose a directory you own.',
|
||||
attempts: 3,
|
||||
};
|
||||
lastPeerInboxFailure.value = failure;
|
||||
peerMessagingStart.mockResolvedValue(null);
|
||||
|
||||
expect(await observeFailureAfterStart(settingsWithoutTheKey)).toEqual(
|
||||
failure,
|
||||
);
|
||||
});
|
||||
|
||||
it('closes the inbox from exit cleanup', async () => {
|
||||
const close = vi.fn().mockResolvedValue(undefined);
|
||||
peerMessagingStart.mockResolvedValue({ close });
|
||||
|
||||
@@ -25,6 +25,10 @@ import {
|
||||
PeerMessagingContext,
|
||||
} from '../peerMessaging/PeerMessagingContext.js';
|
||||
import { inboundPolicyScope } from '../peerMessaging/inbound-policy-scope.js';
|
||||
import {
|
||||
isCrossSessionMessagingEnabled,
|
||||
isCrossSessionMessagingOptedIn,
|
||||
} from '../peerMessaging/enabled.js';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import { isValidSessionId } from '../config/config.js';
|
||||
import type { InitializationResult } from '../core/initializer.js';
|
||||
@@ -182,7 +186,7 @@ export async function startInteractiveUI(
|
||||
? installTerminalResizeReflow(process.stdout, { virtualViewport: useVP })
|
||||
: { restore: () => {}, repaint: () => {} };
|
||||
|
||||
// Cross-session messaging (experimental, off by default). The inbox is
|
||||
// Cross-session messaging (on by default; see peerMessaging/enabled.ts). The inbox is
|
||||
// owned outside React — bound once per process by the block at the end of
|
||||
// this function — and this promise is how the bound instance (or null,
|
||||
// when the feature is off or the socket could not be bound) reaches the
|
||||
@@ -217,9 +221,26 @@ export async function startInteractiveUI(
|
||||
// cause is what the user needs, not the null.
|
||||
if (
|
||||
messaging === null &&
|
||||
settings.merged.agents?.crossSessionMessaging === true
|
||||
isCrossSessionMessagingEnabled(settings.merged)
|
||||
) {
|
||||
setPeerInboxFailure(getLastPeerInboxFailure());
|
||||
const failure = getLastPeerInboxFailure();
|
||||
// A different question from the helper above: not "is messaging
|
||||
// on", which an unset key also answers yes, but "did a person
|
||||
// write `true`" — in their user settings or this workspace's, not
|
||||
// in an operator's defaults, which merged settings cannot tell
|
||||
// apart. The switch is on by default, so a platform with no inbox
|
||||
// transport would otherwise greet every one of its users with a
|
||||
// failure about a feature they never asked for; that one is said
|
||||
// only to someone who opted in by hand. A bind that failed where
|
||||
// it should have worked is said to everyone: they are
|
||||
// unreachable, and this line is the only place they learn it.
|
||||
const optedInByHand = isCrossSessionMessagingOptedIn(settings);
|
||||
if (
|
||||
failure !== null &&
|
||||
(failure.cause !== 'unsupported_platform' || optedInByHand)
|
||||
) {
|
||||
setPeerInboxFailure(failure);
|
||||
}
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
@@ -452,7 +473,7 @@ export async function startInteractiveUI(
|
||||
// registry record, and `patchSessionRecord` no-ops when there is no record
|
||||
// yet, so binding any earlier would publish the socket path into nothing.
|
||||
// Not awaited — startup must never block on binding a socket.
|
||||
if (settings.merged.agents?.crossSessionMessaging !== true) {
|
||||
if (!isCrossSessionMessagingEnabled(settings.merged)) {
|
||||
publishPeerMessaging(null);
|
||||
} else {
|
||||
let exiting = false;
|
||||
|
||||
@@ -455,7 +455,7 @@ class SendMessageInvocation extends BaseToolInvocation<
|
||||
|
||||
if (!teamManager) {
|
||||
const msg = this.peerMessagingOff
|
||||
? `No active team and no task_id, and cross-session messaging is not enabled in this session (agents.crossSessionMessaging), so "${to}" cannot be another session. ` +
|
||||
? `No active team and no task_id, and cross-session messaging is not active in this session (agents.crossSessionMessaging is off, or its inbox did not start), so "${to}" cannot be another session. ` +
|
||||
'Create a team, or pass `task_id` to message a background task.'
|
||||
: `No active team, no task_id, and no reachable session named "${to}". ` +
|
||||
'Create a team, pass `task_id` to message a background task, or use ' +
|
||||
@@ -486,7 +486,7 @@ class SendMessageInvocation extends BaseToolInvocation<
|
||||
// that the name it wants could belong to a session this setting
|
||||
// is hiding.
|
||||
errMsg += this.peerMessagingOff
|
||||
? ` Cross-session messaging is not enabled in this session (agents.crossSessionMessaging), so another session could not have taken that name either.`
|
||||
? ` Cross-session messaging is not active in this session (agents.crossSessionMessaging is off, or its inbox did not start), so another session could not have taken that name either.`
|
||||
: ` No reachable session has that name either; use list_agents to see who is reachable.`;
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -255,9 +255,9 @@ await endpoint.send({
|
||||
Things to know:
|
||||
|
||||
- A session has an inbox only while its `agents.crossSessionMessaging` setting
|
||||
is on, and the setting is off by default. Without it the session does not
|
||||
appear in `list()`, and its own `list_agents` and `send_message` cannot see
|
||||
or reach the program either. `qwen sessions ps` lists the program regardless.
|
||||
is on, which it is by default. A session that turned it off does not appear
|
||||
in `list()`, and its own `list_agents` and `send_message` cannot see or
|
||||
reach the program either. `qwen sessions ps` lists the program regardless.
|
||||
- A message is delivered without review in exactly two cases: the send
|
||||
presents a controller token (`controller: true`), or its `fromMode` names the
|
||||
receiving session's own review class. `fromMode` is a claim nothing
|
||||
|
||||
@@ -1682,9 +1682,9 @@
|
||||
}
|
||||
},
|
||||
"crossSessionMessaging": {
|
||||
"description": "Experimental. Let Qwen Code sessions on this machine send each other messages over a per-session local socket. Off by default; turning it on opens this session to peer messages, makes it discoverable to others, and lets its model address them from send_message.",
|
||||
"description": "Let Qwen Code sessions on this machine send each other messages over a per-session local socket. On by default: this session is discoverable by the others, takes peer messages under the review rules of agents.crossSessionInbound, and its model can address them from send_message. Two senders are delivered without review unless agents.crossSessionInbound is \"hold\" or \"refuse\": processes this session starts, which inherit its child token, and a same-user process that claims this session's own review class, which nothing authenticates. Set to false to keep this session invisible and unreachable.",
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
"default": true
|
||||
},
|
||||
"crossSessionInbound": {
|
||||
"description": "What happens to inbound cross-session messages. \"accept\" delivers them; \"hold\" parks them for your review without letting the model act; \"refuse\" opts this session out. Unset means user-minted controllers and this session's own child processes auto-deliver, while other sessions use review-class parity: both must review every action, or both must declare a mode that can apply actions without per-action review. Other messages are held for you to review. Options: accept, hold, refuse",
|
||||
|
||||
Reference in new issue
Block a user