feat(question): DSH ask_user_question 桥接 + 前端问答面板 + 待办字段全路径透出

问题(P0):DSH 有两个独立的人机交互 seam —— approval/request(危险工具审批)
与 ask_user_question → ctx.userQuestions(模型主动提问)。原来只桥接了前者。
邮件驱动的会话没有本地 UI,而 ask() 的 provider 是 DSH host 注册的本地 UI 实现,
于是在那里等人点选永久等不到,那一轮工具调用**静默挂死**。

修法(不抢注全局 provider —— registerProvider 只允许一个活动实例,抢注会让
平台自己的界面失效):在 tools/execute around-dispatch 里只对**邮件驱动**的
会话接管 ask_user_question,其余原样 next()。失败一律当场报错而不是 next():
下一个 answerer 是本地 UI,邮件会话没有兜底 UI,放过去就是挂死。

- lib/user-question.js(三桥逐字节同源,14 例测试):DSH questions[] ↔ AgentMail
  单问题询问邮件的双向映射。多问题时把选项并集摊平、按 label 归属分配回各问题
  (label 认不出来就不猜测放行);无选项题走自由文本 custom。
- Gateway:kind=question 且无选项时**不再**回落「同意/拒绝」(那会让自由文本
  问题变成两个毫无意义的按钮);主题按类型区分「权限请求 / 需要回答」;
  推送 payload 带上 permission_kind / multi_select / options。
- mails.permission_kind / permission_multi_select 此前只存在于结构体与写入路径,
  五个读路径的 SELECT/Scan 都没带 —— 前端永远拿到空串,把提问渲染成批准/拒绝。
  container 修正五处并加 repo 测试(含反向验证:删掉任一处字段,测试即失败)。
- 前端 PermissionPanel:question 走「勾选 + 自由文本」,多选/单选、空回答禁止提交;
  approval 路径不变(回归测试覆盖)。

测试:opencode 316 / dsh 349 / pi 405 / 前端 185 / Go 全量 全绿。
This commit is contained in:
2026-09-11 10:44:18 +08:00
parent c401eb2da2
commit f91efd2d8d
16 changed files with 1548 additions and 19 deletions

View File

@ -0,0 +1,13 @@
export declare function hasOptions(question: any): boolean;
export declare function optionLabels(question: any): string[];
export declare function questionTitle(question: any): string;
export declare function flattenQuestions(questions: any[]): {
question: string;
options: string[];
context: string;
multiSelect: boolean;
};
export declare function answersFromDecision(questions: any[], decision: string, note?: string): {
answers: Array<{ id: string; selected: string[]; custom?: string }>;
};
export declare function isBlankAnswer(questions: any[], decision: string, note?: string): boolean;

View File

@ -0,0 +1,176 @@
/**
* `ask_user_question` ↔ AgentMail 询问邮件 的双向映射(纯函数)。
*
* # 为什么需要它
*
* DSH 的 `ask_user_question` 走 `ctx.userQuestions` 这个 UI seam而邮件驱动的
* 会话**没有本地 UI**。不桥接的后果是模型主动提问后永久挂死:`ask()` 的
* promise 永远不 resolve那一轮工具调用卡在那里人却什么也看不到。
*
* # 两个模型的形状差异
*
* DSH`{ questions: [{ id, question, header?, options?: [{label, description?}],
* multiSelect? }] }` → `{ answers: [{ id, selected[], custom? }] }`
* - 一次可以问**多个**问题,每个问题可有自己的选项与多选语义
* - 答案按问题 id 回填,选项用 label 字符串
*
* AgentMail一封询问邮件 = 一个问题 + 一个选项列表 + 一个 multi_select 标志
* - 只有单问题结构,因此多问题时必须摊平
*
* # 摊平策略(多问题时)
*
* 选项取所有问题 label 的并集(去重、保持首次出现顺序),并把每个问题的
* 原文、选项与说明枚举进 context 正文。回信时按「label 属于哪个问题」把
* 选择分配回去。这比「一问一封邮件」简单得多 —— 后者要等人分别回复多封
* 才能凑齐一次 `ask()` 的答案,而 `ask()` 是**单次**调用,凑不齐就还是挂死。
*
* # fail closed
*
* 认不出的答案一律不猜测;没有匹配到任何选项的问题返回空选择 + 把自由文本
* 放进 custom而不是随便挑一个 label 放行。
*/
/** 问题是否带可选项。 */
export function hasOptions(question) {
return Array.isArray(question?.options) && question.options.length > 0;
}
/** 一个 DSH 问题的可选项 label 列表(保序)。 */
export function optionLabels(question) {
if (!hasOptions(question)) return [];
return question.options
.map((o) => (typeof o === 'string' ? o : o?.label))
.filter((l) => typeof l === 'string' && l.length > 0);
}
/** 一个问题的展示标题header 有就用它做前缀,否则只用 question。 */
export function questionTitle(question) {
const header = typeof question?.header === 'string' ? question.header.trim() : '';
const text = typeof question?.question === 'string' ? question.question.trim() : '';
if (header && text) return `${header}: ${text}`;
return header || text || '(未提供问题)';
}
/**
* 把 DSH 的 questions 摊平成一封 AgentMail 询问邮件的正文与元数据。
*
* @param {Array<object>} questions DSH AskUserQuestionItem[]
* @returns {{ question: string, options: string[], context: string, multiSelect: boolean }}
*/
export function flattenQuestions(questions) {
const list = Array.isArray(questions) ? questions.filter(Boolean) : [];
if (list.length === 0) {
throw new Error('ask_user_question 至少需要一个 question');
}
const lines = [];
const options = [];
const seen = new Set();
let anyMulti = false;
list.forEach((q, i) => {
const title = questionTitle(q);
lines.push(`${i + 1}. ${title}`);
const detail = typeof q?.detail === 'string' ? q.detail.trim() : '';
if (detail) lines.push(` ${detail}`);
const labels = optionLabels(q);
if (labels.length > 0) {
lines.push(` 可选项:${labels.join(' / ')}${q.multiSelect ? '(可多选)' : ''}`);
for (const label of labels) {
if (!seen.has(label)) {
seen.add(label);
options.push(label);
}
}
} else {
lines.push(' (请直接填写回答)');
}
if (q?.multiSelect === true) anyMulti = true;
});
// 多问题时必须允许多选:不同问题的选项要能一起勾选。
const multiSelect = list.length > 1 ? options.length > 0 : anyMulti;
const question = list.length === 1 ? questionTitle(list[0]) : `${list.length} 个问题待回答`;
const context = [
list.length === 1 ? '' : '模型提出了多个问题,请在「回复」里一并回答:',
...lines,
'',
options.length > 0
? '可直接勾选下方的选项;补充说明写在备注里。'
: '这题没有预设选项,请把回答写在备注里。',
].filter((l) => l !== '').join('\n');
return { question, options, context, multiSelect };
}
/**
* 把人类的决策回写成 DSH 的 answers[]。
*
* @param {Array<object>} questions 原始 DSH questions回填 id 用)
* @param {string} decision 人类选的选项原文(多选时前端用换行分隔)
* @param {string} [note] 自由文本/备注
* @returns {{ answers: Array<{id: string, selected: string[], custom?: string}> }}
*/
export function answersFromDecision(questions, decision, note) {
const list = Array.isArray(questions) ? questions.filter(Boolean) : [];
const labels = String(decision || '')
.split('\n')
.map((s) => s.trim())
.filter(Boolean);
const custom = typeof note === 'string' ? note.trim() : '';
// 单问题:忠实映射(选项 → selected备注 → custom
if (list.length === 1) {
const q = list[0];
const id = String(q?.id ?? '0');
if (!hasOptions(q)) {
// 无选项题:人类把答案写在决策文本或备注里,都属于「自由文本回答」。
const text = custom || labels.join('\n');
return { answers: [{ id, selected: [], ...(text ? { custom: text } : {}) }] };
}
return {
answers: [{
id,
selected: labels,
...(custom ? { custom } : {}),
}],
};
}
// 多问题:按 label 归属把选择分配给各自的问题;备注归给第一个问题。
let customUsed = false;
const answers = list.map((q, i) => {
const id = String(q?.id ?? String(i));
const labels_q = optionLabels(q);
const selected = labels.filter((l) => labels_q.includes(l));
let qCustom;
if (custom && !customUsed) {
qCustom = custom;
customUsed = true;
}
// 无选项题且人没写备注:退而把决策文本整段给它(否则它的答案永远是空的)。
if (qCustom === undefined && !hasOptions(q) && note === undefined) {
const text = labels.join('\n');
if (text) qCustom = text;
}
return { id, selected, ...(qCustom ? { custom: qCustom } : {}) };
});
return { answers };
}
/**
* 决策是否「什么都没答」——用来在提交前拦住空回答(不把空答案喂给模型)。
*
* 允许多选时空 selected 但有 custom 也算答了;两者都空才算没答。
*/
export function isBlankAnswer(questions, decision, note) {
const labels = String(decision || '').split('\n').map((s) => s.trim()).filter(Boolean);
const custom = typeof note === 'string' ? note.trim() : '';
if (labels.length > 0 || custom) return false;
// 全部问题都没有选项、人也没写字 → 确实什么都没答
const list = Array.isArray(questions) ? questions.filter(Boolean) : [];
return list.some((q) => hasOptions(q)) || list.length === 0;
}

View File

@ -62,6 +62,11 @@ import { appendRenameProposal, renameProposalNote } from '../lib/rename-proposal
import { createSSEClient } from '../lib/sse-client.js';
// 只用 isApprovalDSH 没有 always 语义,免批授权表在这里用不上(见决策处的注释)。
import { isApproval } from '../lib/permission-grants.js';
import {
flattenQuestions,
answersFromDecision,
isBlankAnswer,
} from '../lib/user-question.js';
// ─── 凭证管理 ───
@ -210,6 +215,21 @@ interface PendingApproval {
}
const pendingApprovals = new Map<string, PendingApproval>();
// 待决的 ask_user_question 询问。
//
// 与 pendingApprovals 分开:那一边回的是 DSH 的 ApprovalOutcome 枚举字符串,
// 这一边要把人类的回答还原成 DSH 的 `answers[]` 结构。
//
// 同样**不设上界**:静默淘汰一条会让 `ask_user_question` 永远挂死。
// 清理路径确定:决策到达 / 询问被 abort / 拆插件时 fail closed。
interface PendingQuestion {
resolve: (answers: { answers: Array<{ id: string; selected: string[]; custom?: string }> }) => void;
reject: (err: Error) => void;
questions: any[];
sessionId: string;
}
const pendingQuestions = new Map<string, PendingQuestion>();
// 权限被插件主动拒绝时的真正原因 —— 键是 `${agentId}:${callId}`。
//
// 为什么需要这张表DSH 把 `approval/request` 的返回值翻译成模型可见文本时
@ -1690,6 +1710,87 @@ export function apply(ctx: any, config: PluginConfig): void {
});
});
// ─── 模型主动提问ask_user_question→ 转成邮件问人 ───
//
// 这是**第二个**人机交互 seam与上面的 approval/request 完全独立:
// - approval/request危险工具被 DSH 拦下,人批准/拒绝**执行**
// - ask_user_question模型自己需要确认/选择/补充信息,人**回答问题**
//
// 原来只桥接了前者。邮件驱动的会话没有本地 UI而 ask() 的 provider 是
// DSH host 注册的本地 UI 实现 —— 在那里等人点选,人永远看不到,
// promise 永不 resolve那一轮工具调用**永久挂死**。
//
// 为什么拦 `tools/execute` 而不抢注全局 provider
// `ctx.userQuestions.registerProvider` 只允许一个活动 provider第二个抛
// DUPLICATE_PROVIDER而 DSH host 已经注册了本地 UI 那个;抢注会让平台
// 自己的界面失效。around-dispatch 只对**邮件驱动**的会话接管,其余原样交给
// 下一个 wrapper / 平台 UI。
ctx.on('tools/execute', async (exec: any, next: () => Promise<any>) => {
if (exec?.name !== 'ask_user_question') return next();
const agentId = String(exec?.agent?.id ?? '');
if (!agentId || !mailDrivenSessions.has(agentId)) return next();
const mailSessionID = reverseMap.get(agentId);
if (!mailSessionID) return next();
const rawQuestions: any[] = Array.isArray(exec?.arguments?.questions)
? exec.arguments.questions
: [];
const relayKey = clampRelayKey(`${agentId}:ask:${exec?.callId ?? 'nocall'}`);
let flat: { question: string; options: string[]; context: string; multiSelect: boolean };
try {
flat = flattenQuestions(rawQuestions);
} catch (e: any) {
// 问题本身不合法(空列表)→ 当场把错误交回模型,不要挂死
throw new Error(`ask_user_question 参数不合法:${e?.message || e}`);
}
const mctx = mailContexts.get(mailSessionID);
try {
await client.post('/permission/request', {
question: flat.question,
options: flat.options,
context: [
flat.context,
mctx?.subject ? `触发任务:${mctx.subject}` : '',
mctx?.replyTo ? `任务来自:${mctx.replyTo}` : '',
].filter(Boolean).join('\n'),
session_id: mailSessionID,
relay_key: relayKey,
kind: 'question',
multi_select: flat.multiSelect,
});
} catch (e: any) {
// 与 approval 同一条纪律:不会因重试成功的失败必须**当场报错**
// 不能 `return next()` —— 下一个 answerer 是本地 UI而邮件驱动的会话
// 根本没有 UIwaterfall 跑到尾依旧无人应答 = 挂死。
const hint = [e?.body?.error, e?.body?.detail, e?.body?.suggestion]
.filter(Boolean).join(' ');
console.error(`[dsh-mail-bridge] 主动提问转发失败HTTP ${e?.status ?? '?'}${hint || e?.message || e}`);
throw new Error(
`无法把问题送达给人类HTTP ${e?.status ?? '?'}${hint ? `${hint}` : ''}`
+ `请改用不需要人工确认的方式继续,或在最终回复里说明需要人回答什么。`);
}
console.error(`[dsh-mail-bridge] 主动提问已转邮件 ${relayKey}kind=question`);
// 等人类回答DSH 撤销signal abort时以错误结算不装作答过了。
const answers = await new Promise<any>((resolve, reject) => {
pendingQuestions.set(relayKey, {
resolve, reject, questions: rawQuestions, sessionId: agentId,
});
exec.signal?.addEventListener('abort', () => {
if (pendingQuestions.delete(relayKey)) {
reject(new Error('ask_user_question 被取消(人类尚未回答)'));
}
}, { once: true });
});
// around-wrapper 返回的 canonical value 就是工具的输出(见 dsh-tools
// normalizeDispatchResult非错误结果取 .value 交给 output.schema 校验)。
return { isError: false, value: answers } as any;
});
// 把插件主动拒绝的真正原因递给模型。
//
// DSH 将 approval/request 的 'rejected' 翻译成写死的
@ -1724,9 +1825,32 @@ export function apply(ctx: any, config: PluginConfig): void {
};
});
/** 人类决策回来:先看是不是在等的那approval否则当普通通知投给会话。 */
/** 人类决策回来:先看是不是在等的那个问题/approval否则当普通通知投给会话。 */
function handlePermissionDecision(data: any): void {
const relayKey = String(data?.relay_key ?? '');
// 主动提问的回答与权限审批的结构不同answers[] vs ApprovalOutcome
// 必须分开结算。用 relay_key 查而不是信 data.kind键本身已经唯一。
const pendingQ = relayKey ? pendingQuestions.get(relayKey) : undefined;
if (pendingQ) {
pendingQuestions.delete(relayKey);
const decision = String(data?.decision ?? '');
const note = data?.note;
if (isBlankAnswer(pendingQ.questions, decision, note)) {
// 空回答不能当作「答了」:模型会拿到一个什么都没说的结果继续跑。
pendingQ.reject(new Error('人类提交了空回答;请重新提问或改用不需要回答的方式继续。'));
console.error(`[dsh-mail-bridge] 主动提问 ${relayKey} 收到空回答,已拒绝`);
return;
}
try {
pendingQ.resolve(answersFromDecision(pendingQ.questions, decision, note));
console.error(`[dsh-mail-bridge] 主动提问 ${relayKey} 已回答`);
} catch (e: any) {
pendingQ.reject(new Error(`回答解析失败:${e?.message || e}`));
}
return;
}
const pending = relayKey ? pendingApprovals.get(relayKey) : undefined;
if (pending) {
pendingApprovals.delete(relayKey);
@ -1802,6 +1926,11 @@ export function apply(ctx: any, config: PluginConfig): void {
pending.resolve('unavailable');
pendingApprovals.delete(key);
}
// 主动提问没有「unavailable」这种返回值只能以错误结算。
for (const [key, pending] of pendingQuestions) {
pending.reject(new Error('邮件桥已卸载,无法再接收回答'));
pendingQuestions.delete(key);
}
};
}, 'dsh-mail-bridge.sse');
}

View File

@ -0,0 +1,136 @@
/**
* `ask_user_question` ↔ AgentMail 询问邮件 的映射约定。
*
* 三桥共用同一份deploy/check-shared-libs.sh 校验逐字节相同)。
*
* 这里钉住的是**会让模型永久挂死或拿到错答案**的边界:
* - 摊平多问题时选项不能丢、不能重复
* - 回写 answers 时每个问题必须拿到属于自己的选择(不能张冠李戴)
* - 无选项题(自由文本)必须能通过 custom 把话带回去
* - 空回答不能被当成「答了」
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
flattenQuestions,
answersFromDecision,
isBlankAnswer,
hasOptions,
optionLabels,
questionTitle,
} from '../lib/user-question.js';
test('单问题单选项:忠实映射问题与选项', () => {
const flat = flattenQuestions([{
id: 'q1',
question: '用哪种方案?',
header: '选择',
options: [{ label: '方案 A' }, { label: '方案 B' }],
}]);
assert.equal(flat.question, '选择: 用哪种方案?');
assert.deepEqual(flat.options, ['方案 A', '方案 B']);
assert.equal(flat.multiSelect, false);
});
test('单问题多选multiSelect 透传', () => {
const flat = flattenQuestions([{
id: 'q1',
question: '要哪些?',
options: [{ label: 'a' }, { label: 'b' }],
multiSelect: true,
}]);
assert.equal(flat.multiSelect, true);
});
test('多问题选项取并集且去重保序multiSelect 置真', () => {
const flat = flattenQuestions([
{ id: 'q1', question: '前端?', options: [{ label: 'React' }, { label: 'Vue' }] },
{ id: 'q2', question: '后端?', options: [{ label: 'Vue' }, { label: 'Go' }] },
]);
assert.deepEqual(flat.options, ['React', 'Vue', 'Go'], '重复 label 只出现一次');
assert.equal(flat.multiSelect, true, '多问题必须允许多选,否则无法同时回答两题');
assert.match(flat.context, /前端?/);
assert.match(flat.context, /后端?/);
});
test('无选项题options 为空,正文提示直接填写', () => {
const flat = flattenQuestions([{ id: 'q1', question: '你的名字?' }]);
assert.deepEqual(flat.options, []);
assert.match(flat.context, /直接填写|没有预设选项/);
});
test('空问题列表:抛错而不是造一封没有内容的信', () => {
assert.throws(() => flattenQuestions([]), /至少需要一个/);
assert.throws(() => flattenQuestions(undefined), /至少需要一个/);
});
test('单问题回写:选项进 selected备注进 custom', () => {
const qs = [{ id: 'q1', question: '选哪个', options: [{ label: 'A' }, { label: 'B' }] }];
const ans = answersFromDecision(qs, 'A', '再确认下');
assert.deepEqual(ans.answers, [{ id: 'q1', selected: ['A'], custom: '再确认下' }]);
});
test('单问题多选回写:多行决策拆成多个 selected', () => {
const qs = [{ id: 'q1', question: '选哪些', options: [{ label: 'A' }, { label: 'B' }], multiSelect: true }];
const ans = answersFromDecision(qs, 'A\nB', '');
assert.deepEqual(ans.answers[0].selected, ['A', 'B']);
assert.equal(ans.answers[0].custom, undefined, '空备注不该变成空 custom');
});
test('无选项题回写:答案进 customselected 为空', () => {
const qs = [{ id: 'q1', question: '名字?' }];
const ans = answersFromDecision(qs, '', '张三');
assert.deepEqual(ans.answers, [{ id: 'q1', selected: [], custom: '张三' }]);
});
test('无选项题只有决策文本时:文本进 custom否则答案永远为空', () => {
const qs = [{ id: 'q1', question: '名字?' }];
const ans = answersFromDecision(qs, '李四', undefined);
assert.deepEqual(ans.answers, [{ id: 'q1', selected: [], custom: '李四' }]);
});
test('多问题回写:选择按 label 归属分配到各自的问题(不张冠李戴)', () => {
const qs = [
{ id: 'q1', question: '前端?', options: [{ label: 'React' }, { label: 'Vue' }] },
{ id: 'q2', question: '后端?', options: [{ label: 'Go' }, { label: 'Rust' }] },
];
const ans = answersFromDecision(qs, 'Vue\nGo', '都行');
assert.deepEqual(ans.answers[0].selected, ['Vue'], 'q1 只拿前端的选择');
assert.deepEqual(ans.answers[1].selected, ['Go'], 'q2 只拿后端的选择');
assert.equal(ans.answers[0].custom, '都行', '备注归第一个问题');
assert.equal(ans.answers[1].custom, undefined, '备注不重复分发');
});
test('多问题里认不出的 label不匹配任何问题不猜测放行', () => {
const qs = [
{ id: 'q1', question: 'a', options: [{ label: 'X' }] },
{ id: 'q2', question: 'b', options: [{ label: 'Y' }] },
];
const ans = answersFromDecision(qs, 'Z', '');
assert.deepEqual(ans.answers[0].selected, [], '认不出的 label 不得被塞进任意问题');
assert.deepEqual(ans.answers[1].selected, []);
});
test('answers 的 id 与问题一一对应', () => {
const qs = [{ id: 'alpha', question: 'a', options: [{ label: 'X' }] }, { id: 'beta', question: 'b' }];
const ans = answersFromDecision(qs, 'X', 'note');
assert.deepEqual(ans.answers.map((a) => a.id), ['alpha', 'beta']);
});
test('空回答判定:有选项的问题什么都没选 = 空', () => {
const qs = [{ id: 'q1', question: 'a', options: [{ label: 'X' }] }];
assert.equal(isBlankAnswer(qs, '', ''), true);
assert.equal(isBlankAnswer(qs, 'X', ''), false);
assert.equal(isBlankAnswer(qs, '', '自由文本'), false, '自由文本也算答了');
});
test('hasOptions / optionLabels / questionTitle 的边界', () => {
assert.equal(hasOptions({}), false);
assert.equal(hasOptions({ options: [] }), false);
assert.equal(hasOptions({ options: [{ label: 'a' }] }), true);
assert.deepEqual(optionLabels({ options: ['a', { label: 'b' }, { description: 'x' }] }), ['a', 'b']);
assert.equal(questionTitle({ question: '只问一句' }), '只问一句');
assert.equal(questionTitle({}), '(未提供问题)');
});

View File

@ -9,8 +9,8 @@
"esModuleInterop": true,
"declaration": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src"],
"types": ["node"],
"allowJs": true
},
"include": ["src"]
}