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

@ -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();
});
});