diff --git a/Sources/AgentPetCore/Integration/ExtensionTemplate.swift b/Sources/AgentPetCore/Integration/ExtensionTemplate.swift index 2a1a67c..b62208a 100644 --- a/Sources/AgentPetCore/Integration/ExtensionTemplate.swift +++ b/Sources/AgentPetCore/Integration/ExtensionTemplate.swift @@ -6,7 +6,7 @@ import Foundation /// events the pet needs; those events, the agent's id, its ownership marker and /// the cost note are what vary between the two generated files: /// -/// | what it is for | Pi 0.85 | Oh My Pi 18 | +/// | what it is for | Pi 0.87 | Oh My Pi 18 | /// |---|---|---| /// | "blocked on you" | `ui_prompt_start` (its own prompt is on screen) | `tool_approval_requested` / the `ask` tool | /// | a finished turn | `agent_settled` | `agent_end`, minus the ones carrying `willContinue` | @@ -141,7 +141,32 @@ public enum ExtensionTemplate { return "pid-" + process.pid; } - function send(event, payload) { + // A session only belongs on the panel while a user is driving it. That + // excludes two things at once, and one rule should say both: + // + // - `omp -p` / `--mode json`: no one is watching, and the run is over in + // seconds — the row it drew was noise that vanished again. + // - a subagent session: omp builds its extension runner with + // `initialize(actions, contextActions)` and no mode, so `ctx.mode` + // falls back to the runner's default `"print"` even when the parent + // is an interactive TUI (measured on 18.3.0). A subagent is therefore + // indistinguishable from `-p` by mode, which is what lets one test + // cover both. + // + // The non-interactive modes are named and everything else reports, so a + // mode this file has never heard of still shows up: an extra row is + // visible, while a silent session is impossible to notice. + function isReportable(ctx) { + try { + const mode = ctx && ctx.mode; + return mode !== "print" && mode !== "json"; + } catch { + return true; + } + } + + function send(event, payload, ctx) { + if (!isReportable(ctx)) return; try { // The payload goes through the environment, not stdin. This code // runs inside the agent's own runtime, where a write to a pipe is @@ -169,7 +194,7 @@ public enum ExtensionTemplate { try { const payload = { session_id: sessionIdFor(ctx), cwd: (ctx && ctx.cwd) || "" }; if (toolName) payload.toolName = String(toolName); - send(event, payload); + send(event, payload, ctx); } catch {} } @@ -199,7 +224,7 @@ public enum ExtensionTemplate { function reportContext(ctx) { const context = contextPayload(ctx); - if (context) send("context_update", context); + if (context) send("context_update", context, ctx); } export default function (pi) { diff --git a/Tests/AgentPetCoreTests/OhMyPiTests.swift b/Tests/AgentPetCoreTests/OhMyPiTests.swift index 689e961..497e9e9 100644 --- a/Tests/AgentPetCoreTests/OhMyPiTests.swift +++ b/Tests/AgentPetCoreTests/OhMyPiTests.swift @@ -221,14 +221,61 @@ struct OhMyPiRegistryTests { #expect(profile.detection.configFiles.allSatisfy { $0.path.contains(".omp/agent") }) } - @Test("the shipped extension text is stable enough to diff") - func sourceIsDeterministic() { - // The configurator writes this text and later compares the file to it - // to decide that nothing changed, so it cannot be a function of time, - // locale, or process state. - let first = AgentPetExtension.source(agentID: "omp", shimPath: aShimPath) - let second = AgentPetExtension.source(agentID: "omp", shimPath: aShimPath) - #expect(first == second) - #expect(first.contains("\n"), "source with no newlines is not a file") + @Test("only a session a user is driving is reported") + func reportsOnlyDrivenModes() { + // `omp -p` and a subagent session are both `print` — a subagent's + // runner is built with `initialize(actions, contextActions)` and no + // mode, so it takes the runner's default even under an interactive + // parent (measured on 18.3.0). One test therefore has to cover both, + // and the gate has to sit where every event passes: `send`. + let source = AgentPetExtension.source(agentID: "omp", shimPath: aShimPath) + // How the mode is turned into a decision is pinned wholesale in + // `unknownModeFailsOpen`, below: this one owns where the gate sits and + // who feeds it, not what `isReportable` says. + + // The gate is in `send`, not in one handler: `report` and + // `reportContext` are its only callers, so a handler added later + // cannot grow an unguarded path to the shim. + let sendBody = source.components(separatedBy: "function send(event, payload, ctx) {") + #expect(sendBody.count == 2, "send does not take the ctx it must gate on") + // The gate is the first thing `send` does: before `try`, before the + // spawn. Indentation is the emitter's business, so compare on trimmed + // lines rather than on the exact column. + let firstLines = sendBody[1] + .split(separator: "\n", omittingEmptySubsequences: true) + .map { $0.trimmingCharacters(in: .whitespaces) } + #expect(firstLines.first == "if (!isReportable(ctx)) return;", + "send does not gate before it spawns: \(firstLines.prefix(3))") + + // Both callers hand it the context they were given. + #expect(source.contains("send(event, payload, ctx);")) + #expect(source.contains("send(\"context_update\", context, ctx);")) + } + + @Test("a mode the file has never heard of still reports") + func unknownModeFailsOpen() { + // Naming the silent modes rather than the loud ones is half of it: the + // other half is that nothing gets in front of the return. A default + // (`ctx.mode || "print"`) or a guard (`if (typeof mode !== "string") + // return false;`) leaves the return line intact and still silences a + // context carrying no mode at all — the one outcome the design chose + // against, and the one no user could see. So the whole body is what + // gets pinned here, not just its decision. + let source = AgentPetExtension.source(agentID: "omp", shimPath: aShimPath) + let helper = source.components(separatedBy: "function isReportable(ctx) {") + #expect(helper.count == 2, "the file has no mode test to gate on") + let body = helper[1] + .components(separatedBy: "function send(")[0] + .split(separator: "\n", omittingEmptySubsequences: true) + .map { $0.trimmingCharacters(in: .whitespaces) } + #expect(body == [ + "try {", + "const mode = ctx && ctx.mode;", + "return mode !== \"print\" && mode !== \"json\";", + "} catch {", + "return true;", + "}", + "}", + ], "mode must be read straight off ctx and decided on the return alone: \(body)") } } diff --git a/Tests/AgentPetCoreTests/PiConfiguratorTests.swift b/Tests/AgentPetCoreTests/PiConfiguratorTests.swift index 8c8920d..c2d3531 100644 --- a/Tests/AgentPetCoreTests/PiConfiguratorTests.swift +++ b/Tests/AgentPetCoreTests/PiConfiguratorTests.swift @@ -71,6 +71,17 @@ struct PiConfigurationTests { #expect(known.contains("context_update"), "context_update is sent but unknown") } + @Test("Pi reports only a session a user is driving") + func reportsOnlyDrivenModes() { + // The template is shared, so `pi -p` is silenced by the same rule that + // keeps a subagent off the panel — the one behaviour worth pinning on + // this side, since Pi has no subagents of its own. + let source = PiConfigurator.template(shimPath: piShim) + #expect(source.contains("function isReportable(ctx)")) + #expect(source.contains("mode !== \"print\"") && source.contains("mode !== \"json\"")) + #expect(source.contains("if (!isReportable(ctx)) return;")) + } + @Test("the extension hands the payload over in the environment, not on a pipe") func payloadGoesThroughTheEnvironment() throws { // Ported from PR #1's finding: a write to a pipe from inside the diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1fa6ad9..0923e8b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1104,13 +1104,15 @@ UI 显示的组合状态: 生成文本由 `AgentPetExtension.source(agentID:shimPath:)` 拼出(不是随包资源):能被测试逐条断言,也能在不安装任何东西的情况下读全文——与 `HookSetup.claudeCodeJSON` 同样的理由。它自己只是把参数交给**两个扩展文件共用的模板** `ExtensionTemplate.source(agentID:shimPath:events:marker:)`:两个 Agent 的扩展 API 相同,事件集是显式参数(agent id 与所有权标记也是),其余(shim 调用、会话 id、上下文读数、环境变量通道)全部共享。Pi 的配置器(`PiConfigurator`)走同一个模板的 `.pi` 分支。 -| 用途 | Pi 0.85 发 | Oh My Pi 18 发 | +| 用途 | Pi 0.87 发 | Oh My Pi 18 发 | |---|---|---| | "被扣住了" | `ui_prompt_start`(自己的提示框在屏上) | `tool_approval_requested` / `ask` 工具 | | 一轮结束 | `agent_settled` | `agent_end`(丢掉 `willContinue` 为真的那些) | 一条普通 `agent_end` 在 Pi 上不是结算(之后还可能自动重试或续跑),而 `agent_settled` 在 omp 18.1.22 里**不存在**(逐事件核对过),所以一个文件不可能同时正确;模板按事件集参数化正是为此。写入走 `ConfigTransaction.performText`(快照 → 备份 → 原子写 → 读回验证 → 失败回滚),与 JSON 事务同一套纪律、同一份代码。 +(版本号说明:表头的 **Pi 0.87** 是本机装机 0.87.1 的发行物口径——它的 `dist` 仍声明 `agent_settled` 与 `ui_prompt_start`——**不是**在 0.87.1 上重跑过行为验证。§6.7b 那条 2026-09-15 的实机验收记的是当时的 0.85.1,那里保留原数字不动;`ui_prompt_start` 至今仍未拿到行为证据,见 §6.7b 节尾的先测清单。) + #### 上报什么 扩展监听八个事件、发送九个(多出来的是每次结算后那条上下文读数),全部由它自己发给 shim(`--agent omp --event <名>`)。payload 只有 `session_id` 与 `cwd`;带工具名的那几个事件(`tool_execution_start` 与两个审批事件)多一个 `toolName`。 @@ -1137,13 +1139,25 @@ omp 的扩展是**进程内**的,能改 Agent 行为,所以边界必须写 #### 已知边界 -- 子代理会话若也派发扩展事件,会作为独立 session 出现在面板上(本项目不读 Agent 内部状态,无法可靠区分主/子会话);实测 `-p` 模式下未出现。 +- **面板只画用户正在驾驶的会话**(2026-09-24 改)。判据是扩展上下文里的 `ctx.mode`,不是会话树:omp 给子代理建扩展运行时用的是 `initialize(actions, contextActions)` **两个参数**,没有 mode,于是 `ctx.mode` 取运行时的默认值 `"print"`——**即使父会话是交互式 TUI**(实测 18.3.0)。所以"子代理"和"`omp -p`"在 mode 上是同一种东西,一条规则同时管住两者:`send()` 在 `mode === "print"` 或 `"json"` 时直接返回,不 spawn shim。这意味着: + - 用户敲 `omp` 进 TUI → `mode: "tui"` → 上报,面板有一行。 + - 会话内部起的子代理 → `mode: "print"` → **完全不上报**,不再有独立行。 + - `omp -p` / `--mode json` → `mode: "print"` / `"json"` → **完全不上报**(从前会画一行,几秒后随 `session_shutdown` 消失)。JSON 模式是 `--mode json`——没有 `--json` 这个 flag(`omp --json` 会被当成未知 flag 拒绝)。 + - RPC / ACP → `mode: "rpc"` → 上报,按"有人在驾驶"处理。 + - 未知的 mode → **上报**(fail-open):多点一行看得见,静默了没人会发现。 + - 闸门写在 `send()` 里而不是某个 handler 里:`report()` 与 `reportContext()` 是它仅有的两个调用者,将来新加的 handler 不可能绕过去。 + - **代价**:子代理干活期间面板上看不到"还在忙"。实测异步 spawn(`async.enabled` 默认 true):父会话 t=11.7s 发 `agent_end`,子代理最后的工具在 t=15.76s,父会话 t=15.76s 才重新 `agent_start`——中间约 4 秒父行显示"已完成"。这与本仓库为 Claude Code 记下的那一类是同一个误报(`README.md`:`Stop` lies when a subagent is running;机制见 §5.1b 的 `Stop` 后台任务抑制)。抑制这件事本身有先例:Claude Code 靠默认字段名 `background_tasks`(`NormalizationRule.suppressedByRunningBackgroundTask`,不声明 `backgroundTasksField` 时用它),Grok 显式声明 `backgroundTasks`;缺的是 **omp 侧把这个信号发出来**,omp profile 上也还没有对应的抑制规则。 + - **代价**:只用 `omp -p` 的装机,`lastEventAt` 永远是 nil,`IntegrationHealthEvaluator` 据此判 `.degraded`:卡片上是小圆点变黄、`Connected` 改成 `Degraded`,而"Last event"那一行显示 `none yet`(`AgentsView.lastEventSummary` 的 nil 分支;CLI 的 `--status` 对同一个 nil 打印的是 `never`)。从前任何一次 `-p` 都会写下这个时间戳。 - 面板的**费用**一项对 Oh My Pi 没有数据源(扩展不发 `cost_usd`);模型与上下文占用随每次结算的 `context_update` 上报(§6.7)。 - 扩展在**会话启动时**加载:装完要新起一个 omp 会话才生效。UI 的 degraded / disconnected 文案按机制分支,正是为这句话。 -#### 真机验证(2026-09-15) +#### 真机验证(2026-09-24 改) + +**不要用 `omp -p` 验证。** 这一节原来的配方是 `omp -p` + `AGENTPET_SOCKET` 指向打印信封的 UDS 监听、以六个按序信封为凭证;但 §8.8 的 mode 闸门(见上方"已知边界")让 `omp -p` **一个信封都不发**——`send()` 在 spawn 之前就返回了。照旧配方跑,一个正常的装机会被判成坏了。 + +改用交互式会话(pty 里起 `omp`,真终端,逐字符输入 prompt 再回车):得到 `session_start`(`mode: "tui"`)→ `agent_start` → 工具事件 → `agent_end`,全部带同一个 session id;同一次运行里让模型起一个子代理,则**不出现**任何第二个 session id 的信封(子代理的 `mode` 是 `print`)。同样的警告对 §6.7b 的 Pi 验收成立:那条写的是 headless 会话,照它跑现在会一个信封都没有。 -`--configure omp` 写文件 → 起一个真实 omp 会话(`omp -p`,`AGENTPET_SOCKET` 指向一个打印信封的 UDS 监听)→ 得到按序的 `session_start / agent_start / tool_execution_start(bash) / tool_execution_end / agent_end / session_shutdown`,每个信封都带同一个 session id 与 `/tmp` 工作目录 → `--unconfigure omp` 后文件消失、目录里另外两个扩展原样、`--status` 回到 `extension: 0`。 +`--unconfigure omp` 后文件消失、目录里另外两个扩展原样、`--status` 回到 `extension: 0`(这部分不受闸门影响)。 ---