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..9f13323c3 100644 --- a/docs/adr/0239-session-collaboration-messages.md +++ b/docs/adr/0239-session-collaboration-messages.md @@ -1,8 +1,8 @@ # ADR 0239: Host-owned session collaboration messages -- Status: Accepted +- Status: Accepted; amended by D446 - Date: 2026-09-13 -- Decision: D409 +- Decision: D409 (amended by D446) - Amends: ADR 0237, ADR 0165, ADR 0213 ## Context @@ -69,3 +69,38 @@ Validation covers concurrent senders, durable identity, reuse, queue admission, source preservation across reload, exactly-once callback creation, failure and cancellation results, permission ceilings, model resolution, hover lifecycle, and the relevant host/Electron E2E journeys. + +## Amendment (2026-09-18, D446): a completion notice's own reply may stay silent + +Issue #504: the completion prompt states that a notice needs no +acknowledgement, yet the runtime's silent-turn recovery (spec 02-agent-runtime +§5e) retried the resulting silence and reported `EMPTY_MODEL_RESPONSE` after a +task that had succeeded. This amendment bounds one exception; every other +decision above is unchanged. + +- The first settled assistant reply to a completion notice may end with no + visible text and no tool call without silent-turn recovery or + `EMPTY_MODEL_RESPONSE`. The reply is emitted as a completed message with the + normal terminal lifecycle. Provider errors and aborts keep their handling; + a provider retry of the same attempt keeps the exception. +- The exception covers exactly that reply. It is spent by the first settled + response whether silent, textual, or a tool batch, so a reply that follows + tool results or accepted user steering in the same run is ordinary. It is + also revoked as soon as an accepted steering message enters the model + context, and every new run recomputes it. +- Only provenance that Main resolved from the queued Host ledger record can + enable it: `kind: completion`, the current target session, and nonempty + message and reply-to IDs. Prompt text, plugin or model content, task and + ordinary message deliveries, copied notice framing, and restored history + cannot. No protocol field or caller authority is added. +- An accepted silent reply is still not worth resending. Main persists it as + an empty completed row (hidden by the renderer, `NULL` text in the host), but + the runtime keeps it out of its entries and pi's transcript state, and the + context projection drops any assistant without content blocks, matching what + a restored transcript already did. The next request therefore carries no + empty assistant message for a provider to reject or skip. + +Validation: runtime unit coverage for the accepted shapes, the trust boundary, +tool batches, provider retry, steering before and after the reply, and context +exclusion; `pnpm test:e2e:session-completion` against the real host ledger, +sidecar, and a local SSE provider. See E2E-SESSION-completion-notice-allows-silence. diff --git a/docs/adr/README.md b/docs/adr/README.md index d0278390f..928b5680b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -261,7 +261,7 @@ Each ADR includes: | 0236 | Restore archived projects when session import adds a bound session | Accepted | | 0237 | Keep Session Orchestration in an Official Plugin | Accepted | | 0238 | Prioritize MainChat in the three-column shell | Accepted (amends ADR 0226) | -| 0239 | Host-owned session collaboration messages | Accepted (amends ADR 0237 / 0165 / 0213) | +| 0239 | Host-owned session collaboration messages | Accepted (amends ADR 0237 / 0165 / 0213; amended by D446) | | 0240 | Independent session discovery and navigable collaboration projections | Accepted (amends ADR 0239) | | 0241 | Ship the file view as a vendored, updatable plugin | Accepted (supersedes ADR 0105; issue #304) | | 0242 | Delta-only coalesced streaming updates | Accepted (amends 0127 / 0130 / 0149 / 0153; issue #299) | diff --git a/docs/spec/03-runtime/02-agent-runtime.md b/docs/spec/03-runtime/02-agent-runtime.md index a60a79856..31d0f67bd 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,35 @@ 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 Host-ledger completion notice (ADR 0239, D446) 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 silent notice reply emits its +normal completed message and terminal lifecycle without a recovery request or +`EMPTY_MODEL_RESPONSE`. Provider errors and aborts retain their normal handling, +and a provider retry of the same attempt keeps the exception. The original +task/result is not rewritten, and completion notices never request another +callback. + +The exception covers exactly the notice's own reply: the first settled +assistant response of the run spends it, whether that response is silent, +textual, or a tool batch. A reply that follows tool results is therefore +ordinary work under this section, and the exception is revoked as soon as an +accepted steering message enters the model context, so a reply to the user +keeps its full re-run and error path. Every new run recomputes it from the +prompt's provenance. Ordinary user input, task/message deliveries, copied +source framing, and restored history cannot enable it. + +An accepted silent reply is still not worth resending. Main persists it as an +empty completed row (the transcript hides it and the host stores no text), but +the runtime keeps it out of its entries and out of pi's transcript state, and +the context projection drops any assistant with no content blocks, exactly as +a restored transcript already did. The next provider request therefore carries +no empty assistant message. + +Decision D193 and D446 (ADR 0239 amendment); 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 a8114cb52..b74a86e98 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 | the model ended its turn with no tool call and no visible text twice: once as streamed, once after the automatic re-run; the first reply to a Host-ledger completion notice is exempt (spec 02-agent-runtime §5e, D446) | | `PROMPT_ENHANCEMENT_EMPTY` | no | the one-shot enhancement model returned no text | | `SPEECH_NOT_CONFIGURED` | no | host speech ASR or TTS is not bound in settings | | `SPEECH_PROTOCOL_UNSUPPORTED` | no | the speech protocol is unknown or does not support this role | diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index a1120783c..e85f0133f 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -154,6 +154,7 @@ The minimum selection is: - Imported-extension dependency installation or registry-boundary changes: `pnpm test:e2e:plugin-import-deps`. - Trusted extension or plugin-extension changes: `pnpm test:e2e:trusted-extensions`. - Session collaboration / Session Orchestrator: `pnpm test:e2e:collaboration`. +- Completion-notice silence or the silent-turn contract (D193 / D446): `pnpm test:e2e:session-completion`. - Changes spanning multiple surfaces use the union of the applicable suites. `pnpm test:e2e` is the default cross-system smoke suite for host RPC, IPC, @@ -11290,6 +11291,38 @@ 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`, and no request the recipient sends after + the silent notice carries an empty assistant message. Unit coverage + additionally rejects missing reply-to IDs/wrong targets, spends the exception + on a tool batch, keeps it across a provider retry, revokes it once accepted + user steering enters the context, and keeps the accepted silence out of the + runtime entries and pi transcript state. +- **Specs linked**: `03-runtime/02-agent-runtime.md` §5e, + `03-runtime/08-error-codes.md`, ADR 0239 (D446 amendment) +- **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 77ab22f52..ba9e36102 100644 --- a/docs/spec/08-meta/decisions-log.md +++ b/docs/spec/08-meta/decisions-log.md @@ -83,8 +83,9 @@ 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 | *(amended by D446)* **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. | | 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. | +| D446 | Silent completion notices | **Amend D409 / ADR 0239 and the D193 silent-turn contract for issue #504: the first settled assistant reply to a current Host-resolved completion notice may end with no visible text and no tool call without silent-turn recovery or `EMPTY_MODEL_RESPONSE`. The exception is spent by that reply (silent, textual, or a tool batch), survives a provider retry of the same attempt, and is revoked once accepted user steering enters the model context. Only provenance Main resolved from the Host ledger enables it (`kind: completion`, the current target session, nonempty message and reply-to IDs); prompt text, plugin content, task/message deliveries, and restored history cannot. An accepted silent reply is persisted as an empty completed row but is excluded from runtime entries and model context, as a restored transcript already is. See the ADR 0239 amendment and E2E-SESSION-completion-notice-allows-silence.** | The completion prompt already says no acknowledgement is needed, yet the runtime retried the silence and reported a failure after a task that had succeeded (issue #504). Bounding the exception to one ledger-verified reply keeps ordinary prompts, deliveries, tool work, and user steering under D193, and keeping the empty reply out of context avoids a provider rejecting the next request. | | 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. | | D412 | Delta-only coalesced streaming updates | **Amend the local `message_update` contract: append-only streaming frames carry `stream: delta` plus `deltaText`/`deltaThinking` (and reset flags) without growing `content`/`thinking`. Runtime coalesces those frames every 16ms and flushes before semantic boundaries. AgentHost, inflight checkpoints, and the renderer apply deltas; `message_start`/`message_end` remain full snapshots. Transcript activity parts keep object identity when only the tail token changes. Protocol version stays 11. See ADR 0242, E2E-STREAM-long-turn-keeps-realtime, and issue #299.** | Each token re-serialized the full assistant snapshot across sidecar, AgentHost, and IPC, so a long turn cost O(n²) bytes and backlogged later short chunks. | 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 95771ad0d..32014ba69 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,26 @@ HTTP 429 处理是一个逻辑回合策略。此路径禁用了 pi-ai 的嵌套 可重试的 `EMPTY_MODEL_RESPONSE`,它为转录本提供正常的重试 行动。在这两种情况下都不会保留空的助理消息。 -决定D193;参见 E2E-146。 +Host 账本完成通知(ADR 0239、D446)是唯一例外:其提示已经允许无需确认。 +Main 按 ID 从账本读取排队消息、检查目标会话,再构造来源元数据。只有 +`kind: completion`、目标为当前会话、消息 ID 和回复目标 ID 均非空时,运行时 +才接受静默。静默的通知回复正常发出完成消息和终止生命周期,不重试、不报告 +`EMPTY_MODEL_RESPONSE`。提供商错误和中止仍按原规则处理,同一次尝试的 +provider 重试保留例外;原任务及结果不被改写,完成通知不会要求再次回调。 + +例外只覆盖通知自己的那条回复:本次运行第一条结算的 assistant 回复会消耗它, +无论该回复是静默、有文本还是工具批次。因此紧随工具结果之后的回复按本节普通 +规则处理;被接受的 steering 消息一旦进入模型上下文,例外立即撤销,所以对 +用户的回复保留完整的重跑与报错路径。每次新运行都按提示的来源信息重新计算。 +普通用户输入、task/message 投递、复制的来源文本和恢复的历史记录都不能开启它。 + +被接受的静默回复仍然不值得重发。Main 会把它持久化为一条空的已完成行(转录 +隐藏该行,host 不存文本),但运行时不把它写入自己的条目和 pi 的转录状态, +上下文投影也会丢弃没有内容块的 assistant,与恢复转录时的处理完全一致。因此 +下一次 provider 请求不会带上空的 assistant 消息。 + +决定 D193 与 D446(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 fcb035cbb..cfaeacf51 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、D446) | | `PROMPT_ENHANCEMENT_EMPTY` | 不 | 一次性增强模型没有返回任何文本 | | `SPEECH_NOT_CONFIGURED` | 不 | 设置里没有绑定转写或朗读 | | `SPEECH_PROTOCOL_UNSUPPORTED` | 不 | 语音协议未知或不支持该角色 | 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 4af0a28e6..b7735d795 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 @@ -118,6 +118,7 @@ unit/integration 测试;代码 pull request 使用有选择且高价值的 E2E - 导入扩展依赖安装或 registry 边界改动:`pnpm test:e2e:plugin-import-deps`。 - 受信任扩展或插件扩展改动:`pnpm test:e2e:trusted-extensions`。 - 会话通信 / Session Orchestrator:`pnpm test:e2e:collaboration`。 +- 完成通知静默或静默回合契约(D193 / D446):`pnpm test:e2e:session-completion`。 - 同时涉及多个面的改动使用适用套件的并集。 `pnpm test:e2e` 是 host RPC、IPC、Agent 执行、插件、持久化集成和共享运行时合约的默认跨系统烟雾测试。由于显示、平台、凭据、硬件或其他环境能力缺失而无法运行的必需套件,必须记录为 `NOT RUN`,并说明原因、替代验证和剩余风险。在具备条件且可信的环境中通过前,该 pull request 不具备合入条件。 @@ -7193,6 +7194,30 @@ 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` 结束,且静默通知之后接收方发出的任何请求 + 都不携带空的 assistant 消息。单测另覆盖缺少回复目标 ID、目标不符、工具 + 批次消耗例外、provider 重试保留例外、被接受的用户 steering 进入上下文后 + 撤销例外,以及被接受的静默不进入运行时条目和 pi 转录状态。 +- **关联规格**:`03-runtime/02-agent-runtime.md` §5e、 + `03-runtime/08-error-codes.md`、ADR 0239(D446 修订段) +- **验收**: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 7466e0037..9a2577b83 100644 --- a/docs/zh-CN/spec/08-meta/decisions-log.md +++ b/docs/zh-CN/spec/08-meta/decisions-log.md @@ -86,8 +86,9 @@ | 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 | 宿主拥有的会话协作消息 | *(由 D446 修订)* **修订 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 协议。 | | 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 目录和可导航投影让持久会话可通信、可解释,同时不暴露转录或凭据。 | +| D446 | 完成通知允许静默 | **针对 issue #504 修订 D409 / ADR 0239 及 D193 静默回合契约:对当前由 Host 账本解析的完成通知,其第一条结算的 assistant 回复可以没有可见文本也没有工具调用,而不触发静默回合恢复或 `EMPTY_MODEL_RESPONSE`。该例外由这条回复消耗(无论静默、有文本还是工具批次),在同一次尝试的 provider 重试中保留,并在被接受的用户 steering 进入模型上下文后立即撤销。只有 Main 从 Host 账本解析出的来源信息才能开启它(`kind: completion`、目标为当前会话、消息 ID 与回复目标 ID 非空);提示文本、插件内容、task/message 投递和恢复的历史都不能。被接受的静默回复仍以空的已完成行持久化,但不进入运行时条目和模型上下文,与恢复转录的处理一致。见 ADR 0239 修订段与 E2E-SESSION-completion-notice-allows-silence。** | 完成提示已经声明无需确认,运行时却重试这次静默并在任务已成功后报错(issue #504)。把例外限定在一条经账本校验的回复上,让普通提示、投递、工具工作和用户 steering 继续遵守 D193;空回复不进入上下文,避免下一次请求被 provider 拒绝。 | | 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 可见。 | | D412 | 仅增量且合并的流式更新 | **修订本地 `message_update` 契约:追加型流式帧携带 `stream: delta` 以及 `deltaText`/`deltaThinking`(和 reset 标志),不再附带增长中的 `content`/`thinking`。运行时每 16ms 合并这些帧,并在语义边界前立即 flush。AgentHost、进行中检查点和渲染器应用增量;`message_start`/`message_end` 仍是完整快照。仅尾部 token 变化时,转录活动 part 保持对象身份。协议版本仍为 11。见 ADR 0242、E2E-STREAM-long-turn-keeps-realtime 和 issue #299。** | 每个 token 都在 sidecar、AgentHost 和 IPC 上重新序列化完整助手快照,长回合接近 O(n²) 字节并让后续短块排队变慢。 | diff --git a/package.json b/package.json index 67feeb3e1..ca2834da4 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,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 535ba4858..90189d6fa 100644 --- a/packages/agent-runtime/src/runtime.test.ts +++ b/packages/agent-runtime/src/runtime.test.ts @@ -2880,6 +2880,228 @@ describe("DesktopAgentRuntime thinking configuration", () => { }); describe("DesktopAgentRuntime session collaboration provenance", () => { + const completionOrigin: SessionMessageOrigin = { + messageId: "completion-1", sourceSessionId: "sender", sourceTitle: "Worker", + targetSessionId: "session-1", kind: "completion", replyToMessageId: "task-1", + }; + const eventsOf = (onEvent: ReturnType) => + onEvent.mock.calls.map(([envelope]) => (envelope as AgentEventEnvelope).event); + const emptyModelResponse = expect.objectContaining({ + type: "error", error: expect.objectContaining({ code: "EMPTY_MODEL_RESPONSE" }), + }); + + 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 () => { + // pi appends the reply to its transcript before notifying listeners. + agent.state.messages = [...agent.state.messages, silent]; + 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: completionOrigin }, "notice-user", "notice-turn"); + expect(agent.continue).not.toHaveBeenCalled(); + const notices = eventsOf(onEvent); + 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" }), + })); + // Accepted silence is not resent: neither the runtime entries nor pi's + // transcript carry an empty assistant into the next request. + expect((runtime as any).fullEntries).toHaveLength(0); + expect(agent.state.messages.some((message: { role: string }) => message.role === "assistant")).toBe(false); + expect(buildSessionContext((runtime as any).fullEntries).messages).toEqual([]); + onEvent.mockClear(); + await runtime.prompt("Please answer", "human-user", "human-turn"); + expect(agent.continue).toHaveBeenCalledOnce(); + expect(eventsOf(onEvent)).toContainEqual(emptyModelResponse); + } 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") { + // Steering that pi injects before the first reply: the reply answers + // the user, so the notice exception no longer applies to it. + 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; + const [queued] = [...(runtime as any).pendingSteering.keys()]; + await handle({ type: "message_start", message: queued }); + await handle({ type: "message_end", message: queued }); + } + await respond(); + }); + agent.continue = vi.fn(respond); + agent.waitForIdle = vi.fn(async () => undefined); + const origin: SessionMessageOrigin = { + ...completionOrigin, + targetSessionId: kind === "wrong-session" ? "other-session" : "session-1", + kind: kind === "task" || kind === "message" ? kind : "completion", + }; + if (kind === "unlinked") delete origin.replyToMessageId; + try { + await runtime.prompt(kind === "text-only" ? formatSessionMessage("Task completed", origin) + : { text: "Task completed", sessionMessage: origin }, "user", "notice-turn"); + expect(agent.continue).toHaveBeenCalledOnce(); + expect(eventsOf(onEvent)).toContainEqual(emptyModelResponse); + } finally { await runtime.dispose(); } + }, + ); + + it("spends the notice exception on a tool batch so the post-tool reply keeps recovery", async () => { + const onEvent = vi.fn(); + const runtime = createRuntime({ onEvent }); + const agent = (runtime as any).agent; + const handle = (runtime as any).handleAgentEvent.bind(runtime); + const toolBatch = assistantMessage({ + content: [{ type: "toolCall", id: "call-1", name: "Read", arguments: {} }], + stopReason: "toolUse", + }); + const silent = assistantMessage({ content: [] }); + agent.prompt = vi.fn(async () => { + await handle({ type: "agent_start" }); + await handle({ type: "message_start", message: toolBatch }); + await handle({ type: "message_end", message: toolBatch }); + 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.continue = vi.fn(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.waitForIdle = vi.fn(async () => undefined); + try { + await runtime.prompt({ text: "Task completed", sessionMessage: completionOrigin }, "notice-user", "notice-turn"); + // The model did work and then said nothing about it: D193 applies. + expect(agent.continue).toHaveBeenCalledOnce(); + expect(eventsOf(onEvent)).toContainEqual(emptyModelResponse); + } finally { await runtime.dispose(); } + }); + + it("keeps the notice exception for the retried attempt after a transient stream failure", async () => { + const onEvent = vi.fn(); + const runtime = createRuntime({ onEvent }); + const agent = (runtime as any).agent; + const handle = (runtime as any).handleAgentEvent.bind(runtime); + const failed = assistantMessage({ content: [], stopReason: "error" }); + (failed as { errorMessage?: string }).errorMessage = "terminated"; + const silent = assistantMessage({ content: [] }); + agent.prompt = vi.fn(async () => { + agent.state.messages = [{ role: "user", content: "Task completed", timestamp: 1 }, failed]; + await handle({ type: "message_start", message: failed }); + await handle({ type: "message_end", message: failed }); + await handle({ type: "turn_end" }); + await handle({ type: "agent_end", messages: [] }); + }); + agent.continue = vi.fn(async () => { + agent.state.messages = [...agent.state.messages, silent]; + 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.waitForIdle = vi.fn(async () => undefined); + try { + await runtime.prompt({ text: "Task completed", sessionMessage: completionOrigin }, "notice-user", "notice-turn"); + // One provider retry, then the silent notice reply is accepted as-is. + expect(agent.continue).toHaveBeenCalledOnce(); + const events = eventsOf(onEvent); + expect(events.some((event) => event.type === "error")).toBe(false); + expect(events.filter((event) => event.type === "agent_end")).toHaveLength(1); + expect(events).toContainEqual(expect.objectContaining({ + type: "message_end", message: expect.objectContaining({ status: "complete" }), + })); + expect(agent.state.messages.some((message: { role: string }) => message.role === "assistant")).toBe(false); + } finally { await runtime.dispose(); } + }); + + it("answers user steering after a silent notice reply with full recovery", async () => { + 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: [] }); + agent.prompt = vi.fn(async () => { + agent.state.messages = [...agent.state.messages, silent]; + await handle({ type: "agent_start" }); + await handle({ type: "message_start", message: silent }); + // The user types while the notice reply streams; pi injects the + // steering message after this reply and streams another one. + 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(), + }); + await handle({ type: "message_end", message: silent }); + const [queued] = [...(runtime as any).pendingSteering.keys()]; + agent.state.messages = [...agent.state.messages, queued]; + await handle({ type: "message_start", message: queued }); + await handle({ type: "message_end", message: queued }); + agent.state.messages = [...agent.state.messages, silent]; + await handle({ type: "message_start", message: silent }); + await handle({ type: "message_end", message: silent }); + agent.state.isStreaming = false; + await handle({ type: "turn_end" }); + await handle({ type: "agent_end", messages: [] }); + }); + agent.continue = vi.fn(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.waitForIdle = vi.fn(async () => undefined); + try { + await runtime.prompt({ text: "Task completed", sessionMessage: completionOrigin }, "notice-user", "notice-turn"); + // The notice reply spent the exception silently; the reply to the user + // still gets its one re-run and then the visible error. + expect(agent.continue).toHaveBeenCalledOnce(); + expect(eventsOf(onEvent)).toContainEqual(emptyModelResponse); + } 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 07dac6e8c..76c78cd3e 100644 --- a/packages/agent-runtime/src/runtime.ts +++ b/packages/agent-runtime/src/runtime.ts @@ -1573,6 +1573,12 @@ export class DesktopAgentRuntime { * One automatic re-run per prompt, then the failure becomes visible. */ private pendingSilentTurnRerun = false; private silentTurnRerunAttempted = false; + /** + * The first settled reply to a current Host-ledger completion notice may + * need no acknowledgement (D446). Spent by that reply, and revoked as soon + * as accepted user steering enters the model context. + */ + private allowSilentCompletion = false; private silentTurnRerunInProgress = false; private suppressSilentTurnRunEnd = false; /** Autonomous plan/goal execution: one progress-only continue (#43). */ @@ -5342,6 +5348,7 @@ Delegation rules: this.suppressProviderRetryRunEnd = false; this.pendingSilentTurnRerun = false; this.silentTurnRerunAttempted = false; + this.allowSilentCompletion = false; this.silentTurnRerunInProgress = false; this.suppressSilentTurnRunEnd = false; this.pendingProgressTurnRerun = false; @@ -6679,8 +6686,15 @@ Delegation rules: if (event.message.role === "user") { const steeringId = this.pendingSteering.get(event.message); const id = steeringId ?? this.pendingUserMessageId ?? randomUUID(); - if (steeringId) this.pendingSteering.delete(event.message); - else this.pendingUserMessageId = undefined; + if (steeringId) { + this.pendingSteering.delete(event.message); + // User input is now part of the model context: whatever the model + // says next answers the user, not a completion notice, so the + // ordinary response contract applies again. + this.allowSilentCompletion = false; + } else { + this.pendingUserMessageId = undefined; + } this.appendLiveEntry(id, event.message); break; } @@ -6765,11 +6779,20 @@ Delegation rules: // leaving the user with nothing: the reasoning that may hold the // answer is never rendered. Re-run once with a nudge before letting // that surface as a finished turn. - const silentTurn = + const silence = !failed && !aborted && responseText.trim().length === 0 && !messageRequestsTools(event.message); + // A completion notice needs no acknowledgement, so its own reply may + // stay silent (D446). The exception covers exactly that reply: the + // first settled response spends it, whether silent, textual, or a + // tool batch, so later replies in the same run answer tool results + // or user input under the ordinary contract. A provider failure + // keeps it for the retried attempt. + const exemptSilence = silence && this.allowSilentCompletion; + if (!failed && !aborted) this.allowSilentCompletion = false; + const silentTurn = silence && !exemptSilence; if (silentTurn && !this.silentTurnRerunAttempted) { this.silentTurnRerunAttempted = true; this.pendingSilentTurnRerun = true; @@ -6914,7 +6937,18 @@ Delegation rules: this.compactionEnabled && overflow && !this.overflowRecoveryAttempted; - if (!failed && !aborted && !emptyResponse) { + if (exemptSilence) { + // Accepted silence is still nothing worth resending: keep it out + // of the runtime entries, exactly as a restored transcript would, + // and out of pi's transcript state so the next request carries no + // empty assistant message. pi appends the message before it + // notifies listeners; the identity check keeps this from touching + // anything else should that order ever change. + const messages = this.agent.state.messages; + if (messages.at(-1) === event.message) { + this.agent.state.messages = messages.slice(0, -1); + } + } else if (!failed && !aborted && !emptyResponse) { this.appendLiveEntry(assistantId, event.message); } else { this.turnHadError = true; @@ -7271,6 +7305,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(); diff --git a/packages/agent-runtime/src/session-context.test.ts b/packages/agent-runtime/src/session-context.test.ts index 9c1cad248..3fd3522f2 100644 --- a/packages/agent-runtime/src/session-context.test.ts +++ b/packages/agent-runtime/src/session-context.test.ts @@ -82,6 +82,20 @@ describe("buildSessionContext", () => { ).toBe("ok"); }); + it("drops assistants that have no content blocks", () => { + // D446: an accepted silent completion reply, or any other empty + // assistant, is not worth resending and would be rejected by providers. + const empty = assistant("a1", "", 1); + (empty.message as { content: unknown[] }).content = []; + const messages = buildSessionContext([ + user("u1", "notice", 0), + empty, + user("u2", "next", 2), + assistant("a2", "answer", 3), + ]).messages; + expect(messages.map((message) => message.role)).toEqual(["user", "user", "assistant"]); + }); + it("slices from the newest compaction and puts the summary before the tail", () => { const keptUser = user("u2", "keep me", 3).message; const messages = buildSessionContext([ diff --git a/packages/agent-runtime/src/session-context.ts b/packages/agent-runtime/src/session-context.ts index 176bce50a..5aae7c8de 100644 --- a/packages/agent-runtime/src/session-context.ts +++ b/packages/agent-runtime/src/session-context.ts @@ -24,12 +24,19 @@ import { type ReasoningReplayIdentity, } from "./reasoning-replay.js"; +/** + * Failed, aborted, and deferred assistants are transcript rows, not context. + * An assistant with no content blocks is not worth resending either: the + * runtime never appends one live, a restored transcript drops them, and a + * provider would reject or silently skip it (D446). + */ function isContextMessage(message: AgentMessage): boolean { return ( message.role !== "assistant" || (message.stopReason !== "error" && message.stopReason !== "aborted" && - message.stopReason !== "deferred") + message.stopReason !== "deferred" && + message.content.length > 0) ); } diff --git a/scripts/e2e-session-completion.mjs b/scripts/e2e-session-completion.mjs new file mode 100644 index 000000000..26da188cd --- /dev/null +++ b/scripts/e2e-session-completion.mjs @@ -0,0 +1,181 @@ +#!/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"); + for (const payload of requests.slice(before)) { + const emptyAssistant = payload.messages.filter((message) => + message.role === "assistant" && !message.tool_calls && !(typeof message.content === "string" ? message.content : "").trim()); + assert.deepEqual(emptyAssistant, [], "no request carries an empty assistant message"); + } + 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 }); +}