diff --git a/apps/desktop/electron/main/services/session-collaboration.ts b/apps/desktop/electron/main/services/session-collaboration.ts index 44038ebc6..6595c48d7 100644 --- a/apps/desktop/electron/main/services/session-collaboration.ts +++ b/apps/desktop/electron/main/services/session-collaboration.ts @@ -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); diff --git a/apps/desktop/test/session-collaboration-service.test.mjs b/apps/desktop/test/session-collaboration-service.test.mjs index 411f23bbc..7bb640534 100644 --- a/apps/desktop/test/session-collaboration-service.test.mjs +++ b/apps/desktop/test/session-collaboration-service.test.mjs @@ -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 = () => ({})) { @@ -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"); +}); diff --git a/docs/adr/subagent-model-opt-in.md b/docs/adr/subagent-model-opt-in.md index 8c255779e..397065dc3 100644 --- a/docs/adr/subagent-model-opt-in.md +++ b/docs/adr/subagent-model-opt-in.md @@ -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 @@ -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 diff --git a/docs/spec/03-runtime/11-provider-model-system.md b/docs/spec/03-runtime/11-provider-model-system.md index d9158e6d8..fd7158c2b 100644 --- a/docs/spec/03-runtime/11-provider-model-system.md +++ b/docs/spec/03-runtime/11-provider-model-system.md @@ -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 diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 4894f933a..486c5ad67 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -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 @@ -10937,7 +10939,10 @@ 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 @@ -10945,7 +10950,8 @@ are withdrawn with ADR 0165. - **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 diff --git a/docs/spec/07-plugins/03-plugin-api.md b/docs/spec/07-plugins/03-plugin-api.md index e16954186..c2601f8a0 100644 --- a/docs/spec/07-plugins/03-plugin-api.md +++ b/docs/spec/07-plugins/03-plugin-api.md @@ -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 diff --git a/docs/spec/08-meta/decisions-log.md b/docs/spec/08-meta/decisions-log.md index dfd59e70c..ad20ac794 100644 --- a/docs/spec/08-meta/decisions-log.md +++ b/docs/spec/08-meta/decisions-log.md @@ -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 diff --git a/docs/zh-CN/spec/03-runtime/11-provider-model-system.md b/docs/zh-CN/spec/03-runtime/11-provider-model-system.md index 6f65423f6..f6037ad97 100644 --- a/docs/zh-CN/spec/03-runtime/11-provider-model-system.md +++ b/docs/zh-CN/spec/03-runtime/11-provider-model-system.md @@ -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. 秘密 diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index 7981d1ab4..9b96c4199 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -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 策略 diff --git a/docs/zh-CN/spec/07-plugins/03-plugin-api.md b/docs/zh-CN/spec/07-plugins/03-plugin-api.md index 013d40654..35b6fc4aa 100644 --- a/docs/zh-CN/spec/07-plugins/03-plugin-api.md +++ b/docs/zh-CN/spec/07-plugins/03-plugin-api.md @@ -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`,但不能用该路径发送或创建工作。宿主执行来源权限 diff --git a/docs/zh-CN/spec/08-meta/decisions-log.md b/docs/zh-CN/spec/08-meta/decisions-log.md index 9f762ec02..2f9cd518b 100644 --- a/docs/zh-CN/spec/08-meta/decisions-log.md +++ b/docs/zh-CN/spec/08-meta/decisions-log.md @@ -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 快照都在 那里。