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:
@ -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}`);
|
||||
|
||||
50
plugins/dsh-mail-bridge/test/permission-note.test.mjs
Normal file
50
plugins/dsh-mail-bridge/test/permission-note.test.mjs
Normal file
@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user