Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/desktop/electron/main/services/session-collaboration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,14 @@ export function createSessionCollaborationService(deps: SessionCollaborationDepe
const chosen = requested ? models.find((item) => item.key === requested)
: models.find((item) => item.availableForSubagents) ?? models.find((item) => item.isDefault);
if (!chosen) fail("MODEL_NOT_CONFIGURED", "No matching configured model is available");
// A worker the agent starts is AI-driven delegation, so naming a model here
// needs that model's own `availableForSubagents` opt-in — the gate `Task.model`
// already applies (ADR subagent-model-opt-in; #386). Inheriting stays open, and
// naming the default is that inheritance spelled out, the way repeating a
// definition's own pin is on the Task path.
if (requested && !chosen.availableForSubagents && !chosen.isDefault) {
fail("PERMISSION_DENIED", `Model "${requested}" is not enabled for AI delegation. Turn on "Available for AI delegation" for it in model settings, or omit modelKey to inherit.`);
}
model = parsePluginModelKey(chosen.key);
}
checkCurrent(host, input.signal);
Expand Down
99 changes: 98 additions & 1 deletion apps/desktop/test/session-collaboration-service.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,18 @@ function load(relative, imports) {
return module.exports;
}

// The real model helpers, not a stub: the opt-in flag has to survive the whole
// way from `providers.list` through the plugin model list into the spawn
// decision, and a hand-written stub would prove only the last step.
const pluginAgentComplete = load("../electron/main/plugin-agent-complete.ts", {
"@pi-desktop/agent-runtime": await import("@pi-desktop/agent-runtime"),
"@pi-desktop/shared": await import("@pi-desktop/shared"),
});

const { createSessionCollaborationService } = load("../electron/main/services/session-collaboration.ts", {
"node:crypto": crypto,
"../agent-host-bridge": { DESKTOP_PRINCIPAL: { kind: "desktop" } },
"../plugin-agent-complete": {},
"../plugin-agent-complete": pluginAgentComplete,
});

function createHost(name, respond = () => ({})) {
Expand Down Expand Up @@ -234,3 +242,92 @@ test("an omitted session message kind defaults to message", async () => {
assert.equal(send[0].params.kind, kind ?? "message");
}
});

// Spawning a worker is the agent choosing a model for work it delegates, so
// the per-model "Available for AI delegation" opt-in governs it the same way
// it governs `Task.model` (#386).
const SPAWN_PROVIDERS = [
{
id: "provider-a",
name: "Provider A",
enabled: true,
authKind: "none",
models: [
{ id: "default-model" },
{ id: "private-model" },
{ id: "delegable-model", availableForSubagents: true },
],
},
];

function spawnHost() {
const stored = sessionMessage({ id: "spawn-message", status: "completed", kind: "task" });
return createHost("host-1", (method) => {
if (method === "providers.list") return { providers: SPAWN_PROVIDERS };
if (method === "settings.get") {
return { defaultProviderId: "provider-a", defaultModelId: "default-model" };
}
if (method === "session.collaboration.spawn") return { message: stored };
if (method === "session.collaboration.message") return { message: stored };
return { messages: [] };
});
}

function invokeSpawn(service, args) {
return service.invoke({
operation: "session/collaboration/spawn",
source: "plugin",
args: [args],
pluginContext: {
pluginId: "demo-plugin",
sessionId: "source-session",
turnId: "turn-1",
invocationId: "invocation-1",
},
});
}

function spawnService(host) {
return createService({ getHost: () => host, activeTurns: { "source-session": "turn-1" } }).service;
}

test("spawn refuses a model the user did not enable for AI delegation (#386)", async () => {
const host = spawnHost();

await assert.rejects(
invokeSpawn(spawnService(host), { task: "Review the diff", modelKey: "provider-a/private-model" }),
{ code: "PERMISSION_DENIED", message: /not enabled for AI delegation/ },
);
assert.deepEqual(methodCalls(host, "session.collaboration.spawn"), []);
});

test("spawn accepts a model enabled for AI delegation (#386)", async () => {
const host = spawnHost();

await invokeSpawn(spawnService(host), { task: "Review the diff", modelKey: "provider-a/delegable-model" });

const spawns = methodCalls(host, "session.collaboration.spawn");
assert.equal(spawns.length, 1);
assert.equal(spawns[0].params.providerId, "provider-a");
assert.equal(spawns[0].params.modelId, "delegable-model");
});

test("spawn treats the default model's own key as inheritance, not a selection (#386)", async () => {
const host = spawnHost();

await invokeSpawn(spawnService(host), { task: "Review the diff", modelKey: "provider-a/default-model" });

const spawns = methodCalls(host, "session.collaboration.spawn");
assert.equal(spawns.length, 1);
assert.equal(spawns[0].params.modelId, "default-model");
});

test("spawn without a model key still prefers an enabled model over the default (#386)", async () => {
const host = spawnHost();

await invokeSpawn(spawnService(host), { task: "Review the diff" });

const spawns = methodCalls(host, "session.collaboration.spawn");
assert.equal(spawns.length, 1);
assert.equal(spawns[0].params.modelId, "delegable-model");
});
14 changes: 13 additions & 1 deletion docs/adr/subagent-model-opt-in.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

- Status: Accepted for implementation
- Date: 2026-09-13
- Related: D278, ADR 0062, ADR 0089, E2E-166, issue #286
- Related: D278, ADR 0062, ADR 0089, ADR 0237, E2E-166, issues #286, #386

## Context

Expand Down Expand Up @@ -34,6 +34,18 @@ name rule as pin resolution; ambiguous vendor aliases fail closed unless the
caller uses the exact provider id. A changed launch list retires an idle
runtime on the next prompt, including after opt-in is revoked.

The opt-in belongs to the decision, not to the Task tool: it governs every
entry point through which the AI picks a model for work it delegates.
`session/collaboration/spawn` is the second such entry point (ADR 0237), and
its `modelKey` is that same selection written by a plugin on the agent's
behalf, so an unopted model is refused with `PERMISSION_DENIED` before a worker
exists. The inheritance half is unchanged: omitting `modelKey` still takes the
first opted-in model and otherwise the default, and naming the default model's
own key is that inheritance spelled out, exactly as repeating a definition's
own pin is on the Task path. `models.list` keeps reporting every ready model
with its `availableForSubagents` flag — the flag is advice to the caller and
authority only in main, which is the side a plugin cannot rewrite (#386).

D278's priority remains Task.model → definition pin → session model. The
existing exact-session-model exception remains unchanged. Repeating the
target definition's own pin key is treated as omitting `model`, so catalog
Expand Down
4 changes: 3 additions & 1 deletion docs/spec/03-runtime/11-provider-model-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,9 @@ normal pin resolution, including when `Task.model` repeats that definition's
own pin key. On-demand matching uses unique provider id/vendor/name lookup and
must not overwrite a pin with another account's credentials. If vendor/model aliases collide across accounts, the
opted-in account uses its exact provider ID as the override key. Selection priority remains Task.model → definition pin
→ session model (D278; ADR subagent-model-opt-in).
→ session model (D278; ADR subagent-model-opt-in). The opt-in governs every entry point that lets the AI pick a model
for delegated work, not only `Task.model`: a `session/collaboration/spawn` `modelKey` naming a model without it is
refused with `PERMISSION_DENIED`, while omitting the key, or naming the default model's own key, still inherits.

## 8. Secrets

Expand Down
12 changes: 9 additions & 3 deletions docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -10922,7 +10922,9 @@ are withdrawn with ADR 0165.
using the exact returned `sessionId`, and stop another worker. 10) Restart
the host/plugin with a queued delivery and confirm it remains held, while an
interrupted turn is not replayed. Repeat the parallel creation step with a
large existing session list while keeping the parent visible.
large existing session list while keeping the parent visible. 11) With one
model's «Available for AI delegation» left off, ask the parent to spawn a
worker on it by `modelKey`, then spawn again with no `modelKey`.
- **Expected**: Each worker is a real durable session with the parent's
project/model/thinking/permission ceiling and an independent empty
transcript at creation. The host ledger binds every delivery to the actual
Expand All @@ -10937,15 +10939,19 @@ are withdrawn with ADR 0165.
interrupts only the selected delivery/turn without deleting the session.
Sends without an active plugin tool invocation, forged source ids, targets
above the source permission ceiling, worker fan-out overflow, inbox overflow,
and autonomous callback loops fail closed. Unrelated sessions and the
and autonomous callback loops fail closed. A `spawn` naming a model the user
has not enabled for AI delegation is refused with `PERMISSION_DENIED` before
a worker exists, while omitting `modelKey` — or naming the default model's
own key — still inherits. Unrelated sessions and the
existing Task family are unchanged, and no localhost MCP call or token
access occurs. Bursts of worker notifications serialize and coalesce
session-list refreshes while preserving the final worker list and the
foreground session.
- **Specs linked**: `07-plugins/03-plugin-api.md`,
`07-plugins/04-plugin-security.md`, `07-plugins/11-plugin-storage-isolation.md`,
`03-runtime/01-ipc-protocol.md`, `03-runtime/06-host-rpc-protocol.md`,
`03-runtime/04-data-storage.md`, ADR 0237, ADR 0239
`03-runtime/04-data-storage.md`, `03-runtime/11-provider-model-system.md`,
ADR 0237, ADR 0239, ADR subagent-model-opt-in
- **Acceptance**: C (parallel durable sessions), D (plugin security), Quality
- **Milestone**: M6+
- **Status**: host ledger coverage is automated by
Expand Down
7 changes: 7 additions & 0 deletions docs/spec/07-plugins/03-plugin-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,13 @@ and `result` are bounded projections and do not load a full transcript.
`cancel` interrupts only the exact queued delivery or bound turn and retains
the target session and history.

A named `spawn` `modelKey` is an AI-driven delegation choice and needs that
model's own `ModelBinding.availableForSubagents` opt-in; the host answers
`PERMISSION_DENIED` for a model the user has not enabled, before creating a
worker. Omitting `modelKey` still inherits — the first enabled model, else the
default — and naming the default model's own key is that same inheritance
rather than a selection (ADR subagent-model-opt-in).

`list` returns at most 100 non-deleted Agent sessions that can receive a
message, including sessions created independently of Session Orchestrator. Each
entry contains only its Session ID, title, status, updated time, readable
Expand Down
7 changes: 7 additions & 0 deletions docs/spec/08-meta/decisions-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -3072,6 +3072,13 @@ D193, and D194.
different provider id, and do not retire an idle runtime. Repeating a
definition's own pin key is omit. On-demand matching uses unique provider
lookup. The Task catalog discloses each definition's default model.
- Scope clarification (2026-09-15, issue #386): the opt-in governs every entry
point through which the AI picks a model for delegated work, not only
`Task.model`. A `session/collaboration/spawn` `modelKey` naming a model
without the opt-in is refused with `PERMISSION_DENIED` before a worker is
created; omitting the key, or naming the default model's own key, remains
inheritance. `models.list` still reports every ready model with its flag —
the flag advises the caller and is authoritative only in Electron main.

- Models not pre-resolved at sidecar launch are resolved on-demand via the
`provider.resolveSubagentModel` RPC to Electron main, where credentials and
Expand Down
4 changes: 3 additions & 1 deletion docs/zh-CN/spec/03-runtime/11-provider-model-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,9 @@ agent 系统提示的委托目录中。父 agent 随后就能通过 Task 工具
正常的固定模型解析生效,包括 `Task.model` 重复该定义自己的固定键。按需匹配使用唯一
provider id/vendor/name 查找,不得用另一账号凭据覆盖固定模型。多个账号的 vendor/model 别名冲突时,已勾选账号改用
确切的提供商 ID 作为覆盖键。优先级保持 Task.model → 定义固定模型 → 会话模型
(D278;ADR subagent-model-opt-in)。
(D278;ADR subagent-model-opt-in)。该许可约束所有让 AI 为委派工作挑选模型的入口,
而不只是 `Task.model`:`session/collaboration/spawn` 的 `modelKey` 指向未勾选的模型时
以 `PERMISSION_DENIED` 拒绝,省略该键或写出默认模型自己的键仍按继承处理。

## 8. 秘密

Expand Down
7 changes: 5 additions & 2 deletions docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -6956,12 +6956,15 @@ runner 会在运行时的隔离临时目录中生成六个插件形态 fixture
上下文,不创建替代会话。状态和面板刷新使用有界的轻量轮询;`wait` 在其等待上限内返回
`timedOut`,不占满宿主工具超时时间。`cancel` 中止但不删除,无关会话和现有 Task 系列
保持不变,且不发生 localhost MCP 调用或 token 访问。Worker 不能再创建 Worker,
不属于调用方的 Session ID 必须被拒绝,并发上限超出时必须安全失败。Worker 创建和
不属于调用方的 Session ID 必须被拒绝,并发上限超出时必须安全失败。`spawn` 指定用户
未勾选「可供 AI 自动调度」的模型时,在创建 Worker 之前以 `PERMISSION_DENIED` 拒绝;
省略 `modelKey`,或写出默认模型自己的键,仍按继承处理。Worker 创建和
prompt 通知突发时,会话列表刷新串行执行并合并,同时保留最终 Worker 列表和前台会话。
后到达的通知等待后续读取,不会因复用 Worker 创建之前已开始的读取而丢失。
- **链接规格**:`07-plugins/03-plugin-api.md`、`07-plugins/04-plugin-security.md`、
`07-plugins/11-plugin-storage-isolation.md`、`03-runtime/01-ipc-protocol.md`、
`03-runtime/06-host-rpc-protocol.md`、ADR 0237
`03-runtime/06-host-rpc-protocol.md`、`03-runtime/11-provider-model-system.md`、
ADR 0237、ADR subagent-model-opt-in
- **接受**:C(并行持久化会话)、D(插件安全性)、品质
- **里程碑**:M6+
- **状态**:host ledger 覆盖由 `pnpm test:e2e:collaboration` 自动化;marketplace 插件测试覆盖插件运行时,host-core 和 desktop 单元测试覆盖新增的宿主原子能力。完整真实 provider/Electron 旅程仍需在具备条件的 runner 中验证,遵循无本地 E2E 策略
Expand Down
6 changes: 6 additions & 0 deletions docs/zh-CN/spec/07-plugins/03-plugin-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,12 @@ Session ID,并复用该会话的项目、模型、上下文和权限配置;`
不是 worker 身份。`status` 和 `result` 是有界投影,不会加载完整转录本。`cancel` 只中断
精确的排队投递或绑定回合,并保留目标会话及其历史。

`spawn` 中显式指定的 `modelKey` 属于 AI 自动调度的模型选择,需要该模型自身的
`ModelBinding.availableForSubagents` 许可;对用户未勾选的模型,宿主在创建 worker 之前
返回 `PERMISSION_DENIED`。省略 `modelKey` 仍然是继承——先取已勾选的模型,否则取默认
模型——显式写出默认模型自己的键同样按继承处理,而不是一次选择
(ADR subagent-model-opt-in)。

`spawn` 和 `send` 仅在插件当前 Agent 工具调用期间有效。broker 注入 `pluginId`、来源
`sessionId`、来源 `turnId` 和调用身份;插件参数不能提供或覆盖这些字段。面向用户的插件
面板可使用自有插件身份调用 `cancel`,但不能用该路径发送或创建工作。宿主执行来源权限
Expand Down
5 changes: 5 additions & 0 deletions docs/zh-CN/spec/08-meta/decisions-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -2684,6 +2684,11 @@ D193 和 D194。
只有启动许可键进入复用快照。按需授权写入独立缓存,不得用不同 provider id
覆盖固定模型,也不会替换空闲运行时。重复定义自己的固定键视为省略 `model`。
按需匹配使用唯一提供商查找。Task 目录同时展示各定义的默认模型。
- 范围澄清(2026-09-15,issue #386):该许可约束所有让 AI 为委派工作挑选模型
的入口,不只是 `Task.model`。`session/collaboration/spawn` 的 `modelKey`
指向未勾选的模型时,在创建 worker 之前以 `PERMISSION_DENIED` 拒绝;省略该键
或写出默认模型自己的键仍按继承处理。`models.list` 依旧返回全部就绪模型及其
标志——该标志只是给调用方的提示,权威判断仅在 Electron main。
- sidecar 启动时未预解析的模型通过发往 Electron main 的
`provider.resolveSubagentModel` RPC 按需解析,凭据和 models.dev 快照都在
那里。
Expand Down