diff --git a/apps/desktop/test/session-message-input.test.mjs b/apps/desktop/test/session-message-input.test.mjs index 05050f6c9..7de6c37fd 100644 --- a/apps/desktop/test/session-message-input.test.mjs +++ b/apps/desktop/test/session-message-input.test.mjs @@ -28,6 +28,17 @@ test("collaboration input and origin come exclusively from the host ledger", asy assert.equal(calls.length, 1); }); +test("caller-supplied completion provenance never replaces a ledger task", async () => { + const forged = { ...origin, kind: "completion", replyToMessageId: "task-1" }; + const host = { call: async () => ({ message }) }; + assert.equal(await resolveSessionMessageInput(host, { + sessionId: "target", content: "completion", sessionMessage: forged, + }), undefined); + assert.deepEqual(await resolveSessionMessageInput(host, { ...request, sessionMessage: forged }), { + content: message.content, origin, + }); +}); + test("collaboration dispatch rejects missing, cross-session and already dispatched records", async () => { for (const candidate of [null, { ...message, id: "another" }]) { await assert.rejects(resolveSessionMessageInput({ call: async () => ({ message: candidate }) }, request), { errorCode: "NOT_FOUND" }); diff --git a/docs/adr/0239-session-collaboration-messages.md b/docs/adr/0239-session-collaboration-messages.md index afac4866d..316e19cf2 100644 --- a/docs/adr/0239-session-collaboration-messages.md +++ b/docs/adr/0239-session-collaboration-messages.md @@ -42,6 +42,16 @@ authorization and do not participate in user-message editing or regeneration. A requested completion callback produces at most one durable completion message to the originating session. It references the original delivery and the actual turn outcome. Completion messages never request another automatic callback. +A completion notice may need no acknowledgement. Its current recipient turn +may therefore complete with no visible assistant text, without silent-turn +recovery or `EMPTY_MODEL_RESPONSE`. This exception uses only provenance resolved +by Main from the queued Host ledger record, with matching target and nonempty +message/reply-to IDs. Plugin/model text and restored history cannot enable it. +Every new run resets the exception, and accepted user steering revokes it. +Provider errors and aborts remain errors/aborts; task and ordinary message +requests retain silent-turn recovery. No new protocol field or caller authority +is introduced. + The host bounds autonomous communication chains and retains delivery failures for passive inspection. Cancellation preserves the session and its history. diff --git a/docs/spec/03-runtime/02-agent-runtime.md b/docs/spec/03-runtime/02-agent-runtime.md index 666643d7d..25fa635fc 100644 --- a/docs/spec/03-runtime/02-agent-runtime.md +++ b/docs/spec/03-runtime/02-agent-runtime.md @@ -248,7 +248,7 @@ started on and a proxy is never silently dropped. ### 5e. Silent-turn recovery -A turn that ends with no tool call and no visible assistant text is invisible +An ordinary turn that ends with no tool call and no visible assistant text is invisible to the user: reasoning is never rendered, so a conclusion written only there did not arrive. 15 of 255 recorded sessions ended a turn that way, and the user's only recourse was typing "继续". @@ -289,7 +289,23 @@ If the re-run is silent too, the turn ends as a visible assistant error with retriable `EMPTY_MODEL_RESPONSE`, which gives the transcript its normal retry action. No empty assistant message is persisted in either case. -Decision D193; see E2E-146. +A current Host-ledger completion notice (ADR 0239) is the narrow exception: +its prompt already permits no acknowledgement. Main resolves the queued message +by ID, verifies its target session, and constructs provenance from the ledger. +The runtime accepts silence only for `kind: completion` targeting the current +session with nonempty message and reply-to IDs. A successful silent notice emits +its normal completed message and terminal lifecycle without a recovery request +or `EMPTY_MODEL_RESPONSE`; its actual empty outcome may be persisted. Provider +errors and aborts retain their normal handling. The original task/result is not +rewritten, and completion notices never request another callback. + +The exception belongs only to that prompt. Ordinary user input, task/message +deliveries, copied source framing, and restored history cannot enable it. Every +new run resets it; accepting user steering during a notice revokes it so the +new request must receive the ordinary response/recovery behavior. + +Decision D193 and ADR 0239; see E2E-146 and +E2E-SESSION-completion-notice-allows-silence. ### 5e.1. Progress-only recovery for approved Plan/Goal execution diff --git a/docs/spec/03-runtime/08-error-codes.md b/docs/spec/03-runtime/08-error-codes.md index 7d9b18840..10be51540 100644 --- a/docs/spec/03-runtime/08-error-codes.md +++ b/docs/spec/03-runtime/08-error-codes.md @@ -95,7 +95,7 @@ does not turn temporary thread pressure into a host process exit. | `CONTEXT_TOO_LARGE` | no | prompt/context still exceeds the safe model budget after recovery, the second provider overflow occurred, or automatic recovery is disabled | | `CONTEXT_COMPACTION_FAILED` | no | automatic retained-tail recovery could not prepare, persist, or fit a checkpoint, or manual checkpoint summary generation / durable append failed; the guarded next provider request does not start | | `STREAM_FAILED` | yes | provider stream was terminated, closed prematurely, or otherwise ended before a complete response; up to ten same-turn retries may precede the terminal event | -| `EMPTY_MODEL_RESPONSE` | yes | the model ended its turn with no tool call and no visible text twice: once as streamed, once after the automatic re-run (spec 02-agent-runtime §5e) | +| `EMPTY_MODEL_RESPONSE` | yes | outside a current Host-ledger completion notice, the model ended its turn with no tool call and no visible text twice: once as streamed, once after the automatic re-run (spec 02-agent-runtime §5e) | | `PROMPT_ENHANCEMENT_EMPTY` | no | the one-shot enhancement model returned no text | | `SUBAGENT_IDLE_TIMEOUT` | no | withdrawn (D328): idle watchdogs are not armed; the code remains for stored results | | `SUBAGENT_DURATION_TIMEOUT` | no | withdrawn (D328): duration watchdogs are not armed; the code remains for stored results | diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index fe664b99f..880eceaa9 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -11246,6 +11246,34 @@ are withdrawn with ADR 0165. automated. The live multi-session provider/Electron journey remains runner validation under the no-local-E2E policy +#### E2E-SESSION-completion-notice-allows-silence: A trusted completion notice may finish without an acknowledgement + +- **Preconditions**: A candidate commit has its own built host-core and runtime + sidecar. The local SSE provider deterministically returns visible text or a + successful empty response; no live credentials are required. +- **Steps**: 1) Deliver a task through the real Host collaboration ledger and + sidecar, read its successful result, and complete the coordinator summary. + 2) Resolve the queued completion callback through the production Main input + resolver and run the recipient against an empty SSE response. 3) Run another + human request, copied completion framing, a ledger task, and a ledger message + against empty responses on the same recipient runtime. +- **Expected**: The original result remains unchanged. The completion has one + provider request, no error, one terminal lifecycle, completed ledger status, + and no acknowledgement callback. Each ordinary input still retries once and + ends with `EMPTY_MODEL_RESPONSE`. Unit coverage additionally rejects missing + reply-to IDs/wrong targets and revokes the exception on accepted user steering. +- **Specs linked**: `03-runtime/02-agent-runtime.md` §5e, + `03-runtime/08-error-codes.md`, ADR 0239 +- **Acceptance**: C (conversation & stream), D (provenance), Quality +- **Milestone**: M6+ +- **Status**: Automated by `pnpm test:e2e:session-completion` on the committed, + rebased candidate in its dedicated worktree. The harness drives real Host + RPCs, the production provenance resolver, sidecar, and local SSE, and persists + runtime messages before Host settlement. It does not exercise Electron's + queue/outbox UI or a live provider. Candidate/base SHAs and results belong in + the validation report; existing ledger coverage runs separately through + `pnpm test:e2e:collaboration`. + #### E2E-SESSION-hover-card-model-and-links: Session hover cards expose readable model and creation navigation - **Preconditions**: The app has one collaboration-created session, one diff --git a/docs/spec/08-meta/decisions-log.md b/docs/spec/08-meta/decisions-log.md index 4b95a75d5..d1014ca9a 100644 --- a/docs/spec/08-meta/decisions-log.md +++ b/docs/spec/08-meta/decisions-log.md @@ -83,7 +83,7 @@ This log freezes previously open questions into concrete decisions. | D406 | Keep macOS DMG opening guidance text-only | **Amend D371 / ADR 0204: macOS DMGs expose the opening-help note as `If app won't open, read this.txt` and no longer include the executable `PI-Desktop-macOS-open.command`. macOS ZIP packages retain both the note and the helper. The note provides the narrow Terminal fallback for trusted unsigned builds; signed and notarized builds do not need it. See ADR 0232 and E2E-196b.** | The DMG should keep the normal app-to-Applications flow focused while still giving users a visible, actionable answer when an unsigned app does not open. | | D407 | Restore archived projects after session import | **Additive renderer behavior for issue #250: when a core or plugin import adds a new project-bound session, the import-triggered session refresh normalizes its project path and clears the renderer's archived presentation state for that project. Pathless sessions, skipped imports, historical plugin paths without an active binding, and ordinary refreshes leave archive state unchanged. Host project rows, IPC channels, plugin methods, storage schema, and data formats do not change. See ADR 0236 and E2E-257.** | The host can successfully materialize an imported session under a project while the renderer still hides that project's sidebar row as archived. Restoring only the newly imported binding makes the result discoverable without weakening deliberate archive choices during ordinary refreshes (issue #250). | | D408 | Prioritize MainChat in the three-column shell | **Amend ADR 0226 / ADR 0151 / ADR 0033 for issue #267: MainChat keeps a hard 450px minimum, the work panel is capped by the live budget (`client width - 450px - expanded sidebar`, with no fixed maximum), and the expanded sidebar yields at that threshold — including while `sidebar-out` still occupies flex space. A manual sidebar reopen spends panel width first and otherwise targets 460px; closing the panel restores only a sidebar the layout collapsed. The native window never changes: the reservation seam stays at zero and no geometry is applied. Preview mode temporarily unmounts MainChat and uses a window-level chrome row; collapsed-sidebar macOS preview reserves 88px, or 8px in fullscreen, for traffic lights (D433). See ADR 0238 and E2E-LAYOUT-three-column-width-priority.** | The fixed client area had no explicit width priority, so the side docks could pin MainChat to its floor and leave the composer unusable. Making the yield order explicit keeps the chat readable inside the fixed window without reintroducing native window growth (issue #267). | -| D409 | Host-owned session collaboration messages | **Amend ADR 0237 / ADR 0165 / ADR 0213: Rust host-core owns a durable session-collaboration ledger keyed by message id and real source/target Session IDs. Plugin-mediated `spawn`, `send`, `status`, `result`, and `cancel` operations use the reviewed desktop-control gateway; the sender is bound to the active plugin Agent tool invocation, target turns retain their existing configuration, and each delivery is claimed by its actual durable turn. Completion callbacks are durable, at-most-once, and reference the settled turn. Provenance is persisted with transcript rows and cannot be forged, stripped, or edited through regeneration. The additive schema v16 migration retains queued work across restart without unattended replay, applies permission ceilings and bounded autonomous hops, and keeps the existing Task family unchanged. See ADR 0239 and E2E-PLUGIN-session-orchestrator-real-workers.** | The plugin's prior create/prompt polling path could infer neither a durable turn outcome nor a safe bidirectional sender identity. A host-owned ledger makes delivery, provenance, callback, cancellation, and restart behavior auditable without restoring the withdrawn A2A protocol. | +| D409 | Host-owned session collaboration messages | **Amend ADR 0237 / ADR 0165 / ADR 0213: Rust host-core owns a durable session-collaboration ledger keyed by message id and real source/target Session IDs. Plugin-mediated `spawn`, `send`, `status`, `result`, and `cancel` operations use the reviewed desktop-control gateway; the sender is bound to the active plugin Agent tool invocation, target turns retain their existing configuration, and each delivery is claimed by its actual durable turn. Completion callbacks are durable, at-most-once, and reference the settled turn. Only the current Host-resolved completion prompt may finish silently without empty-response recovery; later runs and accepted user steering restore the ordinary response contract. Provenance is persisted with transcript rows and cannot be forged, stripped, or edited through regeneration. The additive schema v16 migration retains queued work across restart without unattended replay, applies permission ceilings and bounded autonomous hops, and keeps the existing Task family unchanged. See ADR 0239 and E2E-PLUGIN-session-orchestrator-real-workers.** | The plugin's prior create/prompt polling path could infer neither a durable turn outcome nor a safe bidirectional sender identity. A host-owned ledger makes delivery, provenance, callback, cancellation, and restart behavior auditable without restoring the withdrawn A2A protocol. | | D410 | Independent session discovery and navigable collaboration projections | **Amend ADR 0239: add the reviewed read operation `session/collaboration/list`, bounded to 100 non-deleted Agent sessions and redacted to Session IDs, titles, status, updated time, readable provider/model labels, and bounded creation links. Extend the sidebar projection with readable model labels and at most eight created-session references. Render creator/created-session references as keyboard-focusable navigation buttons; independent sessions do not receive fabricated creator links. No renderer storage ownership or collaboration mutation boundary changes. See ADR 0240, E2E-SESSION-independent-top-level-communication, and E2E-SESSION-hover-card-model-and-links.** | Existing Session IDs were valid send targets but could be undiscoverable when they were not created by the plugin, while the hover card exposed only IDs and non-interactive provenance. A bounded host directory and navigable projection make durable sessions communicable and explainable without exposing transcripts or credentials. | | D413 | Skill market public-HTTPS catalog fetch | **Additive: Settings → Skills Market discovers SKILL.md catalogs in Electron main under a shared public-HTTPS policy (syntactic public host + DNS classification + per-hop redirect re-validation). The renderer does not fetch. Install remains `skills.create`. Catalog ids match host `valid_capability_id`. Expanded documents over 128 KiB are refused. Builtin titles are English. See ADR 0243, E2E-SKILL-MARKET-*, issue #287.** | Community skill discovery needs main-process egress without a plugin-marketplace host allowlist, and copied classifiers would collide with the MCP market. | | D421 | Native Pi session continuation | **Amend baseline D007: discover Pi v3 sessions as source-discriminated projections and continue them through coding-agent `AgentSession`/`SessionManager` against their canonical JSONL. Rust remains authoritative for Desktop SQLite/transcripts. Native continuation requires exact saved provider/auth, project trust, canonical path/header identity, and a cooperative lease plus byte/leaf validation; failures remain browseable/read-only. First slice excludes native rename/delete/move/revisions/Plan/Goal/queue/collaboration; the 2026-09-14 ADR 0254 amendment adds native fork with exact stream re-keying and inode-tracked publication; the side-chat panel it added is retired by ADR 0268. See ADR 0254 and E2E-SESSION-native-pi-*.** | Importing a flattened copy cannot preserve Pi's tree or make later Desktop turns visible to Pi Web. | diff --git a/docs/zh-CN/spec/03-runtime/02-agent-runtime.md b/docs/zh-CN/spec/03-runtime/02-agent-runtime.md index e53e3e8f1..43a176986 100644 --- a/docs/zh-CN/spec/03-runtime/02-agent-runtime.md +++ b/docs/zh-CN/spec/03-runtime/02-agent-runtime.md @@ -201,7 +201,7 @@ HTTP 429 处理是一个逻辑回合策略。此路径禁用了 pi-ai 的嵌套 ### 5e。静默回合恢复 -以没有工具调用且没有可见辅助文本结束的回合是不可见的 +以没有工具调用且没有可见辅助文本结束的普通回合是不可见的 对用户:推理永远不会呈现,因此结论只写在那里 没有到达。 255 个录制的会话中有 15 个以这种方式结束了一个回合,并且 用户唯一的办法就是输入“继续”。 @@ -238,7 +238,19 @@ HTTP 429 处理是一个逻辑回合策略。此路径禁用了 pi-ai 的嵌套 可重试的 `EMPTY_MODEL_RESPONSE`,它为转录本提供正常的重试 行动。在这两种情况下都不会保留空的助理消息。 -决定D193;参见 E2E-146。 +当前回合收到的 Host 账本完成通知(ADR 0239)是唯一例外:其提示已经允许 +无需确认。Main 按 ID 从账本读取排队消息、检查目标会话,再构造来源元数据。 +只有 `kind: completion`、目标为当前会话、消息 ID 和回复目标 ID 均非空时, +运行时才允许静默成功。静默通知正常发出完成消息和终止生命周期,不重试、 +不报告 `EMPTY_MODEL_RESPONSE`,可持久化其实际的空结果。提供商错误和中止 +仍按原规则处理;原任务及结果不被改写,完成通知不会要求再次回调。 + +例外仅属于当前提示。普通用户输入、task/message 投递、复制的来源文本和 +恢复的历史记录都不能开启它。每次新运行都会重置;通知执行期间接受用户 +steering 后也会撤销例外,让新请求继续遵守普通响应及恢复规则。 + +决定 D193 和 ADR 0239;参见 E2E-146 与 +E2E-SESSION-completion-notice-allows-silence。 ### 5. 1 上下文检查点保护(D158/D203、ADR 0030/0049/0061/0064) diff --git a/docs/zh-CN/spec/03-runtime/08-error-codes.md b/docs/zh-CN/spec/03-runtime/08-error-codes.md index 7e6b874e0..ca8f68405 100644 --- a/docs/zh-CN/spec/03-runtime/08-error-codes.md +++ b/docs/zh-CN/spec/03-runtime/08-error-codes.md @@ -96,7 +96,7 @@ stdio 与 Tokio 的动态阻塞池隔离,因此后一种情况 | `CONTEXT_TOO_LARGE` | 不 | 恢复后 prompt/context 仍超出安全模型预算、发生第二个提供程序溢出或禁用自动恢复 | | `CONTEXT_COMPACTION_FAILED` | 不 | 自动保留尾部恢复无法准备、持久或适合检查点,或手动检查点摘要生成/持久追加失败;受保护的下一个提供程序请求不会启动 | | `STREAM_FAILED` | 是的 | 提供程序流在完整响应之前终止、提前关闭或以其他方式结束;最多四次同回合重试可能会在终止事件之前发生 | -| `EMPTY_MODEL_RESPONSE` | 是的 | 模型在没有工具调用且没有可见文本的情况下结束了两次:一次是流式传输,一次是在自动重新运行后(规范 02-agent-runtime §5e) | +| `EMPTY_MODEL_RESPONSE` | 是的 | 除当前 Host 账本完成通知外,模型在没有工具调用且没有可见文本的情况下结束了两次:一次是流式传输,一次是在自动重新运行后(规范 02-agent-runtime §5e) | | `PROMPT_ENHANCEMENT_EMPTY` | 不 | 一次性增强模型没有返回任何文本 | | `SUBAGENT_IDLE_TIMEOUT` | 不 | 已撤回(D328):空闲看门狗不再武装;代码仅为已存储结果保留 | | `SUBAGENT_DURATION_TIMEOUT` | 不 | 已撤回(D328):时长看门狗不再武装;代码仅为已存储结果保留 | 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 ede2a73ae..8ef5c8462 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 @@ -7177,6 +7177,28 @@ runner 会在运行时的隔离临时目录中生成六个插件形态 fixture - **里程碑**:M6+ - **状态**:host 发现和双向投递由 `pnpm test:e2e:collaboration` 自动化;插件和 host-core 回归覆盖已自动化。真实 provider/Electron 多会话旅程仍需在具备条件的 runner 中验证,遵循无本地 E2E 策略 +#### E2E-SESSION-completion-notice-allows-silence:可信完成通知允许无需确认即结束 + +- **前提**:候选提交拥有独立构建的 host-core 和 runtime sidecar;本地 SSE + 提供商确定性返回可见文本或成功的空响应,无需真实凭证。 +- **步骤**:1)通过真实 Host 协作账本及 sidecar 投递任务,读取成功结果并完成 + 协调者总结。2)使用生产 Main 输入解析器解析排队的完成回调,再让接收会话 + 收到空 SSE 响应。3)在同一接收运行时依次发送普通用户请求、复制的完成 + 来源文本、账本 task 和 message,并都返回空响应。 +- **预期**:原结果保持不变。完成通知只有一次提供商请求、无错误、一次终止 + 生命周期,账本状态为 completed,且不产生确认回调。每个普通输入仍只重试 + 一次并以 `EMPTY_MODEL_RESPONSE` 结束。单测另覆盖缺少回复目标 ID、目标 + 不符,以及接受用户 steering 后撤销静默例外。 +- **关联规格**:`03-runtime/02-agent-runtime.md` §5e、 + `03-runtime/08-error-codes.md`、ADR 0239 +- **验收**:C(会话与流)、D(来源)、质量 +- **里程碑**:M6+ +- **状态**:由 `pnpm test:e2e:session-completion` 在独立 worktree 中对已提交并 + rebase 的候选版本自动验证。测试驱动真实 Host RPC、生产来源解析器、sidecar + 和本地 SSE,并在 Host 结算前持久化运行时消息;不覆盖 Electron 队列/outbox + 界面或真实提供商。候选与基线 SHA 及结果记录于验证报告;既有账本测试另用 + `pnpm test:e2e:collaboration` 运行。 + #### E2E-SESSION-hover-card-model-and-links:会话 hover 卡片展示可读模型并支持创建关系导航 - **前提条件**:应用中存在一个协作创建的会话、一个独立会话,以及带可读目录名称的 provider/model。侧边栏包含这两个会话。 diff --git a/docs/zh-CN/spec/08-meta/decisions-log.md b/docs/zh-CN/spec/08-meta/decisions-log.md index 94dbaeb04..49bc8111c 100644 --- a/docs/zh-CN/spec/08-meta/decisions-log.md +++ b/docs/zh-CN/spec/08-meta/decisions-log.md @@ -86,7 +86,7 @@ | D406 | macOS DMG 只保留打开说明 | **修订 D371 / ADR 0204:macOS DMG 以 Finder 名称 `If app won't open, read this.txt` 展示打开说明,不再包含或暴露可执行的 `PI-Desktop-macOS-open.command`。macOS ZIP 安装包保留说明和助手。说明为可信未签名构建提供范围明确的终端备用命令;已签名和公证版本无需执行。见 ADR 0232 与 E2E-196b。** | DMG 应保持应用拖入 Applications 的正常安装路径简洁,同时在未签名应用打不开时提供可见且可执行的处理指引。 | | D407 | 导入会话后恢复已归档项目 | **针对 issue #250 的渲染器增量行为:核心或插件导入新增项目绑定会话时,导入触发的会话刷新会规范化项目路径,并清除该项目的渲染器归档状态。无路径会话、跳过的导入、没有活动绑定的插件历史路径和普通刷新保持归档状态不变。host 项目行、IPC 通道、插件方法、存储 schema 和数据格式不变。见 ADR 0236 与 E2E-257。** | host 可以在项目下成功生成导入会话,而渲染器仍将该项目侧边栏行隐藏为已归档。只恢复新导入绑定对应的项目,可以让结果可发现,同时不会在普通刷新时削弱用户的归档选择(issue #250)。 | | D408 | 三栏布局中优先保障 MainChat | **针对 issue #267 修订 ADR 0226 / ADR 0151 / ADR 0033:MainChat 保持 450px 硬下限;工作面板上限为动态预算(`客户端宽度 − 450px − 展开的左栏宽度`,无固定上限);中栏到达阈值时展开的左栏立即让位(`sidebar-out` 退场期间仍计入预算)。手动重开左栏优先占用右栏宽度,否则以 460px 为目标;关闭右栏只恢复由布局机制收起的左栏。原生窗口不变:预留 seam 保持 0 且不套用任何面板几何。预览模式临时卸载 MainChat 并使用窗口级 chrome 行;侧边栏折叠时,macOS 窗口模式预留 88px、全屏预留 8px 给交通灯(D433)。见 ADR 0238 与 E2E-LAYOUT-three-column-width-priority。** | 固定客户区此前没有明确的宽度优先级,侧边停靠可以把 MainChat 压到下限、使 composer 不可用;显式化让位顺序后聊天在固定窗口内保持可读,且不重新引入原生窗口增长(issue #267)。 | -| D409 | 宿主拥有的会话协作消息 | **修订 ADR 0237 / ADR 0165 / ADR 0213:Rust host-core 拥有以消息 id 和真实源/目标 Session ID 为键的持久会话协作 ledger。插件驱动的 `spawn`、`send`、`status`、`result` 和 `cancel` 使用已审查的 desktop-control 网关;发送者绑定当前插件 Agent 工具调用,目标回合保留原有配置,每条投递由实际持久回合认领。完成回调持久化且最多一次,并引用已结算回合。来源信息随转录行持久化,不能在重生成中伪造、剥离或编辑。增量架构 v16 迁移在重启后保留排队工作但不无人值守重放,执行权限上限和有界自主跳数,同时保持既有 Task 系列不变。见 ADR 0239 与 E2E-PLUGIN-session-orchestrator-real-workers。** | 插件之前的创建/提示轮询路径既无法推断持久回合结果,也无法安全确认双向发送者身份。宿主拥有的 ledger 让投递、来源、回调、取消和重启行为可审计,同时不恢复已撤回的 A2A 协议。 | +| D409 | 宿主拥有的会话协作消息 | **修订 ADR 0237 / ADR 0165 / ADR 0213:Rust host-core 拥有以消息 id 和真实源/目标 Session ID 为键的持久会话协作 ledger。插件驱动的 `spawn`、`send`、`status`、`result` 和 `cancel` 使用已审查的 desktop-control 网关;发送者绑定当前插件 Agent 工具调用,目标回合保留原有配置,每条投递由实际持久回合认领。完成回调持久化且最多一次,并引用已结算回合。仅当前由 Host 账本解析的完成提示允许静默结束而不触发空响应恢复;后续运行及接受用户 steering 后恢复普通响应契约。来源信息随转录行持久化,不能在重生成中伪造、剥离或编辑。增量架构 v16 迁移在重启后保留排队工作但不无人值守重放,执行权限上限和有界自主跳数,同时保持既有 Task 系列不变。见 ADR 0239 与 E2E-PLUGIN-session-orchestrator-real-workers。** | 插件之前的创建/提示轮询路径既无法推断持久回合结果,也无法安全确认双向发送者身份。宿主拥有的 ledger 让投递、来源、回调、取消和重启行为可审计,同时不恢复已撤回的 A2A 协议。 | | D410 | 独立会话发现与可导航协作投影 | **修订 ADR 0239:新增经审查的 `session/collaboration/list` 读取操作,限制为最多 100 个未删除 Agent 会话,并只返回 Session ID、标题、状态、更新时间、可读 provider/model 标签和有界创建关系。侧边栏投影增加可读模型标签和最多八个已创建会话引用。创建者/已创建会话引用渲染为可键盘聚焦的导航按钮;独立会话不伪造创建者链接。不改变渲染器存储归属或协作写入边界。见 ADR 0240、E2E-SESSION-independent-top-level-communication 和 E2E-SESSION-hover-card-model-and-links。** | 现有 Session ID 虽然是有效发送目标,但未由插件创建的会话可能不可发现;hover 卡片也只暴露 ID,来源信息不可交互。有界 host 目录和可导航投影让持久会话可通信、可解释,同时不暴露转录或凭据。 | | D413 | 技能市场公网 HTTPS 目录拉取 | **增量:设置 → 技能市场由 Electron 主进程按共享公网 HTTPS 策略发现 SKILL.md(公网主机语法 + DNS 分类 + 逐跳 redirect)。渲染层不发网。安装仍走 `skills.create`。目录 id 与 host `valid_capability_id` 对齐。展开后超过 128 KiB 拒绝写入。内置标题为英文。见 ADR 0243、E2E-SKILL-MARKET-*、issue #287。** | 社区技能发现需要主进程出网,且不能复用插件市场的主机允许列表;复制分类器会与 MCP 市场撞名。 | | D421 | Native Pi 会话续接 | **修订基线 D007:把 Pi v3 会话发现为按来源区分的投影,并通过 coding-agent `AgentSession`/`SessionManager` 针对其规范 JSONL 继续会话。Rust host-core 仍是 Desktop SQLite/Desktop 成绩单的权威;原生回合不进入 Desktop outbox,也不产生导入副本。原生续接要求精确的已存 provider/auth、项目信任、规范路径/头身份,以及带字节/叶节点校验的协作租约;失败保持可浏览/只读。首片不含原生 rename/delete/move/revisions/Plan/Goal/queue/collaboration;2026-09-14 的 ADR 0254 修订加入原生 fork,含精确流重键与 inode 跟踪的发布;该修订加入的侧边聊天面板已由 ADR 0268 移除。见 ADR 0254 与 E2E-SESSION-native-pi-*。** | 导入扁平副本无法保留 Pi 的树结构,也无法让之后 Desktop 的回合对 Pi Web 可见。 | diff --git a/package.json b/package.json index a56f6b745..6ae960d71 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,8 @@ "test:e2e:capability-move": "node scripts/e2e-capability-move.mjs", "test:e2e:plugin-import-deps": "node scripts/e2e-plugin-import-deps.mjs", "test:e2e:trusted-extensions": "node scripts/e2e-trusted-extensions.mjs", - "test:e2e:collaboration": "node scripts/e2e-session-collaboration.mjs" + "test:e2e:collaboration": "node scripts/e2e-session-collaboration.mjs", + "test:e2e:session-completion": "node scripts/e2e-session-completion.mjs" }, "devDependencies": { "@biomejs/biome": "^2.5.13", diff --git a/packages/agent-runtime/src/runtime.test.ts b/packages/agent-runtime/src/runtime.test.ts index 4a191cd3b..3cd16a478 100644 --- a/packages/agent-runtime/src/runtime.test.ts +++ b/packages/agent-runtime/src/runtime.test.ts @@ -2879,6 +2879,94 @@ describe("DesktopAgentRuntime thinking configuration", () => { }); describe("DesktopAgentRuntime session collaboration provenance", () => { + it.each([ + { content: [] }, + { content: [{ type: "text", text: " \n " }] }, + { content: [{ type: "thinking", thinking: "Already handled." }] }, + ])( + "accepts a silent completion notice without exempting the following human prompt (%j)", async ({ content }) => { + const onEvent = vi.fn(); + const runtime = createRuntime({ onEvent }); + const agent = (runtime as any).agent; + const handle = (runtime as any).handleAgentEvent.bind(runtime); + const silent = assistantMessage({ content }); + const respond = async () => { + await handle({ type: "agent_start" }); + await handle({ type: "message_start", message: silent }); + await handle({ type: "message_end", message: silent }); + await handle({ type: "turn_end" }); + await handle({ type: "agent_end", messages: [] }); + }; + agent.prompt = vi.fn(respond); + agent.continue = vi.fn(respond); + agent.waitForIdle = vi.fn(async () => undefined); + try { + await runtime.prompt({ text: "Task completed", sessionMessage: { + messageId: "completion-1", sourceSessionId: "sender", sourceTitle: "Worker", + targetSessionId: "session-1", kind: "completion", replyToMessageId: "task-1", + } }, "notice-user", "notice-turn"); + expect(agent.continue).not.toHaveBeenCalled(); + const notices = onEvent.mock.calls.map(([envelope]) => (envelope as AgentEventEnvelope).event); + expect(notices.filter((event) => event.type === "agent_end")).toHaveLength(1); + expect(notices.some((event) => event.type === "error")).toBe(false); + expect(notices).toContainEqual(expect.objectContaining({ + type: "message_end", message: expect.objectContaining({ status: "complete" }), + })); + onEvent.mockClear(); + await runtime.prompt("Please answer", "human-user", "human-turn"); + expect(agent.continue).toHaveBeenCalledOnce(); + expect(onEvent.mock.calls.map(([envelope]) => (envelope as AgentEventEnvelope).event)).toContainEqual( + expect.objectContaining({ type: "error", error: expect.objectContaining({ code: "EMPTY_MODEL_RESPONSE" }) }), + ); + } finally { + await runtime.dispose(); + } + }); + + it.each(["task", "message", "wrong-session", "unlinked", "text-only", "steered"])( + "retains empty-response recovery for %s input", async (kind) => { + const onEvent = vi.fn(); + const runtime = createRuntime({ onEvent }); + const agent = (runtime as any).agent; + const handle = (runtime as any).handleAgentEvent.bind(runtime); + const silent = assistantMessage({ content: [] }); + const respond = async () => { + await handle({ type: "agent_start" }); + await handle({ type: "message_start", message: silent }); + await handle({ type: "message_end", message: silent }); + await handle({ type: "turn_end" }); + await handle({ type: "agent_end", messages: [] }); + }; + agent.prompt = vi.fn(async () => { + if (kind === "steered") { + agent.state.isStreaming = true; + runtime.steer({ text: "Please answer now" }, "notice-turn", { + id: "steering", role: "user", content: "Please answer now", + status: "complete", createdAt: new Date().toISOString(), + }); + agent.state.isStreaming = false; + } + await respond(); + }); + agent.continue = vi.fn(respond); + agent.waitForIdle = vi.fn(async () => undefined); + const origin: SessionMessageOrigin = { + messageId: "completion-1", sourceSessionId: "sender", sourceTitle: "Worker", + targetSessionId: kind === "wrong-session" ? "other-session" : "session-1", + kind: kind === "task" || kind === "message" ? kind : "completion", + ...(kind !== "unlinked" ? { replyToMessageId: "task-1" } : {}), + }; + try { + await runtime.prompt(kind === "text-only" ? formatSessionMessage("Task completed", origin) + : { text: "Task completed", sessionMessage: origin }, "user", "notice-turn"); + expect(agent.continue).toHaveBeenCalledOnce(); + expect(onEvent.mock.calls.map(([envelope]) => (envelope as AgentEventEnvelope).event)).toContainEqual( + expect.objectContaining({ type: "error", error: expect.objectContaining({ code: "EMPTY_MODEL_RESPONSE" }) }), + ); + } finally { await runtime.dispose(); } + }, + ); + it("frames live input and restored history identically without changing human input", async () => { const origin: SessionMessageOrigin = { messageId: "delivery-1", sourceSessionId: "sender", sourceTitle: "Coordinator", diff --git a/packages/agent-runtime/src/runtime.ts b/packages/agent-runtime/src/runtime.ts index 8e8ca661a..6646406bc 100644 --- a/packages/agent-runtime/src/runtime.ts +++ b/packages/agent-runtime/src/runtime.ts @@ -1568,6 +1568,8 @@ export class DesktopAgentRuntime { * One automatic re-run per prompt, then the failure becomes visible. */ private pendingSilentTurnRerun = false; private silentTurnRerunAttempted = false; + /** Only a current Host-ledger completion notice may need no acknowledgement. */ + private allowSilentCompletion = false; private silentTurnRerunInProgress = false; private suppressSilentTurnRunEnd = false; /** Autonomous plan/goal execution: one progress-only continue (#43). */ @@ -5337,6 +5339,7 @@ Delegation rules: this.suppressProviderRetryRunEnd = false; this.pendingSilentTurnRerun = false; this.silentTurnRerunAttempted = false; + this.allowSilentCompletion = false; this.silentTurnRerunInProgress = false; this.suppressSilentTurnRunEnd = false; this.pendingProgressTurnRerun = false; @@ -6740,6 +6743,7 @@ Delegation rules: // answer is never rendered. Re-run once with a nudge before letting // that surface as a finished turn. const silentTurn = + !this.allowSilentCompletion && !failed && !aborted && responseText.trim().length === 0 && @@ -7245,6 +7249,12 @@ Delegation rules: this.pendingUserMessageId = userMessageId; this.resetRunRecoveryState(); this.autonomousExecution = false; + // Main resolves this provenance from the Host ledger. Never infer it from + // prompt text, model output, extension content, or restored history. + const origin = typeof input === "string" ? undefined : input.sessionMessage; + this.allowSilentCompletion = origin?.kind === "completion" && + origin.targetSessionId === this.sessionId && + Boolean(origin.messageId?.trim() && origin.replyToMessageId?.trim()); this.turnEpoch += 1; this.abortDelegationsFromPreviousTurns(); this.requestStartedAt = Date.now(); @@ -7419,6 +7429,8 @@ Delegation rules: steer(input: RuntimePrompt, expectedTurnId: string, message: UiMessage): { accepted: boolean; turnId: string } { this.steeringContext(expectedTurnId); + // User input accepted during a notice requires the ordinary response contract. + this.allowSilentCompletion = false; const queued: AgentMessage = { role: "user", content: promptContent(input), timestamp: Date.now() }; this.pendingSteering.set(queued, message.id); this.agent.steer(queued); diff --git a/scripts/e2e-session-completion.mjs b/scripts/e2e-session-completion.mjs new file mode 100644 index 000000000..942568d67 --- /dev/null +++ b/scripts/e2e-session-completion.mjs @@ -0,0 +1,176 @@ +#!/usr/bin/env node +/** Real Host ledger + production provenance resolver + sidecar + local SSE. + * The harness persists completed runtime messages and settles the Host turn; + * it does not stand in for a test of Electron's queue/outbox UI. + */ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { mkdtempSync, rmSync } from "node:fs"; +import { createServer } from "node:http"; +import { register } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createInterface } from "node:readline"; +import { fileURLToPath } from "node:url"; +import { Host, resolveHostBinary } from "./e2e/host.mjs"; + +register(new URL("../apps/desktop/test/helpers/ts-import-hooks.mjs", import.meta.url)); +const { resolveSessionMessageInput } = await import("../apps/desktop/electron/main/session-message-input.ts"); +const { formatSessionMessage } = await import("../packages/shared/dist/index.js"); +const scenario = "E2E-SESSION-completion-notice-allows-silence"; +const dataDir = mkdtempSync(join(tmpdir(), "pi-completion-e2e-")); +const host = new Host(resolveHostBinary(), dataDir); +const requests = []; +let responseText = ""; +const server = createServer(async (req, res) => { + let body = ""; + for await (const chunk of req) body += chunk; + const payload = JSON.parse(body); + requests.push(payload); + const base = { id: randomUUID(), object: "chat.completion.chunk", created: 1, model: payload.model }; + res.writeHead(200, { "content-type": "text/event-stream" }); + res.write(`data: ${JSON.stringify({ ...base, choices: [{ index: 0, delta: { role: "assistant", content: responseText }, finish_reason: null }] })}\n\n`); + res.write(`data: ${JSON.stringify({ ...base, choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 } })}\n\n`); + res.end("data: [DONE]\n\n"); +}); +let child; +let lines; +let stderr = ""; +const pending = new Map(); +const events = []; +const send = (message) => child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`); +function rpc(method, params) { + const id = randomUUID(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { pending.delete(id); reject(new Error(`Timeout: ${method}\n${stderr}`)); }, 15_000); + pending.set(id, { resolve, reject, timer }); + send({ id, method, params }); + }); +} +async function until(predicate) { + const deadline = Date.now() + 20_000; + while (!predicate()) { + assert.ok(Date.now() < deadline, `Runtime did not settle\n${stderr}`); + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} +const uiMessage = (role, content) => ({ id: randomUUID(), role, content, status: "complete", createdAt: new Date().toISOString() }); +async function createSession(title) { + return (await host.call("session.create", { title, mode: "agent", projectPath: process.cwd() })).session.id; +} +async function sendDelivery(sourceSessionId, sessionId, kind, notifyOnCompletion = false) { + return (await host.call("session.collaboration.send", { + sourceSessionId, sessionId, kind, pluginId: "pi.session-orchestrator", + content: `Fixture ${kind} request`, idempotencyKey: randomUUID(), notifyOnCompletion, + })).message; +} +let provider; +async function runTurn(sessionId, content, delivery, expectedError = false) { + const resolved = await resolveSessionMessageInput(host, { + sessionId, content, ...(delivery ? { sessionMessageId: delivery.id } : {}), + }); + const turn = await host.call("session.beginTurn", { + sessionId, ...(delivery ? { sessionMessageId: delivery.id } : {}), + }); + const user = uiMessage("user", resolved?.content ?? content); + await host.call("session.appendMessage", { sessionId, turnId: turn.turnId, message: user }); + const before = requests.length; + await rpc("agent.prompt", { + sessionId, turnId: turn.turnId, userMessageId: user.id, content: user.content, + ...(resolved ? { sessionMessage: resolved.origin } : {}), + mode: "agent", provider, thinkingLevel: "off", projectPath: process.cwd(), + commandShell: { id: "bash", label: "Bash", dialect: "posix", available: true, isDefault: true }, + }); + await until(() => events.some((entry) => entry.turnId === turn.turnId && entry.event.type === "agent_end")); + const turnEvents = events.filter((entry) => entry.turnId === turn.turnId).map((entry) => entry.event); + const errors = turnEvents.filter((event) => event.type === "error"); + assert.equal(errors.length, expectedError ? 1 : 0, JSON.stringify(errors)); + if (expectedError) assert.equal(errors[0].error.code, "EMPTY_MODEL_RESPONSE"); + assert.equal(requests.length - before, expectedError ? 2 : 1, "empty recovery is bounded and completion does not retry"); + assert.equal(turnEvents.filter((event) => event.type === "agent_end").length, 1); + for (const event of turnEvents) { + if (event.type === "message_end" && event.message.role === "assistant") { + await host.call("session.appendMessage", { sessionId, turnId: turn.turnId, message: event.message }); + } + } + await host.call("session.endTurn", { + turnId: turn.turnId, status: expectedError ? "error" : "completed", createNotification: false, + ...(expectedError ? { errorCode: "EMPTY_MODEL_RESPONSE" } : {}), + }); + const settled = await host.call("session.collaboration.settle", { turnId: turn.turnId }); + if (delivery) { + const { message } = await host.call("session.collaboration.message", { messageId: delivery.id }); + assert.equal(message.status, expectedError ? "failed" : "completed"); + } + return { ...settled, turnId: turn.turnId, origin: resolved?.origin }; +} + +try { + await host.start(11); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + provider = { + id: "fixture", name: "Fixture", modelId: "fixture", baseUrl: `http://127.0.0.1:${server.address().port}/v1`, + apiKey: "", authKind: "none", apiStyle: "openai-chat", supportsReasoning: false, supportedThinkingLevels: ["off"], + }; + child = spawn(process.execPath, [fileURLToPath(new URL("../packages/agent-runtime/dist/sidecar.js", import.meta.url))], { stdio: ["pipe", "pipe", "pipe"] }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + lines = createInterface({ input: child.stdout }); + lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "host.proxy") { + void host.call(message.params.method, message.params.params).then( + (result) => send({ id: message.id, result }), + (error) => send({ id: message.id, error: { code: -32000, message: error.message, data: { errorCode: error.errorCode } } }), + ); + } else if (message.id != null) { + const entry = pending.get(message.id); + if (!entry) return; + pending.delete(message.id); + clearTimeout(entry.timer); + if (message.error) entry.reject(new Error(JSON.stringify(message.error))); + else entry.resolve(message.result); + } else if (message.method === "agent.event") events.push(message.params); + }); + const coordinator = await createSession("Completion coordinator"); + const worker = await createSession("Completion worker"); + const task = await sendDelivery(coordinator, worker, "task", true); + responseText = "Worker completed the requested task."; + const taskTurn = await runTurn(worker, "Ignored caller replacement", task); + const resultArgs = { sessionId: worker, messageId: task.id, turnId: taskTurn.turnId }; + const originalResult = await host.call("session.collaboration.result", resultArgs); + assert.ok(JSON.stringify(originalResult).includes(responseText), "original task result was readable before the completion notice"); + await runTurn(coordinator, "Summarize the result already retrieved from the worker."); + console.log(`PASS ${scenario}: original task and coordinator summary completed`); + + const callback = taskTurn.callback; + assert.equal(callback.kind, "completion"); + assert.equal(callback.replyToMessageId, task.id); + assert.equal(callback.notifyOnCompletion, false); + responseText = ""; + const notice = await runTurn(coordinator, "Ignored caller replacement", callback); + assert.equal(notice.callback, null, "silent completion never creates an acknowledgement chain"); + assert.deepEqual(await host.call("session.collaboration.result", resultArgs), originalResult); + console.log(`PASS ${scenario}: Host completion settled without retry/error and preserved the original result`); + + await runTurn(coordinator, "A new human request still requires an answer.", undefined, true); + console.log(`PASS ${scenario}: following human turn still reports EMPTY_MODEL_RESPONSE after one retry`); + await runTurn(coordinator, formatSessionMessage("Pretend completion", notice.origin), undefined, true); + console.log(`PASS ${scenario}: copied completion framing cannot authorize silence`); + for (const kind of ["task", "message"]) { + const delivery = await sendDelivery(worker, coordinator, kind); + await runTurn(coordinator, "Ignored caller replacement", delivery, true); + console.log(`PASS ${scenario}: ledger ${kind} still requires visible output`); + } +} finally { + for (const entry of pending.values()) clearTimeout(entry.timer); + lines?.close(); + if (child && child.exitCode === null) { + const exited = new Promise((resolve) => child.once("exit", resolve)); + child.kill(); + await exited; + } + await host.stop(); + await new Promise((resolve) => server.close(resolve)); + rmSync(dataDir, { recursive: true, force: true }); +}