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:
@@ -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-')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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: '拒绝',
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user