Files
MailUI4Agents/plugins/pi-mail-bridge/lib/user-question.js
JianFeeeee d616582e96 fix: 回滚 user-question.js 那一搬(它把 check-shared-libs 打红两处),并把 drift 的非运行时差异摘出来
pi 逐处对文件后指出:我按"本平台不可达 ⇒ 搬去 test/lib/"把 `lib/user-question.js`
搬走,打红了 `deploy/check-shared-libs.sh` 两处(实测确认,脚本真退出码 1):

    共用模块缺失:plugins/pi-mail-bridge/lib/user-question.js
    共用测试已分叉:test/user-question.test.mjs(opencode vs pi)

根因不是取舍而是口径:**`lib/` 上挂着两条方向相反的不变量** ——
① 共用模块四方逐字节同源(`check-shared-libs.sh`,连相对路径一起钉);
② 本平台生产可达(我新加的规则)。而 `user-question.js` **是 dsh 桥的生产代码**
(`plugins/dsh-mail-bridge/src/index.ts` 引用它)⇒ 两条必然冲突。
**`lib/` 首先是四桥共用命名空间,其次才是"本平台可达"**;可达性只能当**报告**,
不能当搬家判据。教训的形状:**一条新判据上线时,先找它可能与哪些既有不变量冲突** ——
我只看⻅了自己那条。

改动:
- `user-question.js` 与它的测试回到 `lib/`、`test/`(路径也与 dsh 侧一致),
  两边逐字节相同已复验;`check-shared-libs.sh` 退出码 0。
- `reach.mjs` 增加 `sharedLibNames()`:直接从 `check-shared-libs.sh` 的 `ALL_LIBS`
  读共用清单做豁免(不手抄常量),并把"进快照但本平台不可达"降级为**报告**。
- `layout-boundaries.test.mjs` 增加回归判据:共用模块必须留在 `lib/`、
  测试相对路径与 dsh 一致、两侧逐字节相同。
- 删掉 `reach.mjs` / `docs/DEV-TOOLING.md` 里那句**无据的机制说明**
  ("user-question 走前缀动态 import"):`localRefs` 的三条正则只认引号字面量,
  对模板字面量形状是**盲的** ⇒ 那句若为真,搬走的就是生产代码而两条判据都会绿。
  pi 读了 `src/` 下九个文件都找不到引用,我也确认是记忆偏差;理由改用 `addressing.js`
  (传递可达、`src` 直接引用数为 0)—— 它已足够证明"直接引用数不是可达性"。

顺带按 pi 的第二条建议:`deploy/check-deploy-drift.mjs` 判据 ① 把
**非运行时差异**摘出来(`jsonTestOnlyChange`,只豁免 `scripts.test` 一类字段,
只对"两边都在、仅内容不同"的文件生效)。理由:一条**永远黄、没人打算为它动手**的判据
唯一的下场是被学会忽略,那时真正的运行时漂移会被一起忽略。
⚠️ 摘的条件很窄 —— **把运行时差异误判成非运行时比恒黄更坏(那是假绿)**,
所以 `main`/`start`/`dependencies` 变了、或解析不了,一律仍算运行时;
纯函数加了六个反/正样本的判据(含三个"必须算运行时"的)。

(该文件同时有另一条会话的改动,未提交、我未触碰;本次只加了我这一段。)

验证:`npm test` 463/463;`check-shared-libs.sh` 退出码 0;`--self-check` 18 条全过。
2026-09-14 20:11:58 +08:00

177 lines
6.9 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 询问邮件 的双向映射(纯函数)。
*
* # 为什么需要它
*
* 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;
}