fix(permission): 另外四家桥的"人类说明"缺口 —— 三家修、一家本来就有

承接 453f451(pi 桥)。用户批准后把同款缺口在其余四家逐一核对:
**zcode 本来就有**(`说明:${decision.note}`),homeagent / dsh / opencode 三家缺。

## homeagent(Go,能完整修)

SSE 事件结构里**根本没有 Note 字段**(json 里只有 decision/decided_by)⇒ 备注在
解码那一步就没了。补上字段,并把提示词抽成纯函数 `permissionDecisionPrompt(evt)`,
加了判据(说明必须出现 + 反向对照:无说明/空白说明不得凭空造出说明段)。
构建(`go build -buildmode=plugin`)后 install 到
`/home/newqqagent/plugins/homeagent-mail-bridge/plugin.bin` 并重启,已核验部署件
含新符号(`grep -a`,中文用 strings 查是查不到的)。

## dsh / opencode(平台回执放不下理由 → 分两步)

两家的审批回执都是**三态字符串**:DSH `ApprovalOutcome` 只有
allowed-once / rejected / cancelled / unavailable,openCode 只有 once / always / reject
—— **没有地方放人类的说明**。所以:

1. 提示词("你之前发起的权限请求已有结论:…")统一走 `permissionPrompt(data)`,
   带上 `用户的说明:…`。dsh 原有**三处**内联文案(续谈/新会话/通知投递),
   措辞分叉正是这类信息漏掉的地方 —— 判据直接钉"只有一处拼这句话"。
2. 带说明的决策**另投一趟通知**,让模型在会话里看到理由。代价是多一轮;比悄悄
   丢掉人的指令轻(原缺陷就是丢了指令,模型把同一条命令换写法又问一遍,连问 9 次)。
3. 决策回执不再被当成"新任务"(内容已随 permission_decision 交付),并记下
   `decision_mail_id` 防重复 —— 与 pi 桥同源。

判据:dsh / opencode 各 5 条(含"拿缺陷时的源码形态喂进来必须判红"的自检)。

## 部署与代价

- dsh → 快照 20260914-081456、opencode → 20260914-081516、homeagent → 新 plugin.bin,
  三家的服务 active 且心跳/连接已核。
- 重启 dsh 时它正在"续谈"一封邮件(08:10:45 日志)——事后核对:那一轮**已回完**
  (faad0037 的 parent = 4919aa88),没有丢活。
- 套件:dsh 372、opencode 323、homeagent go test ok、zcode 382 全绿。
This commit is contained in:
2026-09-14 08:17:23 +08:00
parent ababe4ae56
commit 1ec88866ac
6 changed files with 213 additions and 6 deletions

View File

@ -0,0 +1,42 @@
/**
* 「人类的说明必须到达模型」—— opencode 桥的接线判据2026-09-13 线上缺陷)。
*
* 与 dsh 桥同源:`note` 在 SSE 回包里没人读,模型只拿到「决策」一个词。
* openCode 的权限回执response: once/always/reject**放不下理由**,所以有说明时
* 另外投一趟通知 —— 这条接线也必须被钉住,否则"修了"会随重构悄悄消失。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const SRC = readFileSync(join(HERE, '..', 'index.js'), 'utf8');
test('权限结论的提示词带上了人类的说明', () => {
const fn = SRC.match(/function permissionPrompt\(data\) \{[\s\S]*?\n\}/);
assert.ok(fn, 'permissionPrompt 必须存在');
assert.match(fn[0], /note/);
assert.match(fn[0], /用户的说明/);
});
test('★ 只有一处拼"已有结论"', () => {
assert.equal([...SRC.matchAll(/你之前发起的权限请求已有结论/g)].length, 1);
assert.match(SRC, /permissionPrompt\(data\)/);
});
test('回执之后:有说明就另投一趟通知', () => {
assert.match(SRC, /data\.note\.trim\(\)/);
assert.match(SRC, /deliverMail\(client, directory, data, "permission"\)/);
});
test('决策回执不再被当成新任务', () => {
assert.match(SRC, /mail_type === "permission_decision"/);
});
test('★ 判据自检:旧形态必须判红', () => {
const old = '? `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || "用户"})。请据此继续后续工作。`';
assert.equal(/用户的说明/.test(old), false, '旧形态没有说明段');
assert.equal([...old.matchAll(/你之前发起的权限请求已有结论/g)].length, 1);
});