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

@ -587,27 +587,34 @@ function ThreadCard({ mail, onForward }: { mail: Mail; onForward?: () => void })
}
/**
* 权限请求的决策面板
* 待办面板审批型permission与主动提问question共用入口
*
* 两类待办的渲染与提交语义完全不同:
* - permission点「同意/拒绝」当场放行或拦下一个危险操作
* - question模型缺信息人**回答问题**(勾选预设选项 + 自由文本)
*
* 混用一套 UI 的后果很具体:一个问「配置文件叫什么」的问题会被渲染成
* 「同意 / 拒绝」,人只能点个毫无意义的按钮,模型拿到「同意」当答案。
*
* 导出供测试单独渲染:通过整个 MailView 渲染它需要先把 mailStore 与
* sessionStore 摆到「当前正看着一封 permission_request 邮件」的状态,
* 那些铺垫与这个组件本身的行为无关。
*/
export function PermissionPanel({ mail }: { mail: Mail }) {
const isQuestion = mail.permission_kind === 'question';
const [note, setNote] = useState('');
const [busy, setBusy] = useState(false);
const [decided, setDecided] = useState(mail.permission_result || '');
// 问题模式下已勾选的选项(多选时是多个)。
const [picked, setPicked] = useState<string[]>([]);
const fetchInbox = useMailStore(s => s.fetchInbox);
const selectSession = useSessionStore(s => s.selectSession);
const options = mail.permission_options?.length ? mail.permission_options : ['同意', '拒绝'];
const isApprove = (s: string) => /同意|允许|批准|approve|yes/i.test(s);
const decide = async (choice: string) => {
const submit = async (decision: string, noteText: string) => {
setBusy(true);
try {
await api.decidePermission(mail.mail_id, choice, note || undefined);
setDecided(choice);
await api.decidePermission(mail.mail_id, decision, noteText || undefined);
setDecided(decision || '(自由文本回答)');
await fetchInbox('all');
if (mail.session_id) selectSession(mail.session_id);
} catch (err) {
@ -620,18 +627,93 @@ export function PermissionPanel({ mail }: { mail: Mail }) {
if (decided) {
return (
<div className="mt-3 pt-2.5 border-t border-orange-200 text-xs text-gray-600">
<strong className="text-gray-800">{decided}</strong>
<strong className="text-gray-800 whitespace-pre-wrap">{decided}</strong>
</div>
);
}
// ─── 主动提问:勾选 + 自由文本 ───
if (isQuestion) {
const options = mail.permission_options ?? [];
const multi = mail.permission_multi_select === true;
const toggle = (opt: string) => {
setPicked(prev => {
if (multi) return prev.includes(opt) ? prev.filter(p => p !== opt) : [...prev, opt];
// 单选:再点同一项则取消,否则替换
return prev.includes(opt) ? [] : [opt];
});
};
// 回答必须非空:空提交会让模型拿到一个什么都没说的结果继续跑。
const blank = picked.length === 0 && note.trim().length === 0;
return (
<div className="mt-3 pt-3 border-t border-orange-200">
<div className="text-[11px] text-gray-500 mb-2">
{options.length === 0
? '这题没有预设选项,请直接填写回答:'
: multi ? '可多选,也可补充说明:' : '请选择一项,也可补充说明:'}
</div>
{options.length > 0 && (
<div className="flex flex-wrap gap-2">
{options.map(opt => {
const on = picked.includes(opt);
return (
<button
key={opt}
onClick={() => toggle(opt)}
disabled={busy}
aria-pressed={on}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border transition-colors disabled:opacity-40 ${
on
? 'bg-blue-700 text-white border-blue-700 hover:bg-blue-800'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
}`}
>
{on && <CheckIcon className="w-3.5 h-3.5" />}
{opt}
</button>
);
})}
</div>
)}
<textarea
value={note}
onChange={e => setNote(e.target.value)}
rows={options.length === 0 ? 3 : 2}
placeholder={options.length === 0 ? '你的回答(必填)' : '补充说明(可选)'}
className="mt-2 w-full text-xs border border-gray-300 rounded-md px-2.5 py-1.5 resize-y focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
<div className="mt-2 flex items-center gap-2">
<button
onClick={() => submit(picked.join('\n'), note.trim())}
disabled={busy || blank}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md bg-blue-700 text-white hover:bg-blue-800 disabled:opacity-40"
>
</button>
{blank && (
<span className="text-[11px] text-gray-400"></span>
)}
</div>
</div>
);
}
// ─── 审批型:批准 / 拒绝 ───
const options = mail.permission_options?.length ? mail.permission_options : ['同意', '拒绝'];
const isApprove = (s: string) => /同意|允许|批准|approve|yes/i.test(s);
return (
<div className="mt-3 pt-3 border-t border-orange-200">
<div className="flex flex-wrap gap-2">
{options.map(opt => (
<button
key={opt}
onClick={() => decide(opt)}
onClick={() => submit(opt, note)}
disabled={busy}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors disabled:opacity-40 ${
isApprove(opt)

View File

@ -100,6 +100,16 @@ export interface Mail {
mail_type: 'normal' | 'permission_request';
permission_options: string[] | null;
permission_result: string | null;
/**
* 待办类型:`permission`(危险操作审批)/ `question`(模型主动提问)。
*
* 两者共用 permission_request 这个 mail_type但**该渲染什么完全不同**
* 前者是「批准 / 拒绝」,后者是「回答问题」(勾选 + 自由文本)。
* 混用一套 UI 会让人把「回答问题」当成「批准执行」。
*/
permission_kind?: string;
/** 仅 question 使用:是否允许多选(对应 DSH 的 multi_select。 */
permission_multi_select?: boolean;
status: 'unread' | 'read' | 'archived';
created_at: string;
hop_limit?: number;

View File

@ -201,3 +201,126 @@ describe('PermissionPanel 决策', () => {
expect(cls('算了')).toContain('text-red-700');
});
});
/**
* 模型主动提问permission_kind='question')。
*
* 与审批型共用 permission_request 这个 mail_type但语义完全不同
* 这里是「回答问题」而不是「批准执行」。断言集中在两件事:
* - 不能把问题渲染成同意/拒绝(那会让人点出一个毫无意义的答案)
* - 空回答不能提交(模型会拿到一个什么都没说的结果继续跑)
*/
describe('PermissionPanel 回答问题', () => {
beforeEach(() => {
vi.restoreAllMocks();
useMailStore.setState({ fetchInbox: vi.fn(async () => {}) } as any);
useSessionStore.setState({ selectSession: vi.fn(async () => {}) } as any);
});
const questionMail = (over: Partial<Mail> = {}): Mail =>
permMail({ permission_kind: 'question', ...over });
it('问题不带选项时:不渲染同意/拒绝,只给自由文本', () => {
render(React.createElement(PermissionPanel, { mail: questionMail({ permission_options: [] }) }));
expect(screen.queryByRole('button', { name: /同意/ })).toBeNull();
expect(screen.queryByRole('button', { name: /拒绝/ })).toBeNull();
expect(screen.getByPlaceholderText('你的回答(必填)')).toBeInTheDocument();
});
it('问题带选项时:渲染选项按钮(而不是同意/拒绝)', () => {
render(
React.createElement(PermissionPanel, {
mail: questionMail({ permission_options: ['方案 A', '方案 B'] })
})
);
expect(screen.getByRole('button', { name: /方案 A/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /方案 B/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^同意$/ })).toBeNull();
});
it('单选:再点已选项会取消,不会同时选中两个', async () => {
render(
React.createElement(PermissionPanel, {
mail: questionMail({ permission_options: ['A', 'B'] })
})
);
const a = screen.getByRole('button', { name: /^A$/ });
const b = screen.getByRole('button', { name: /^B$/ });
await userEvent.click(a);
expect(a).toHaveAttribute('aria-pressed', 'true');
await userEvent.click(b);
expect(b).toHaveAttribute('aria-pressed', 'true');
expect(a).toHaveAttribute('aria-pressed', 'false');
});
it('多选:可同时选中多项,提交时用换行拼接', async () => {
const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
render(
React.createElement(PermissionPanel, {
mail: questionMail({ permission_options: ['A', 'B'], permission_multi_select: true })
})
);
await userEvent.click(screen.getByRole('button', { name: /^A$/ }));
await userEvent.click(screen.getByRole('button', { name: /^B$/ }));
await userEvent.click(screen.getByRole('button', { name: /提交回答/ }));
// 服务端按换行拆分多选答案,不能拼接成 "AB" 或数组字符串
await waitFor(() => expect(spy).toHaveBeenCalledWith('m-1', 'A\nB', undefined));
});
it('空回答禁止提交(模型不能拿到一个什么都没说的结果)', async () => {
const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
render(React.createElement(PermissionPanel, { mail: questionMail({ permission_options: [] }) }));
const submit = screen.getByRole('button', { name: /提交回答/ });
expect(submit).toBeDisabled();
expect(screen.getByText('请先选择或填写回答')).toBeInTheDocument();
await userEvent.type(screen.getByPlaceholderText('你的回答(必填)'), '配置在 /etc/foo.conf');
expect(submit).toBeEnabled();
await userEvent.click(submit);
await waitFor(() =>
expect(spy).toHaveBeenCalledWith('m-1', '', '配置在 /etc/foo.conf')
);
});
it('选了选项又写了备注:两者都发出去', async () => {
const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
render(
React.createElement(PermissionPanel, {
mail: questionMail({ permission_options: ['方案 A'] })
})
);
await userEvent.click(screen.getByRole('button', { name: /方案 A/ }));
await userEvent.type(screen.getByPlaceholderText('补充说明(可选)'), '但要先备份');
await userEvent.click(screen.getByRole('button', { name: /提交回答/ }));
await waitFor(() =>
expect(spy).toHaveBeenCalledWith('m-1', '方案 A', '但要先备份')
);
});
it('问题已回答过:显示结论,不再显示任何输入控件', () => {
render(
React.createElement(PermissionPanel, {
mail: questionMail({ permission_result: '方案 A' })
})
);
expect(screen.getByText(/已处理/)).toBeInTheDocument();
expect(screen.queryByRole('button')).toBeNull();
});
it('审批型(无 permission_kind仍然走同意/拒绝路径', () => {
render(React.createElement(PermissionPanel, { mail: permMail() }));
// 回归防护question 分支不能把普通审批也带走
expect(screen.getByRole('button', { name: /同意/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /提交回答/ })).toBeNull();
});
});

View File

@ -12,7 +12,7 @@ PEERS=(plugins/dsh-mail-bridge plugins/pi-mail-bridge)
fail=0
for peer in "${PEERS[@]}"; do
for f in relay-dedup relay-policy relay-key permission-mode bounded inbox-format session-snapshot workspace model-scope catchup addressing discovery rename-proposal permission-grants adopt sse-client; do
for f in relay-dedup relay-policy relay-key permission-mode bounded inbox-format session-snapshot workspace model-scope catchup addressing discovery rename-proposal permission-grants adopt sse-client user-question; do
if [[ ! -f "$peer/lib/$f.js" ]]; then
echo "共用模块缺失:$peer/lib/$f.js" >&2
fail=1
@ -26,7 +26,7 @@ for peer in "${PEERS[@]}"; do
done
# 测试同样要同源:共用模块的行为约定写在测试里,
# 只同步实现不同步测试,等于允许一侧偷偷放宽约定。
for f in relay-policy relay-key permission-mode bounded inbox-format session-snapshot workspace model-scope catchup addressing discovery rename-proposal permission-grants adopt sse-client; do
for f in relay-policy relay-key permission-mode bounded inbox-format session-snapshot workspace model-scope catchup addressing discovery rename-proposal permission-grants adopt sse-client user-question; do
if [[ ! -f "$peer/test/$f.test.mjs" ]]; then
echo "共用测试缺失:$peer/test/$f.test.mjs" >&2
fail=1

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"]
}

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

@ -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

@ -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

@ -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

@ -69,7 +69,13 @@ func RequestPermission(w http.ResponseWriter, r *http.Request) {
}
options := req.Options
if len(options) == 0 {
if len(options) == 0 && kind != "question" {
// 审批型询问必须给两个可点选项,否则人在界面上无以为答。
//
// 主动询问question不同它可能根本没有预设选项 ——
// 那是「请把你的名字告诉我」「请把报错贴给我」这类自由文本问题。
// 给它们塞「同意/拒绝」会让人只能选一个毫无意义的答案,
// 而模型拿到的 selected 里也会是这种噪音。
options = []string{"同意", "拒绝"}
}
@ -107,7 +113,7 @@ func RequestPermission(w http.ResponseWriter, r *http.Request) {
repo.TouchSession(r.Context(), sessionID)
} else {
// workspace 空串:权限询问不经三维寻址,没有 path 位可归属。
id, err := repo.CreateSession(r.Context(), nil, agentName, "权限请求: "+req.Question, "")
id, err := repo.CreateSession(r.Context(), nil, agentName, mailSubjectFor(kind, req.Question), "")
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to create session")
return
@ -239,10 +245,16 @@ func RequestPermission(w http.ResponseWriter, r *http.Request) {
"mail_id": mailID.String(),
"session_id": sessionID.String(),
"from_name": agentName,
"subject": "权限请求: " + req.Question,
"subject": mailSubjectFor(kind, req.Question),
"mail_type": "permission_request",
"role": "to",
"session_alias": alias,
// 待办类型与多选语义必须随推送下发:前端靠它们决定渲染
// 「批准/拒绝」还是「回答问题」(勾选 + 自由文本)。
// 不下发的话前端只能重查一次,而授权页是靠这条推送实时更新的。
"permission_kind": kind,
"permission_multi_select": req.MultiSelect,
"permission_options": options,
})
JSON(w, http.StatusOK, map[string]string{
@ -253,6 +265,18 @@ func RequestPermission(w http.ResponseWriter, r *http.Request) {
})
}
// mailSubjectFor 给待办邮件起主题。
//
// 审批型与主动询问是两类不同的待办,主题必须一眼能区分:「权限请求」意味着
// 有人要被放行一个危险操作,「需要回答」只是模型缺信息。混用一套措辞会让人
// 在授权页里把「回答问题」当成「批准执行」。
func mailSubjectFor(kind, question string) string {
if kind == "question" {
return "需要回答: " + question
}
return "权限请求: " + question
}
// POST /api/v1/permission/decide —— 需登录;只有该权限请求的收件人或管理员可决策
func DecidePermission(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)

View File

@ -0,0 +1,191 @@
package repo
import (
"context"
"testing"
"github.com/agentmail/gateway/internal/models"
)
// 待办类型permission / question与多选语义必须在**每一条读路径**上透出。
//
// # 为什么值得单独钉一个测试
//
// 这几个字段mails.permission_kind / permission_multi_select曾经只存在于
// 结构体与写入路径模型里有字段、INSERT 里有列,但五个读路径的 SELECT/Scan
// 都没带上它们。
//
// 这类遗漏不会报错 —— SQL 照常返回,只是那个字段永远为零值。前端于是拿到
// permission_kind="" 与 permission_multi_select=false把「模型问了一个问题」
// 渲染成「批准 / 拒绝」两个按钮,人点出来的答案对模型毫无意义。
//
// 所以判据必须落在「五个不同的读函数都看得见」上,而不是只看写入那一侧。
func TestPermissionFieldsVisibleOnEveryReadPath(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, err := CreateSession(ctx, nil, "agent-q", "问与答", "")
if err != nil {
t.Fatal(err)
}
// 一个自由文本问题:无预设选项、不允许多选。
mailID, err := CreatePermissionMail(ctx, sid, "agent-q", "alice",
"配置文件名是什么", "请直接回答", nil, "question", false)
if err != nil {
t.Fatal(err)
}
if err := CreatePermissionRequest(ctx, mailID, sid, "agent-q",
"配置文件名是什么", nil, "请直接回答", "question", false); err != nil {
t.Fatal(err)
}
// 一个多选问题:单独造一封,验证 multi_select 也能透出。
multiID, err := CreatePermissionMail(ctx, sid, "agent-q", "alice",
"要哪些环境", "勾选", []string{"dev", "prod"}, "question", true)
if err != nil {
t.Fatal(err)
}
if err := CreatePermissionRequest(ctx, multiID, sid, "agent-q",
"要哪些环境", []string{"dev", "prod"}, "勾选", "question", true); err != nil {
t.Fatal(err)
}
// 一封普通审批kind 必须是 permissionmulti 必须是 false。
approvalID, err := CreatePermissionMail(ctx, sid, "agent-q", "alice",
"删除 build/", "rm -rf build/", []string{"同意", "拒绝"}, "permission", false)
if err != nil {
t.Fatal(err)
}
assertQuestion := func(what string, m *models.Mail) {
t.Helper()
if m == nil {
t.Fatalf("%s 返回 nil", what)
}
if m.PermissionKind != "question" {
t.Errorf("%sPermissionKind = %q期望 question"+
"(字段没进 SELECT/Scan 时会静默变成空串,前端就会渲染成批准/拒绝)",
what, m.PermissionKind)
}
}
// 1) GetMailByID —— 单封详情(前端点开邮件走这条)。
got, err := GetMailByID(ctx, mailID)
if err != nil {
t.Fatal(err)
}
assertQuestion("GetMailByID", got)
// 2) ListInbox —— 授权页的列表来源。
inbox, err := ListInbox(ctx, "alice", "all", 50)
if err != nil {
t.Fatal(err)
}
var found *models.Mail
for i := range inbox {
if inbox[i].ID == mailID {
found = &inbox[i]
}
}
if found == nil {
t.Fatal("ListInbox 没返回那封询问邮件")
}
assertQuestion("ListInbox", found)
// 3) GetSessionMails —— 会话视图整树。
thread, err := GetSessionMails(ctx, sid)
if err != nil {
t.Fatal(err)
}
var inThread *models.Mail
for i := range thread {
if thread[i].ID == mailID {
inThread = &thread[i]
}
}
if inThread == nil {
t.Fatal("GetSessionMails 没返回那封询问邮件")
}
assertQuestion("GetSessionMails", inThread)
// 4) GetSessionMailByID —— 会话内单封。
one, err := GetSessionMailByID(ctx, sid, mailID)
if err != nil {
t.Fatal(err)
}
assertQuestion("GetSessionMailByID", one)
// 5) ListSentBy —— 发件箱Agent 自查「我问过什么」)。
sent, err := ListSentBy(ctx, "agent-q", 50)
if err != nil {
t.Fatal(err)
}
var inSent *models.Mail
for i := range sent {
if sent[i].ID == mailID {
inSent = &sent[i]
}
}
if inSent == nil {
t.Fatal("ListSentBy 没返回那封询问邮件")
}
assertQuestion("ListSentBy", inSent)
// multi_select 也要透出:勾选语义决定前端渲染单选还是多选。
multi, err := GetMailByID(ctx, multiID)
if err != nil {
t.Fatal(err)
}
if !multi.PermissionMulti {
t.Error("多选问题PermissionMulti = false期望 true字段漏读会让多选退化成单选")
}
// 审批型不能被误标成 question。
approval, err := GetMailByID(ctx, approvalID)
if err != nil {
t.Fatal(err)
}
if approval.PermissionKind != "permission" {
t.Errorf("审批邮件PermissionKind = %q期望 permission", approval.PermissionKind)
}
if approval.PermissionMulti {
t.Error("审批邮件不该带多选语义")
}
}
// 无选项的 question 不能被塞上「同意 / 拒绝」默认选项。
//
// 那是 handler 层的默认值逻辑:审批型没有选项时确实该给两个按钮,
// 但自由文本问题给它们只会让人点出一个毫无意义的答案。
// 这里从 repo 侧确认「传什么就存什么」handler 的默认值规则另有测试。
func TestQuestionKeepsEmptyOptions(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, err := CreateSession(ctx, nil, "agent-q2", "自由文本", "")
if err != nil {
t.Fatal(err)
}
mailID, err := CreatePermissionMail(ctx, sid, "agent-q2", "alice",
"你的名字", "请回答", nil, "question", false)
if err != nil {
t.Fatal(err)
}
// GetPermissionByMailID 读的是 permission_requests 表(不是 mails
// 所以必须把那一行也插上 —— 否则拿到的是「没有这条请求」。
if err := CreatePermissionRequest(ctx, mailID, sid, "agent-q2",
"你的名字", nil, "请回答", "question", false); err != nil {
t.Fatal(err)
}
pr, err := GetPermissionByMailID(ctx, mailID)
if err != nil {
t.Fatal(err)
}
if pr.Kind != "question" {
t.Errorf("Kind = %q期望 question", pr.Kind)
}
if len(pr.Options) != 0 {
t.Errorf("无选项问题被写入了选项 %v期望空", pr.Options)
}
}

View File

@ -343,10 +343,16 @@ func CreatePermissionMail(ctx context.Context, sessionID uuid.UUID, fromName, to
multiSelectInt = 1
}
var id uuid.UUID
// 主题按待办类型区分:「权限请求」意味着有人要放行一个危险操作,
// 「需要回答」只是模型缺信息。授权页与收件箱都靠这一行措辞判断该做什么。
subject := "权限请求: " + question
if kind == "question" {
subject = "需要回答: " + question
}
err := db.DB.QueryRowContext(ctx,
`INSERT INTO mails (session_id, from_name, from_workspace, to_name, subject, body, mail_type, permission_options, permission_kind, permission_multi_select, created_at)
VALUES ($1, $2, COALESCE((SELECT workspace FROM sessions WHERE session_id = $1), ''), $3, $4, $5, 'permission_request', $6, $7, $8, NOW()) RETURNING mail_id`,
sessionID, fromName, toUser, "权限请求: "+question, body, optsJSON, kind, multiSelectInt,
sessionID, fromName, toUser, subject, body, optsJSON, kind, multiSelectInt,
).Scan(&id)
return id, err
}
@ -390,6 +396,8 @@ func GetMailByID(ctx context.Context, id uuid.UUID) (*models.Mail, error) {
`SELECT m.mail_id, m.session_id, m.parent_mail_id,
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
COALESCE(m.permission_kind,'') AS permission_kind,
COALESCE(m.permission_multi_select,0) AS permission_multi_select,
m.status, m.created_at, s.session_alias, s.workspace, m.rename_alias, m.rename_reason,
EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human,
EXISTS (SELECT 1 FROM users u WHERE u.username = m.to_name) AS to_human
@ -399,6 +407,7 @@ func GetMailByID(ctx context.Context, id uuid.UUID) (*models.Mail, error) {
).Scan(&m.ID, &m.SessionID, &m.ParentMailID,
&m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace,
&ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult,
&m.PermissionKind, &m.PermissionMulti,
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace, &renameAlias, &renameReason,
&m.FromHuman, &m.ToHuman)
if err != nil {
@ -433,6 +442,8 @@ func ListInbox(ctx context.Context, agentName, status string, limit int) ([]mode
q := `SELECT m.mail_id, m.session_id, m.parent_mail_id,
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
COALESCE(m.permission_kind,'') AS permission_kind,
COALESCE(m.permission_multi_select,0) AS permission_multi_select,
m.status, m.created_at, s.session_alias, s.workspace,
EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human,
EXISTS (SELECT 1 FROM users u WHERE u.username = m.to_name) AS to_human,
@ -466,6 +477,7 @@ func ListInbox(ctx context.Context, agentName, status string, limit int) ([]mode
if err := rows.Scan(&m.ID, &m.SessionID, &m.ParentMailID,
&m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace,
&ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult,
&m.PermissionKind, &m.PermissionMulti,
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace, &m.FromHuman, &m.ToHuman,
&m.PermissionMode, &m.PermissionEnforcement); err != nil {
return nil, err
@ -508,6 +520,8 @@ func GetSessionMails(ctx context.Context, sessionID uuid.UUID) ([]models.Mail, e
`SELECT m.mail_id, m.session_id, m.parent_mail_id,
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
COALESCE(m.permission_kind,'') AS permission_kind,
COALESCE(m.permission_multi_select,0) AS permission_multi_select,
m.status, m.created_at, s.session_alias, s.workspace,
EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human,
EXISTS (SELECT 1 FROM users u WHERE u.username = m.to_name) AS to_human
@ -528,6 +542,7 @@ func GetSessionMails(ctx context.Context, sessionID uuid.UUID) ([]models.Mail, e
if err := rows.Scan(&m.ID, &m.SessionID, &m.ParentMailID,
&m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace,
&ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult,
&m.PermissionKind, &m.PermissionMulti,
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace, &m.FromHuman, &m.ToHuman); err != nil {
return nil, err
}
@ -633,6 +648,8 @@ func GetSessionMailByID(ctx context.Context, sessionID, mailID uuid.UUID) (*mode
`SELECT m.mail_id, m.session_id, m.parent_mail_id,
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
COALESCE(m.permission_kind,'') AS permission_kind,
COALESCE(m.permission_multi_select,0) AS permission_multi_select,
m.status, m.created_at, s.session_alias, s.workspace,
EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human,
EXISTS (SELECT 1 FROM users u WHERE u.username = m.to_name) AS to_human
@ -641,6 +658,7 @@ func GetSessionMailByID(ctx context.Context, sessionID, mailID uuid.UUID) (*mode
).Scan(&m.ID, &m.SessionID, &m.ParentMailID,
&m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace,
&ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult,
&m.PermissionKind, &m.PermissionMulti,
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace, &m.FromHuman, &m.ToHuman)
if err != nil {
return nil, err
@ -1166,6 +1184,8 @@ func ListSentBy(ctx context.Context, fromName string, limit int) ([]models.Mail,
SELECT m.mail_id, m.session_id, m.parent_mail_id,
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
COALESCE(m.permission_kind,'') AS permission_kind,
COALESCE(m.permission_multi_select,0) AS permission_multi_select,
m.status, m.created_at, s.session_alias, s.workspace,
EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human,
EXISTS (SELECT 1 FROM users u WHERE u.username = m.to_name) AS to_human
@ -1188,6 +1208,7 @@ func ListSentBy(ctx context.Context, fromName string, limit int) ([]models.Mail,
if err := rows.Scan(&m.ID, &m.SessionID, &m.ParentMailID,
&m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace,
&ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult,
&m.PermissionKind, &m.PermissionMulti,
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace, &m.FromHuman, &m.ToHuman); err != nil {
return nil, err
}