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:
@ -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)
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
13
plugins/dsh-mail-bridge/lib/user-question.d.ts
vendored
Normal file
13
plugins/dsh-mail-bridge/lib/user-question.d.ts
vendored
Normal 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;
|
||||
176
plugins/dsh-mail-bridge/lib/user-question.js
Normal file
176
plugins/dsh-mail-bridge/lib/user-question.js
Normal 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;
|
||||
}
|
||||
@ -62,6 +62,11 @@ import { appendRenameProposal, renameProposalNote } from '../lib/rename-proposal
|
||||
import { createSSEClient } from '../lib/sse-client.js';
|
||||
// 只用 isApproval:DSH 没有 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,而邮件驱动的会话
|
||||
// 根本没有 UI,waterfall 跑到尾依旧无人应答 = 挂死。
|
||||
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');
|
||||
}
|
||||
|
||||
136
plugins/dsh-mail-bridge/test/user-question.test.mjs
Normal file
136
plugins/dsh-mail-bridge/test/user-question.test.mjs
Normal 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('无选项题回写:答案进 custom,selected 为空', () => {
|
||||
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({}), '(未提供问题)');
|
||||
});
|
||||
@ -9,8 +9,8 @@
|
||||
"esModuleInterop": true,
|
||||
"declaration": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"types": ["node"],
|
||||
"allowJs": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
176
plugins/opencode-mail-bridge/lib/user-question.js
Normal file
176
plugins/opencode-mail-bridge/lib/user-question.js
Normal 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;
|
||||
}
|
||||
136
plugins/opencode-mail-bridge/test/user-question.test.mjs
Normal file
136
plugins/opencode-mail-bridge/test/user-question.test.mjs
Normal 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('无选项题回写:答案进 custom,selected 为空', () => {
|
||||
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({}), '(未提供问题)');
|
||||
});
|
||||
176
plugins/pi-mail-bridge/lib/user-question.js
Normal file
176
plugins/pi-mail-bridge/lib/user-question.js
Normal 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;
|
||||
}
|
||||
136
plugins/pi-mail-bridge/test/user-question.test.mjs
Normal file
136
plugins/pi-mail-bridge/test/user-question.test.mjs
Normal 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('无选项题回写:答案进 custom,selected 为空', () => {
|
||||
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({}), '(未提供问题)');
|
||||
});
|
||||
@ -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)
|
||||
|
||||
191
server/internal/repo/permission_visibility_test.go
Normal file
191
server/internal/repo/permission_visibility_test.go
Normal 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 必须是 permission,multi 必须是 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("%s:PermissionKind = %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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user