diff --git a/docs/features/assistant-workbar/progress.md b/docs/features/assistant-workbar/progress.md index 36a3bbd6..a53dbcac 100644 --- a/docs/features/assistant-workbar/progress.md +++ b/docs/features/assistant-workbar/progress.md @@ -1,5 +1,16 @@ # 工作栏实现与验证进度 +## 2026-09-10 提交后保留问答 + +- 桌面端在提交成功后保留结构化问答回顾,支持多轮回答、跳过及本地会话重新加载; + 交互规则见 [Runtime 交互边界](./runtime-interactions.md#运行与失败)。 +- App 回归覆盖提交与重载、下一轮问题提前到达、终态先于提交返回、提交失败;数据库 + 回归覆盖完整替换、增量保存、关闭重开、多选及超过 1,000 字符的自定义答案。 +- `npm test`:3,900 项通过、66 项跳过,Agent 离线依赖清单测试在 60 秒超时; + 该项独立复跑通过(58.33 秒)。`npm run typecheck`、`npm run lint` 与差异空白检查通过。 +- 本轮未手动运行真实 OpenCode 问答会话,外部模型调用 0 次。修改仅涉及桌面端展示与 + 本地保存,不改变 Runtime 回传协议或 Agent 路径,不新增远程 ACP 问答能力。 + ## 2026-09-10 Git 历史测试夹具修复 - 桌面候选 `877cf58` 的 CI 在四项工作区测试准备 Git 提交时因缺少作者身份失败。 diff --git a/docs/features/assistant-workbar/runtime-interactions.md b/docs/features/assistant-workbar/runtime-interactions.md index a25eb19a..37cc58bc 100644 --- a/docs/features/assistant-workbar/runtime-interactions.md +++ b/docs/features/assistant-workbar/runtime-interactions.md @@ -16,6 +16,11 @@ ACP 已提供相同业务问答接口。 - 结构化 yes/no 是普通选项,不从日志、自然语言或 Shell 输出猜测用户意图。 - 前台问题停留在现有问答卡片,保留回答、提交失败提示与跳过入口。 +- 回答或跳过成功后,会话保留只读的“问题与回答”,显示原问题及对应的选项答案、 + 自定义文本或“已跳过”。同一任务的多轮问答逐次保留,随本地会话保存,重新打开后仍可查看。 + 提交失败时保留输入与重试入口,不生成已回答记录;取消未回答的问题不记为主动跳过。 +- 提交响应晚于任务结束时仍保存已提交的问答,但不覆盖完成或失败状态;晚于下一轮提问时 + 不清除新的待答卡片。旧版会话未保存的问题与答案无法从“已提交”状态文案恢复。 - 后台任务收到结构化提问时,通过现有 Main 路径明确失败并提示改为前台对话,不永久等待。 - 请求结束或取消后清除对应待答映射;过期回答明确报错,不转交给后来的请求。 - 任意终端程序的密码、OAuth 登录、许可证确认或 stdin 提示不自动回复 `yes`。这类程序应 diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index 8436b53c..cec0101a 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -273,6 +273,116 @@ describe('AssistantDatabase', () => { database.close() }) + it('retains answeredQuestions through replacement, incremental saves, and reopening', async () => { + const database = await createDatabase() + const header = { + id: '00000000-0000-4000-8000-000000000921', + title: 'Answered questions', + updatedAt: 1 + } + const customAnswer = `Custom answer: ${'x'.repeat(1_001)}\nEnd of answer.` + const answeredQuestions = [ + { + questionId: 'question-choices', + skipped: false, + questions: [ + { + header: 'Targets', + question: 'Which platforms should be supported?', + options: [ + { label: 'Windows', description: 'Windows desktop' }, + { label: 'Linux', description: 'Linux desktop' } + ], + multiple: true, + custom: false, + answer: ['Windows', 'Linux'] + }, + { + header: 'Details', + question: 'What else should be included?', + options: [], + multiple: false, + custom: true, + answer: [customAnswer] + } + ] + }, + { + questionId: 'question-skipped', + skipped: true, + questions: [ + { + header: 'Timing', + question: 'When should this ship?', + options: [{ label: 'Now', description: 'Ship immediately' }], + multiple: false, + custom: true + } + ] + } + ] + const message = { + id: '00000000-0000-4000-8000-000000000922', + role: 'assistant' as const, + content: 'Recorded answers', + createdAt: 1, + state: 'complete' as const, + answeredQuestions + } + + try { + database.replaceConversations([{ ...header, messages: [message] }]) + expect(database.getConversation(header.id).messages).toEqual([message]) + database.close() + database.initialize('C:\\Workspace') + expect(database.getConversation(header.id).messages).toEqual([message]) + + const updatedMessage = { + ...message, + answeredQuestions: [ + ...answeredQuestions, + { + questionId: 'question-follow-up', + skipped: false, + questions: [ + { + header: 'Confirm', + question: 'Proceed with the selected platforms?', + options: [{ label: 'Yes', description: 'Proceed' }], + multiple: false, + custom: false, + answer: ['Yes'] + } + ] + } + ] + } + const insertedMessage = { + ...message, + id: '00000000-0000-4000-8000-000000000923', + createdAt: 2 + } + database.saveLocalConversations([ + { + header: { ...header, updatedAt: 2 }, + messages: [updatedMessage, insertedMessage] + } + ]) + expect(database.getConversation(header.id).messages).toEqual([ + updatedMessage, + insertedMessage + ]) + database.close() + database.initialize('C:\\Workspace') + expect(database.getConversation(header.id).messages).toEqual([ + updatedMessage, + insertedMessage + ]) + } finally { + database.close() + } + }) + it('persists complete Run history beyond the former Renderer limits', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-activity-history-') diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index d2c2cb9c..7e9bbf44 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -275,6 +275,7 @@ type MessageMetadata = { imageContextNotice?: ConversationSnapshot['messages'][number]['imageContextNotice'] task?: ConversationSnapshot['messages'][number]['task'] attachments?: ConversationSnapshot['messages'][number]['attachments'] + answeredQuestions?: ConversationSnapshot['messages'][number]['answeredQuestions'] } const MAX_CHANNEL_OUTBOX_RETRY_BYTES = 20 * 1024 * 1024 @@ -1163,7 +1164,8 @@ function toConversationSnapshot( artifactIds: metadata.artifactIds, imageContextNotice: metadata.imageContextNotice, task: metadata.task, - attachments: metadata.attachments + attachments: metadata.attachments, + answeredQuestions: metadata.answeredQuestions } }) } @@ -1189,7 +1191,8 @@ function serializeConversationMessageMetadata( artifactIds: message.artifactIds, imageContextNotice: message.imageContextNotice, task: message.task, - attachments: message.attachments + attachments: message.attachments, + answeredQuestions: message.answeredQuestions }) } @@ -4633,7 +4636,8 @@ export class AssistantDatabase { knowledgeRetrieval: metadata.knowledgeRetrieval, artifactIds: metadata.artifactIds, task: metadata.task, - attachments: metadata.attachments + attachments: metadata.attachments, + answeredQuestions: metadata.answeredQuestions }) const failed = reduceRecoveredAgentEvent(current, { requestId: taskId, @@ -5041,7 +5045,8 @@ export class AssistantDatabase { knowledgeRetrieval: metadata.knowledgeRetrieval, artifactIds: metadata.artifactIds, task: metadata.task, - attachments: metadata.attachments + attachments: metadata.attachments, + answeredQuestions: metadata.answeredQuestions }) const reduced = reduceRecoveredAgentEvent(message, publicEvent) const messageUpdate = database diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 787f419f..67bba0e5 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -10086,8 +10086,8 @@ describe("App", () => { ); }); - it("renders and answers an OpenCode question request", async () => { - render(); + it("renders, saves and reloads OpenCode answers across question rounds", async () => { + const view = render(); fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "需要确认的任务" }, }); @@ -10135,8 +10135,114 @@ describe("App", () => { ]), ); expect(screen.queryByText("OpenCode 需要补充信息")).not.toBeInTheDocument(); + expect(screen.getByText("请选择实现方式")).toBeInTheDocument(); + expect(screen.getByText("先写测试")).toBeInTheDocument(); + act(() => { + agentListener?.({ + requestId: request.requestId, + type: "question", + questionId: "question-2", + questions: [{ + header: "补充", question: "还有补充要求吗?", options: [], + multiple: false, custom: true, + }], + }); + }); + expect(screen.getByText("先写测试")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "跳过" })); + expect(await screen.findByText("已跳过", { exact: true })).toBeInTheDocument(); + act(() => agentListener?.({ requestId: request.requestId, type: "done" })); + await waitFor(() => expect(api.conversations.saveLocal).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ + messages: expect.arrayContaining([expect.objectContaining({ + state: "complete", + answeredQuestions: [ + expect.objectContaining({ questionId: "question-1", questions: [ + expect.objectContaining({ question: "请选择实现方式", answer: ["先写测试"] }), + ] }), + expect.objectContaining({ questionId: "question-2", skipped: true }), + ], + })]), + })]), + )); + const saved = vi.mocked(api.conversations.saveLocal).mock.calls + .flatMap(([batch]) => batch) + .findLast((entry) => entry.messages.some((message) => + message.state === "complete" && message.answeredQuestions?.length === 2)); + if (!saved) throw new Error("Missing saved question reviews"); + view.unmount(); + vi.mocked(api.conversations.list).mockResolvedValueOnce([ + { ...saved.header, messages: saved.messages }, + ]); + render(); + expect(await screen.findByText("先写测试")).toBeInTheDocument(); + expect(screen.getByText("请选择实现方式")).toBeInTheDocument(); + expect(screen.getByText("还有补充要求吗?")).toBeInTheDocument(); + expect(screen.getByText("已跳过", { exact: true })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "提交回答" })).not.toBeInTheDocument(); }); + it.each(["done", "question", "failure"] as const)( + "preserves question state when %s occurs during answer submission", + async (outcome) => { + let resolve!: () => void; + let reject!: (error: Error) => void; + vi.mocked(api.agent.respondQuestion).mockImplementationOnce(() => new Promise((yes, no) => { + resolve = yes; + reject = no; + })); + render(); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "确认执行范围" }, + }); + fireEvent.click(await screen.findByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const request = run.mock.calls[0]![0]; + const questions = [{ + header: "范围", question: "执行哪些检查?", options: [], + multiple: false, custom: true, + }]; + act(() => agentListener?.({ + requestId: request.requestId, type: "question", questionId: "first", questions, + })); + fireEvent.change(screen.getByRole("textbox", { name: "其他回答" }), { + target: { value: "保留完整自定义回答\n" + "检查".repeat(600) }, + }); + fireEvent.click(screen.getByRole("button", { name: "提交回答" })); + await waitFor(() => expect(api.agent.respondQuestion).toHaveBeenCalled()); + await act(async () => { + if (outcome === "failure") { + reject(new Error("Answer delivery failed")); + } else { + agentListener?.(outcome === "done" + ? { requestId: request.requestId, type: "done" } + : { requestId: request.requestId, type: "question", questionId: "second", + questions: [{ ...questions[0]!, question: "是否继续下一步?" }] }); + resolve(); + } + }); + if (outcome === "failure") { + expect(screen.getByRole("alert")).toHaveTextContent("Answer delivery failed"); + expect(screen.getByRole("button", { name: "提交回答" })).toBeEnabled(); + expect(screen.queryByText("问题与回答")).not.toBeInTheDocument(); + } else { + expect(screen.getByText("问题与回答")).toBeInTheDocument(); + await waitFor(() => expect(api.conversations.saveLocal).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ + messages: expect.arrayContaining([expect.objectContaining({ + ...(outcome === "done" ? { state: "complete", status: "任务执行完成" } : {}), + answeredQuestions: [expect.objectContaining({ questionId: "first" })], + })]), + })]), + )); + if (outcome === "question") { + expect(screen.getByText("是否继续下一步?")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "提交回答" })).toBeInTheDocument(); + } + } + }, + ); + it("configures a runtime without reading an existing API key", async () => { render(); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index fcee3ccb..7ad29e17 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1342,6 +1342,7 @@ function toConversationMessage(message: Message): ConversationMessage { imageContextNotice: message.imageContextNotice, task: message.task, attachments: message.attachments, + answeredQuestions: message.answeredQuestions, }; } @@ -7365,13 +7366,34 @@ function App(): React.JSX.Element { questionId: string, answers?: AgentQuestionAnswer[], ): Promise => { + const question = conversationsRef.current + .find((conversation) => conversation.id === conversationId) + ?.messages.find((message) => message.id === messageId)?.question; + if (!question || question.questionId !== questionId) { + return; + } await window.goodbuddy.agent.respondQuestion(questionId, answers); updateMessage(conversationId, messageId, (message) => ({ ...message, - question: undefined, - status: answers - ? tRef.current("chat.status.answerSubmitted") - : tRef.current("chat.status.questionSkipped"), + answeredQuestions: [ + ...(message.answeredQuestions ?? []), + { + questionId, + skipped: answers === undefined, + questions: question.questions.map((item, index) => ({ + ...item, + answer: answers?.[index], + })), + }, + ], + question: message.question?.questionId === questionId + ? undefined + : message.question, + status: message.question?.questionId === questionId && message.state === "streaming" + ? answers + ? tRef.current("chat.status.answerSubmitted") + : tRef.current("chat.status.questionSkipped") + : message.status, })); }, [updateMessage], diff --git a/src/renderer/src/ChatTimeline.tsx b/src/renderer/src/ChatTimeline.tsx index d5cfdf1b..5c54912b 100644 --- a/src/renderer/src/ChatTimeline.tsx +++ b/src/renderer/src/ChatTimeline.tsx @@ -2,6 +2,7 @@ import { Bot, CheckCircle2, ChevronRight, + CircleHelp, Clock3, Copy, Download, @@ -69,6 +70,7 @@ export type Message = { allowPermanent?: boolean } question?: Extract + answeredQuestions?: ConversationMessage['answeredQuestions'] sources?: string[] sourceReferences?: KnowledgeSearchReference[] knowledgeRetrieval?: KnowledgeRetrievalStatus @@ -1033,6 +1035,33 @@ function ChatMessageRowView({ value={message.question} /> )} + {message.answeredQuestions?.map((review) => ( +
+
+
+ {review.questions.map((question, index) => ( +
+

+ {question.header} + {question.question} +

+

+ {t('chat.questionReview.answerLabel')} + {review.skipped || + !question.answer || + question.answer.length === 0 + ? t('chat.questionReview.skipped') + : question.answer.join('、')} +

+
+ ))} +
+ ))} {message.role === 'assistant' && message.imageContextNotice && (

{t(`chat.images.contextNotice.${message.imageContextNotice}`)} diff --git a/src/renderer/src/i18n/locales/en-US/app.ts b/src/renderer/src/i18n/locales/en-US/app.ts index 8384a031..170db42e 100644 --- a/src/renderer/src/i18n/locales/en-US/app.ts +++ b/src/renderer/src/i18n/locales/en-US/app.ts @@ -403,6 +403,11 @@ export const app = { cancelled: 'Cancelled' } }, + questionReview: { + title: 'Questions and answers', + answerLabel: 'Your answer:', + skipped: 'Skipped' + }, approval: { waiting: 'Awaiting approval: {{tool}}', deny: 'Deny', diff --git a/src/renderer/src/i18n/locales/zh-CN/app.ts b/src/renderer/src/i18n/locales/zh-CN/app.ts index dce86666..98ef1cd1 100644 --- a/src/renderer/src/i18n/locales/zh-CN/app.ts +++ b/src/renderer/src/i18n/locales/zh-CN/app.ts @@ -384,6 +384,11 @@ export const app = { cancelled: '已取消' } }, + questionReview: { + title: '问题与回答', + answerLabel: '你的回答:', + skipped: '已跳过' + }, approval: { waiting: '等待审批:{{tool}}', deny: '拒绝', diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 4e7c2326..0b205d16 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -6651,6 +6651,64 @@ button > svg { font-size: var(--font-caption); } +.agent-question-review { + min-width: 0; + overflow-wrap: anywhere; + white-space: pre-wrap; + display: flex; + padding: var(--space-4); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-card); + margin-top: var(--space-3); + background: var(--surface-raised); + color: var(--text-primary); + flex-direction: column; + gap: var(--space-3); +} + +.agent-question-review > header { + display: flex; + align-items: center; + color: var(--text-muted); + gap: var(--space-2); +} + +.agent-question-review__item { + display: flex; + padding: var(--space-3); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-control); + flex-direction: column; + gap: var(--space-1); +} + +.agent-question-review__question { + margin: 0; + color: var(--text-primary); + font-size: var(--font-body); + line-height: 1.5; +} + +.agent-question-review__question span { + display: block; + color: var(--text-muted); + font-size: var(--font-caption); + font-weight: 650; +} + +.agent-question-review__answer { + margin: 0; + color: var(--text-secondary); + font-size: var(--font-body); +} + +.agent-question-review__answer span { + margin-right: var(--space-1); + color: var(--text-muted); + font-size: var(--font-caption); + font-weight: 650; +} + .composer-wrap { width: 100%; max-width: 100%; diff --git a/src/shared/assistant-contracts.ts b/src/shared/assistant-contracts.ts index bfb8b425..79e21ec2 100644 --- a/src/shared/assistant-contracts.ts +++ b/src/shared/assistant-contracts.ts @@ -309,6 +309,38 @@ export type ConversationContextCompressionMarker = z.infer< typeof conversationContextCompressionMarkerSchema > +export const conversationAnsweredQuestionSchema = z + .object({ + questionId: z.string().min(1), + skipped: z.boolean().optional(), + questions: z + .array( + z + .object({ + header: z.string(), + question: z.string(), + options: z + .array( + z + .object({ + label: z.string(), + description: z.string() + }) + .strict() + ), + multiple: z.boolean(), + custom: z.boolean(), + answer: z.array(z.string()).optional() + }) + .strict() + ) + }) + .strict() + +export type ConversationAnsweredQuestion = z.infer< + typeof conversationAnsweredQuestionSchema +> + export const conversationMessageSchema = z .object({ id: assistantIdSchema, @@ -397,7 +429,8 @@ export const conversationMessageSchema = z attachments: z .array(conversationAttachmentSchema) .max(8) - .optional() + .optional(), + answeredQuestions: z.array(conversationAnsweredQuestionSchema).optional() }) .strict()