From 1ec88866ace9a6863d03a88ef6b38803afb83419 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 08:17:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(permission):=20=E5=8F=A6=E5=A4=96=E5=9B=9B?= =?UTF-8?q?=E5=AE=B6=E6=A1=A5=E7=9A=84"=E4=BA=BA=E7=B1=BB=E8=AF=B4?= =?UTF-8?q?=E6=98=8E"=E7=BC=BA=E5=8F=A3=20=E2=80=94=E2=80=94=20=E4=B8=89?= =?UTF-8?q?=E5=AE=B6=E4=BF=AE=E3=80=81=E4=B8=80=E5=AE=B6=E6=9C=AC=E6=9D=A5?= =?UTF-8?q?=E5=B0=B1=E6=9C=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 承接 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 全绿。 --- plugins/dsh-mail-bridge/src/index.ts | 44 ++++++++++++++-- .../test/permission-note.test.mjs | 50 +++++++++++++++++++ plugins/homeagent-mail-bridge/plugin.go | 21 +++++++- plugins/homeagent-mail-bridge/plugin_test.go | 30 +++++++++++ plugins/opencode-mail-bridge/index.js | 32 +++++++++++- .../test/permission-note.test.mjs | 42 ++++++++++++++++ 6 files changed, 213 insertions(+), 6 deletions(-) create mode 100644 plugins/dsh-mail-bridge/test/permission-note.test.mjs create mode 100644 plugins/homeagent-mail-bridge/plugin_test.go create mode 100644 plugins/opencode-mail-bridge/test/permission-note.test.mjs diff --git a/plugins/dsh-mail-bridge/src/index.ts b/plugins/dsh-mail-bridge/src/index.ts index f4d25c8..84a1fa6 100644 --- a/plugins/dsh-mail-bridge/src/index.ts +++ b/plugins/dsh-mail-bridge/src/index.ts @@ -862,7 +862,7 @@ export function apply(ctx: any, config: PluginConfig): void { */ function adoptPrompt(data: any, kind: string): string { if (kind === 'permission') { - return `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || '用户'})。请据此继续后续工作。`; + return permissionPrompt(data); } const fromHuman = data.from_human === true; return [ @@ -974,7 +974,7 @@ export function apply(ctx: any, config: PluginConfig): void { }); } const promptText = kind === 'permission' - ? `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || '用户'})。请据此继续后续工作。` + ? permissionPrompt(data) : [ inboundHeadline({ inReplyTo: data.in_reply_to, @@ -1021,7 +1021,7 @@ export function apply(ctx: any, config: PluginConfig): void { } const promptText = kind === 'permission' - ? `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || '用户'})。请据此继续。` + ? permissionPrompt(data) : [ inboundHeadline({ inReplyTo: data.in_reply_to, @@ -1960,8 +1960,29 @@ export function apply(ctx: any, config: PluginConfig): void { }); /** 人类决策回来:先看是不是在等的那个问题/approval,否则当普通通知投给会话。 */ +/** + * 权限结论的提示词 —— **必须带上人类的说明**。 + * + * 2026-09-13 线上缺陷:人类在界面上写「我说了让你拉取仓库到program下你听不懂吗」, + * 而桥只把 `决策` 一个词给模型(`note` 在 SSE 回包里,没人读)。模型不知道要改什么, + * 把同一条命令换个写法又问了一遍 —— 会话里连问 9 次。 + * + * 三处(续谈/新会话/通知投递)共用这一份:措辞分叉正是这类信息漏掉的地方。 + */ +function permissionPrompt(data: any): string { + const note = typeof data?.note === 'string' ? data.note.trim() : ''; + return ( + `你之前发起的权限请求已有结论:${data?.decision}(决策人:${data?.decided_by || '用户'})。` + + (note ? `\n用户的说明:${note}` : '') + + `\n请据此继续后续工作。` + ); +} + function handlePermissionDecision(data: any): void { const relayKey = String(data?.relay_key ?? ''); + // 决策回执的内容马上随这次恢复交给模型,先记成"已交付",免得那封同 + // 内容的邮件稍后又按新任务起一轮(见 new_mail 分支的注释)。 + if (data?.decision_mail_id) deliveredMails.add(String(data.decision_mail_id)); // 主动提问的回答与权限审批的结构不同(answers[] vs ApprovalOutcome), // 必须分开结算。用 relay_key 查而不是信 data.kind:键本身已经唯一。 @@ -2000,6 +2021,15 @@ export function apply(ctx: any, config: PluginConfig): void { const decision = String(data?.decision ?? ''); const outcome = isApproval(decision) ? 'allowed-once' : 'rejected'; pending.resolve(outcome); + // DSH 的 ApprovalOutcome 只有 allowed-once / rejected / cancelled / unavailable, + // **没有地方放人类的说明**(见 dsh-user-approval 的类型定义)。而说明是模型 + // "下一步该改成什么"的唯一依据 —— 所以带说明的决策另外投一趟通知, + // 多一轮比悄悄丢掉人的指令轻。 + if (typeof data?.note === 'string' && data.note.trim()) { + deliverMail(data, 'permission') + .then(() => console.error(`[dsh-mail-bridge] 决策 ${relayKey} 的说明已单独投递`)) + .catch((e: any) => ctx.logger.error(`[dsh-mail-bridge] 决策说明投递失败: ${e?.message || e}`)); + } ctx.logger.info(`[dsh-mail-bridge] 权限决策 ${relayKey} -> ${outcome}`); return; } @@ -2015,6 +2045,14 @@ export function apply(ctx: any, config: PluginConfig): void { switch (type) { case 'new_mail': if (data?.mail_id) deliveredMails.add(data.mail_id); + // 决策回执不是"新任务":内容已随 permission_decision 交付(或即将交付), + // 再按新邮件投一次 = 同一件事做两遍,还会把人类真正的新邮件挤在队列后面。 + if (data?.mail_type === 'permission_decision') { + deliverMail(data, 'permission') + .then(() => console.error(`[dsh-mail-bridge] 决策回执 ${data.mail_id} 按通知投递`)) + .catch((e: any) => ctx.logger.error(`[dsh-mail-bridge] 决策回执投递失败: ${e?.message || e}`)); + return; + } deliverMail(data, 'mail') .then(({ sessionID, reused }) => { console.error(`[dsh-mail-bridge] ${type} -> ${reused ? '续谈' : '新会话'} ${sessionID}`); diff --git a/plugins/dsh-mail-bridge/test/permission-note.test.mjs b/plugins/dsh-mail-bridge/test/permission-note.test.mjs new file mode 100644 index 0000000..9f0e57e --- /dev/null +++ b/plugins/dsh-mail-bridge/test/permission-note.test.mjs @@ -0,0 +1,50 @@ +/** + * 「人类的说明必须到达模型」—— dsh 桥的接线判据(2026-09-13 线上缺陷)。 + * + * 缺陷现场:人类在界面上拒绝一条 bash 请求并写「我说了让你拉取仓库到program下你 + * 听不懂吗」,而桥只把 `决策` 一个词给模型(`note` 在 SSE 回包里没人读)⇒ 模型把 + * 同一条命令换个写法又问一遍(连问 9 次)。 + * + * # 为什么验源码形态 + * + * dsh 桥的入口是 Cordis 插件工厂,不是可导入的模块(拉起来要整个 Cordis 运行时), + * 而这里要钉住的只有一件事:**那个 note 还在、且落在给模型的那句话上**。 + * 与 pi 桥同源的那条注释是"接线缺口纯函数测不出来"的教训。 + */ +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, '..', 'src', 'index.ts'), 'utf8'); + +test('权限结论的提示词带上了人类的说明', () => { + const fn = SRC.match(/function permissionPrompt\(data: any\): string \{[\s\S]*?\n\}/); + assert.ok(fn, 'permissionPrompt 必须存在'); + assert.match(fn[0], /note/, '提示词里必须用到 note'); + assert.match(fn[0], /用户的说明/, '要显式写成"用户的说明:…",模型才认得出这是人的要求'); +}); + +test('★ 只有一处拼"已有结论"(三处调用点都走同一个 helper)', () => { + const hits = [...SRC.matchAll(/你之前发起的权限请求已有结论/g)]; + assert.equal(hits.length, 1, `应只有 helper 内部那一处,实际 ${hits.length} 处(分叉就是漏信息的地方)`); + assert.match(SRC, /permissionPrompt\(data\)/, '调用点必须用 helper'); +}); + +test('决策回执不再被当成新任务,且记成已交付', () => { + assert.match(SRC, /mail_type === 'permission_decision'/, 'new_mail 分支要认得决策回执'); + assert.match(SRC, /deliveredMails\.add\(String\(data\.decision_mail_id\)\)/, '收到决策时要记下 decision_mail_id'); +}); + +test('平台回执带不了理由 → 带说明的决策要另投一趟通知', () => { + assert.match(SRC, /data\.note\.trim\(\)\)/, '有说明时才另投(空说明不投)'); +}); + +test('★ 判据自检:拿缺陷时的源码形态喂进来必须判红', () => { + const old = "return `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by})。请据此继续后续工作。`;"; + assert.equal(/你之前发起的权限请求已有结论/g.test(old), true); + assert.equal(/用户的说明/.test(old), false, '旧形态里没有"用户的说明" ⇒ 上面那条断言会判红'); + assert.equal([...old.matchAll(/你之前发起的权限请求已有结论/g)].length, 1); +}); diff --git a/plugins/homeagent-mail-bridge/plugin.go b/plugins/homeagent-mail-bridge/plugin.go index 9b82f9c..36b7738 100644 --- a/plugins/homeagent-mail-bridge/plugin.go +++ b/plugins/homeagent-mail-bridge/plugin.go @@ -710,9 +710,14 @@ type mailEvent struct { // PermissionMode 是会话的权限档位(plan / workspace / full)。 // homeagent 无法强制执行任何档位(advisory),只能在提示词里告知模型。 PermissionMode string `json:"permission_mode"` - // Decision / DecidedBy 只在 permission_decision 事件上有值。 + // Decision / DecidedBy / Note 只在 permission_decision 事件上有值。 Decision string `json:"decision"` DecidedBy string `json:"decided_by"` + // Note 是人类决策时写的说明(如「不是让你拉取到 agentmail,是到 program 下」)。 + // + // 缺了它,模型只看到「结论:拒绝」,不知道要改什么 —— 2026-09-13 实测: + // 人类写了三遍说明,模型把同一条命令换个写法又问了一遍(连问 9 次)。 + Note string `json:"note"` } func (p *Plugin) sseLoop() { @@ -1035,11 +1040,23 @@ func (p *Plugin) handleNewMail(evt mailEvent, resumed bool) { // ─── 权限决策 ─── func (p *Plugin) handlePermissionDecision(evt mailEvent) { + p.sdk.InjectText(p.name, outputChannelName, permissionDecisionPrompt(evt)) +} + +// permissionDecisionPrompt 组装"权限结论"那段话。 +// +// 抽成纯函数是为了可判据:**人类的说明必须出现在这里**。2026-09-13 线上缺陷: +// 人类写了「我说了让你拉取仓库到program下你听不懂吗」,而注入的提示词里只有结论 +// 与决策人,模型不知道要改什么,把同一条命令换个写法又问了一遍(连问 9 次)。 +func permissionDecisionPrompt(evt mailEvent) string { prompt := fmt.Sprintf( "你之前发起的权限请求已有结论:%s(决策人:%s)。请据此继续。", evt.Subject, evt.FromName, ) - p.sdk.InjectText(p.name, outputChannelName, prompt) + if note := strings.TrimSpace(evt.Note); note != "" { + prompt += "\n用户的说明:" + note + } + return prompt } // ─── 工具实现 ─── diff --git a/plugins/homeagent-mail-bridge/plugin_test.go b/plugins/homeagent-mail-bridge/plugin_test.go new file mode 100644 index 0000000..cbea057 --- /dev/null +++ b/plugins/homeagent-mail-bridge/plugin_test.go @@ -0,0 +1,30 @@ +package main + +import ( + "strings" + "testing" +) + +// 人类的说明必须出现在注入给模型的提示词里。 +// +// 缺陷现场(2026-09-13):人类在权限决策里写「我说了让你拉取仓库到program下你听不懂吗」, +// 而注入的提示词只有"结论 + 决策人" —— 模型不知道要改什么,把同一条命令又问了一遍。 +func TestPermissionDecisionPromptCarriesNote(t *testing.T) { + const note = "我说了让你拉取仓库到program下你听不懂吗" + got := permissionDecisionPrompt(mailEvent{ + Subject: "拒绝", FromName: "jianf", Note: note, + }) + if !strings.Contains(got, note) { + t.Fatalf("提示词必须原样带上人类的说明,实际:%q", got) + } + + // 反向对照:没写说明时不得凭空造出"用户的说明" + plain := permissionDecisionPrompt(mailEvent{Subject: "同意", FromName: "jianf"}) + if strings.Contains(plain, "用户的说明") { + t.Fatalf("无说明却出现了说明段:%q", plain) + } + // 空白说明同样算没有(ss TrimSpace 过的) + if s := permissionDecisionPrompt(mailEvent{Subject: "拒绝", FromName: "jianf", Note: " "}); strings.Contains(s, "用户的说明") { + t.Fatalf("空白说明不该算说明:%q", s) + } +} diff --git a/plugins/opencode-mail-bridge/index.js b/plugins/opencode-mail-bridge/index.js index 49a08b7..c3b9ecf 100644 --- a/plugins/opencode-mail-bridge/index.js +++ b/plugins/opencode-mail-bridge/index.js @@ -843,6 +843,21 @@ let relaySummaryRef = async () => null; // // relay_key(= opencode 的 permission.id)由服务端随决策事件回传, // 所以插件重启丢了 pendingPermissions 也能续上 —— 这个映射不能只存在内存里。 +/** + * 权限结论的提示词 —— **必须带上人类的说明**。 + * + * 2026-09-13 线上缺陷:人类写「我说了让你拉取仓库到program下你听不懂吗」,而桥只给 + * 模型 `决策` 一个词(`note` 在 SSE 回包里没人读),模型把同一条命令又问了一遍。 + */ +function permissionPrompt(data) { + const note = typeof data?.note === "string" ? data.note.trim() : ""; + return ( + `你之前发起的权限请求已有结论:${data?.decision}(决策人:${data?.decided_by || "用户"})。` + + (note ? `\n用户的说明:${note}` : "") + + `\n请据此继续后续工作。` + ); +} + async function replyPermission(client, directory, data) { const permID = data.relay_key || ""; if (!permID) { @@ -876,6 +891,13 @@ async function replyPermission(client, directory, data) { body: { response }, }); pendingPermissions.delete(permID); + // openCode 的权限回执只有 once/always/reject,**放不下人类的说明**;而说明是模型 + // 下一步该怎么改的唯一依据,所以有它时另外投一趟通知(多一轮 < 悄悄丢掉人的指令)。 + if (typeof data.note === "string" && data.note.trim()) { + await deliverMail(client, directory, data, "permission").catch((e) => + console.error("[mail-bridge] 决策说明投递失败:", e?.message || e), + ); + } console.error(`[mail-bridge] 权限 ${permID} -> ${response}(决策人 ${data.decided_by || "?"})`); return { sessionID }; } @@ -910,7 +932,7 @@ async function deliverMail(client, directory, data, kind) { // 只能在提示词里告知模型档位约束(advisory 路径)。 const permBriefing = modeBriefing({ mode: normalizeMode(data.permission_mode), enforcement: 'advisory', workspace: directory || '' }); const text = kind === "permission" - ? `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || "用户"})。请据此继续后续工作。` + ? permissionPrompt(data) : [ inboundHeadline({ inReplyTo: data.in_reply_to, @@ -1207,6 +1229,14 @@ export default async function mailBridge(input) { if (type !== "new_mail") return; if (data?.mail_id) deliveredMails.add(data.mail_id); + // 决策回执不是"新任务"(内容已随 permission_decision 交付)—— 同 pi 桥那条注释: + // 按新邮件再投一次会把人类真正的新邮件挤在这条会话的队列后面。 + if (data?.mail_type === "permission_decision") { + deliverMail(client, directory, data, "permission").catch((e) => + console.error("[mail-bridge] 决策回执投递失败:", e?.message || e), + ); + return; + } deliverMail(client, directory, data, "mail") .then(({ sessionID, reused }) => { console.error(`[mail-bridge] ${type} -> ${reused ? "续谈" : "新会话"} ${sessionID}`); diff --git a/plugins/opencode-mail-bridge/test/permission-note.test.mjs b/plugins/opencode-mail-bridge/test/permission-note.test.mjs new file mode 100644 index 0000000..6489a3e --- /dev/null +++ b/plugins/opencode-mail-bridge/test/permission-note.test.mjs @@ -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); +});