Files
MailUI4Agents/plugins/pi-mail-bridge/test/user-question.test.mjs
JianFeeeee f91efd2d8d 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 全量 全绿。
2026-09-11 10:44:18 +08:00

137 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* `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({}), '(未提供问题)');
});