fix: retain submitted questions and answers in conversations

Preserve read-only question and answer reviews after successful submission or skipping, including multiple rounds and local conversation reloads. Keep failed submissions retryable and preserve newer questions and terminal states when responses arrive late.

Validation: typecheck and lint passed; full suite had 3900 passes, 66 skips and one Agent package timeout that passed on isolated rerun. Real OpenCode UI acceptance was not run. Previously discarded answers cannot be recovered.

Release note: 修复 OpenCode 提交回答后会话仅显示已提交的问题;现在保留原问题及对应答案,多轮问答和跳过记录在重新打开会话后仍可查看。
This commit is contained in:
mesalogo
2026-09-10 23:47:44 +08:00
parent 1274c762e8
commit 3bf720a32c
11 changed files with 400 additions and 11 deletions
@@ -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 提交时因缺少作者身份失败。
@@ -16,6 +16,11 @@ ACP 已提供相同业务问答接口。
- 结构化 yes/no 是普通选项,不从日志、自然语言或 Shell 输出猜测用户意图。
- 前台问题停留在现有问答卡片,保留回答、提交失败提示与跳过入口。
- 回答或跳过成功后,会话保留只读的“问题与回答”,显示原问题及对应的选项答案、
自定义文本或“已跳过”。同一任务的多轮问答逐次保留,随本地会话保存,重新打开后仍可查看。
提交失败时保留输入与重试入口,不生成已回答记录;取消未回答的问题不记为主动跳过。
- 提交响应晚于任务结束时仍保存已提交的问答,但不覆盖完成或失败状态;晚于下一轮提问时
不清除新的待答卡片。旧版会话未保存的问题与答案无法从“已提交”状态文案恢复。
- 后台任务收到结构化提问时,通过现有 Main 路径明确失败并提示改为前台对话,不永久等待。
- 请求结束或取消后清除对应待答映射;过期回答明确报错,不转交给后来的请求。
- 任意终端程序的密码、OAuth 登录、许可证确认或 stdin 提示不自动回复 `yes`。这类程序应
@@ -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-')
+9 -4
View File
@@ -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
+108 -2
View File
@@ -10086,8 +10086,8 @@ describe("App", () => {
);
});
it("renders and answers an OpenCode question request", async () => {
render(<App />);
it("renders, saves and reloads OpenCode answers across question rounds", async () => {
const view = render(<App />);
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(<App />);
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<void>((yes, no) => {
resolve = yes;
reject = no;
}));
render(<App />);
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(<App />);
+26 -4
View File
@@ -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<void> => {
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],
+29
View File
@@ -2,6 +2,7 @@ import {
Bot,
CheckCircle2,
ChevronRight,
CircleHelp,
Clock3,
Copy,
Download,
@@ -69,6 +70,7 @@ export type Message = {
allowPermanent?: boolean
}
question?: Extract<AgentEvent, { type: 'question' }>
answeredQuestions?: ConversationMessage['answeredQuestions']
sources?: string[]
sourceReferences?: KnowledgeSearchReference[]
knowledgeRetrieval?: KnowledgeRetrievalStatus
@@ -1033,6 +1035,33 @@ function ChatMessageRowView({
value={message.question}
/>
)}
{message.answeredQuestions?.map((review) => (
<div className="agent-question-review" key={review.questionId}>
<header>
<CircleHelp aria-hidden="true" size={16} />
<strong>{t('chat.questionReview.title')}</strong>
</header>
{review.questions.map((question, index) => (
<div
className="agent-question-review__item"
key={`${review.questionId}:${index}`}
>
<p className="agent-question-review__question">
<span>{question.header}</span>
{question.question}
</p>
<p className="agent-question-review__answer">
<span>{t('chat.questionReview.answerLabel')}</span>
{review.skipped ||
!question.answer ||
question.answer.length === 0
? t('chat.questionReview.skipped')
: question.answer.join('、')}
</p>
</div>
))}
</div>
))}
{message.role === 'assistant' && message.imageContextNotice && (
<p className="message__image-context-note">
{t(`chat.images.contextNotice.${message.imageContextNotice}`)}
@@ -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',
@@ -384,6 +384,11 @@ export const app = {
cancelled: '已取消'
}
},
questionReview: {
title: '问题与回答',
answerLabel: '你的回答:',
skipped: '已跳过'
},
approval: {
waiting: '等待审批:{{tool}}',
deny: '拒绝',
+58
View File
@@ -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%;
+34 -1
View File
@@ -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()