fix: deduplicate terminal notices and distinguish cancellation
Keep cancellation and failure details in message status while preserving existing replies. Render cancellation neutrally, restore terminal metadata from history, and offer Edit again only when input can be restored. Release note: Cancelled and failed requests show a single notice; cancellation no longer appears as an error, and Edit again accurately describes the recovery action.
This commit is contained in:
@@ -4428,6 +4428,7 @@ describe('AssistantDatabase', () => {
|
||||
id: assistantMessageId,
|
||||
content: recoveredText,
|
||||
state: 'error',
|
||||
terminalStatus,
|
||||
runtimeChecklist: { source: 'opencode', items: [] },
|
||||
status: '远端请求已不存在'
|
||||
})
|
||||
@@ -4553,7 +4554,7 @@ describe('AssistantDatabase', () => {
|
||||
} finally { database.close() }
|
||||
})
|
||||
|
||||
it('rolls back a recovered event when its message update fails', async () => {
|
||||
it.each(['failed', 'cancelled'] as const)('rolls back and durably recovers a %s event without creating reply text', async (terminalStatus) => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-remote-event-atomic-')
|
||||
)
|
||||
@@ -4617,7 +4618,7 @@ describe('AssistantDatabase', () => {
|
||||
event: {
|
||||
requestId: taskId,
|
||||
type: 'error',
|
||||
status: 'failed',
|
||||
status: terminalStatus,
|
||||
message: '远程失败'
|
||||
}
|
||||
})
|
||||
@@ -4664,7 +4665,7 @@ describe('AssistantDatabase', () => {
|
||||
event: {
|
||||
requestId: taskId,
|
||||
type: 'error',
|
||||
status: 'failed',
|
||||
status: terminalStatus,
|
||||
message: '远程失败'
|
||||
}
|
||||
})
|
||||
@@ -4687,17 +4688,24 @@ describe('AssistantDatabase', () => {
|
||||
expect(
|
||||
recovered.getConversation(conversationId).messages[1]
|
||||
).toMatchObject({
|
||||
content: '远程失败',
|
||||
content: '',
|
||||
state: 'error',
|
||||
terminalStatus,
|
||||
status: '远程失败'
|
||||
})
|
||||
expect(
|
||||
recovered.listTasks().find((task) => task.id === taskId)
|
||||
).toMatchObject({
|
||||
status: 'failed',
|
||||
status: terminalStatus,
|
||||
error: '远程失败'
|
||||
})
|
||||
recovered.close()
|
||||
const reopened = new AssistantDatabase(databasePath)
|
||||
reopened.initialize('C:\\Workspace')
|
||||
const message = reopened.getConversation(conversationId).messages[1]
|
||||
expect(message).toMatchObject({ content: '', state: 'error', terminalStatus, status: '远程失败' })
|
||||
expect(message?.blocks?.some(block => block.type === 'text')).toBe(false)
|
||||
reopened.close()
|
||||
})
|
||||
|
||||
it('persists and arbitrates a FIFO conversation input queue', async () => {
|
||||
|
||||
@@ -290,6 +290,7 @@ type MessageMetadata = {
|
||||
createdAt?: number
|
||||
queueItemId?: string
|
||||
status?: string
|
||||
terminalStatus?: ConversationMessage['terminalStatus']
|
||||
reasoning?: ConversationSnapshot['messages'][number]['reasoning']
|
||||
runtimeChecklist?: ConversationMessage['runtimeChecklist']
|
||||
blocks?: ConversationSnapshot['messages'][number]['blocks']
|
||||
@@ -1213,6 +1214,7 @@ function toConversationSnapshot(
|
||||
createdAt:
|
||||
metadata.createdAt ?? Date.parse(message.created_at),
|
||||
state: message.state,
|
||||
terminalStatus: metadata.terminalStatus,
|
||||
status: metadata.status,
|
||||
contextCompression: metadata.contextCompression,
|
||||
contextCompressions: metadata.contextCompressions,
|
||||
@@ -1240,6 +1242,7 @@ function serializeConversationMessageMetadata(
|
||||
createdAt: message.createdAt,
|
||||
queueItemId: message.queueItemId,
|
||||
status: message.status,
|
||||
terminalStatus: message.terminalStatus,
|
||||
reasoning: message.reasoning,
|
||||
runtimeChecklist: message.runtimeChecklist,
|
||||
blocks: message.blocks,
|
||||
@@ -1568,15 +1571,14 @@ function reduceRecoveredAgentEvent(
|
||||
? ('cancelled' as const)
|
||||
: ('failed' as const)
|
||||
: ('interrupted' as const)
|
||||
const fallback =
|
||||
event.type === 'error' && !represented && !message.content
|
||||
? event.message
|
||||
: ''
|
||||
next = {
|
||||
...message,
|
||||
state: event.type === 'error' ? 'error' : 'complete',
|
||||
terminalStatus: event.type === 'error'
|
||||
? event.status === 'cancelled' ? 'cancelled' : 'failed'
|
||||
: undefined,
|
||||
status:
|
||||
event.type === 'error' && !represented
|
||||
event.type === 'error' && (event.status === 'cancelled' || !represented)
|
||||
? event.message
|
||||
: undefined,
|
||||
contextCompression:
|
||||
@@ -1620,10 +1622,9 @@ function reduceRecoveredAgentEvent(
|
||||
: subagent
|
||||
),
|
||||
blocks: terminalizeRecoveredToolBlocks(
|
||||
appendRecoveredMessageBlock(message.blocks, 'text', fallback),
|
||||
message.blocks,
|
||||
toolState
|
||||
),
|
||||
content: fallback || message.content
|
||||
)
|
||||
}
|
||||
}
|
||||
return conversationMessageSchema.parse(next)
|
||||
@@ -5443,6 +5444,7 @@ export class AssistantDatabase {
|
||||
state: row.state,
|
||||
createdAt: metadata.createdAt ?? Date.parse(row.created_at),
|
||||
status: metadata.status,
|
||||
terminalStatus: metadata.terminalStatus,
|
||||
reasoning: metadata.reasoning,
|
||||
runtimeChecklist: metadata.runtimeChecklist,
|
||||
blocks: metadata.blocks,
|
||||
@@ -5858,6 +5860,7 @@ export class AssistantDatabase {
|
||||
createdAt:
|
||||
metadata.createdAt ?? Date.parse(row.created_at),
|
||||
status: metadata.status,
|
||||
terminalStatus: metadata.terminalStatus,
|
||||
reasoning: metadata.reasoning,
|
||||
runtimeChecklist: metadata.runtimeChecklist,
|
||||
blocks: metadata.blocks,
|
||||
|
||||
@@ -5935,9 +5935,13 @@ describe("App", () => {
|
||||
expect(screen.getByText(toolError, { selector: ".tool-execution__error-preview" })).toBeVisible();
|
||||
expect(screen.queryByText(runtimeError)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "重新编辑并发送" }),
|
||||
screen.getByRole("button", { name: "重新编辑" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "重新编辑" }));
|
||||
expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveValue("读取演示文稿");
|
||||
expect(run).toHaveBeenCalledOnce();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
|
||||
target: { value: "继续处理" },
|
||||
});
|
||||
@@ -5945,7 +5949,7 @@ describe("App", () => {
|
||||
await waitFor(() => expect(run).toHaveBeenCalledTimes(2));
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "重新编辑并发送" }),
|
||||
screen.queryByRole("button", { name: "重新编辑" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -8469,7 +8473,7 @@ describe("App", () => {
|
||||
screen.queryByText("上次运行意外中断,可以重新发送问题"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "重新编辑并发送" }),
|
||||
screen.queryByRole("button", { name: "重新编辑" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(api.conversationQueue.ready).not.toHaveBeenCalled();
|
||||
|
||||
@@ -10074,6 +10078,40 @@ describe("App", () => {
|
||||
expect(mode).toBeDisabled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["cancelled", ""], ["cancelled", "Partial answer"],
|
||||
["failed", ""], ["failed", "Partial answer"],
|
||||
] as const)("preserves reply content and persists a single %s status (%s)", async (status, content) => {
|
||||
render(<App />);
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
|
||||
target: { value: "Test terminal reply" },
|
||||
});
|
||||
fireEvent.click(await screen.findByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
const requestId = run.mock.calls[0]![0].requestId;
|
||||
// Deliberately unrelated to cancellation copy: classification uses status.
|
||||
const detail = "Terminal event detail";
|
||||
act(() => {
|
||||
if (content) agentListener?.({ requestId, type: "text", delta: content });
|
||||
agentListener?.({ requestId, type: "error", status, message: detail });
|
||||
});
|
||||
const notice = await screen.findByRole(status === "cancelled" ? "status" : "alert", { name: detail });
|
||||
expect(screen.getAllByText(detail)).toHaveLength(1);
|
||||
expect(notice.classList.contains("message__status--error")).toBe(status === "failed");
|
||||
const article = notice.closest("article")!;
|
||||
expect(article.querySelector(".message__content")?.textContent ?? "").toBe(content);
|
||||
await waitFor(() => {
|
||||
const saved = vi.mocked(api.conversations.saveLocal).mock.calls
|
||||
.flatMap(([batch]) => batch).flatMap(item => item.messages ?? [])
|
||||
.findLast(message => message.terminalStatus === status);
|
||||
expect(saved).toMatchObject({ state: "error", terminalStatus: status, content, status: detail });
|
||||
expect(saved?.blocks?.filter(block => block.type === "text").map(block => block.content).join("") ?? "").toBe(content);
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "重新编辑" }));
|
||||
expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveValue("Test terminal reply");
|
||||
expect(run).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("terminalizes tools and activity when a request is cancelled", async () => {
|
||||
vi.mocked(api.agent.getStatus).mockResolvedValue({
|
||||
id: "opencode",
|
||||
@@ -10111,15 +10149,17 @@ describe("App", () => {
|
||||
});
|
||||
|
||||
expect(await screen.findByText("已取消")).toBeInTheDocument();
|
||||
const cancelledStatus = screen
|
||||
.getAllByText("请求已取消")
|
||||
.find((element) => element.classList.contains("message__status"));
|
||||
expect(cancelledStatus).toBeDefined();
|
||||
const cancelledStatus = screen.getByRole("status", { name: "请求已取消" });
|
||||
expect(screen.getAllByText("请求已取消")).toHaveLength(1);
|
||||
expect(cancelledStatus).not.toHaveClass("message__status--error");
|
||||
expect(cancelledStatus).toHaveAttribute("aria-live", "polite");
|
||||
const cancelledDot = cancelledStatus?.querySelector(".message__status-dot");
|
||||
expect(cancelledDot).toHaveClass("message__status-dot");
|
||||
expect(cancelledDot).not.toHaveClass("message__status-dot--active");
|
||||
fireEvent.click(screen.getByText("运行记录"));
|
||||
expect((await screen.findAllByText("已取消")).length).toBeGreaterThan(0);
|
||||
expect(await screen.findByText("任务已取消")).toBeInTheDocument();
|
||||
expect(screen.queryByText("任务执行失败")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /^进行中 \d+$/u }));
|
||||
expect(
|
||||
screen.getByText("当前没有等待中或正在运行的活动。"),
|
||||
@@ -12364,7 +12404,7 @@ describe("App", () => {
|
||||
} else {
|
||||
expect(screen.queryByRole("button", { name: "提交回答" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Already queued?")).not.toBeInTheDocument();
|
||||
if (outcome !== "done") expect(screen.getByRole("alert")).toHaveTextContent("Terminal queue result");
|
||||
if (outcome !== "done") expect(screen.getByRole(outcome === "cancelled" ? "status" : "alert", { name: "Terminal queue result" })).toHaveTextContent("Terminal queue result");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1027,6 +1027,7 @@ function toConversationMessage(message: Message): ConversationMessage {
|
||||
displayCaptureTruncated: message.displayCaptureTruncated,
|
||||
createdAt: message.createdAt,
|
||||
state: message.state,
|
||||
terminalStatus: message.terminalStatus,
|
||||
status: message.status,
|
||||
runtimeChecklist: message.runtimeChecklist,
|
||||
contextCompression: message.contextCompression,
|
||||
@@ -4835,7 +4836,8 @@ function App(): React.JSX.Element {
|
||||
kind: "result",
|
||||
title:
|
||||
event.type === "error"
|
||||
? tRef.current("chat.status.taskFailed")
|
||||
? tRef.current(event.status === "cancelled"
|
||||
? "chat.status.taskCancelled" : "chat.status.taskFailed")
|
||||
: tRef.current("chat.status.taskCompleted"),
|
||||
detail:
|
||||
event.type === "error"
|
||||
@@ -4869,15 +4871,14 @@ function App(): React.JSX.Element {
|
||||
? ("cancelled" as const)
|
||||
: ("failed" as const)
|
||||
: ("interrupted" as const);
|
||||
const fallbackError =
|
||||
event.type === "error" && !representedToolError && !message.content
|
||||
? event.message
|
||||
: "";
|
||||
return {
|
||||
...message,
|
||||
state: event.type === "error" ? "error" : "complete",
|
||||
terminalStatus: event.type === "error"
|
||||
? event.status === "cancelled" ? "cancelled" : "failed"
|
||||
: undefined,
|
||||
status:
|
||||
event.type === "error" && !representedToolError
|
||||
event.type === "error" && (event.status === "cancelled" || !representedToolError)
|
||||
? event.message
|
||||
: event.type === "done"
|
||||
? tRef.current("chat.status.taskCompleted")
|
||||
@@ -4928,21 +4929,7 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
: subagent,
|
||||
),
|
||||
blocks: toolTerminalState
|
||||
? terminalizeMessageToolBlocks(
|
||||
appendMessageContentBlock(
|
||||
message.blocks,
|
||||
"text",
|
||||
fallbackError,
|
||||
),
|
||||
toolTerminalState,
|
||||
)
|
||||
: appendMessageContentBlock(
|
||||
message.blocks,
|
||||
"text",
|
||||
fallbackError,
|
||||
),
|
||||
content: fallbackError || message.content,
|
||||
blocks: terminalizeMessageToolBlocks(message.blocks, toolTerminalState),
|
||||
};
|
||||
});
|
||||
activeRuns.current.delete(event.requestId);
|
||||
|
||||
@@ -80,6 +80,74 @@ function createMessages(): Message[] {
|
||||
}
|
||||
|
||||
describe('ChatTimeline', () => {
|
||||
it.each(['cancelled', 'failed'] as const)('renders saved %s status and offers editing only with usable input', (terminalStatus) => {
|
||||
const props = {
|
||||
...callbacks, artifactById: new Map(), conversationId: 'terminal',
|
||||
hiddenMessageCount: 0, isUnusedConversation: false, locale: 'zh-CN' as const,
|
||||
messageStartIndex: 0, totalMessageCount: 1
|
||||
}
|
||||
const message: Message = { id: 'reply', role: 'assistant', content: 'Existing reply',
|
||||
state: 'error', terminalStatus, status: 'Terminal detail', createdAt: 1 }
|
||||
const view = render(<ChatTimeline {...props} messages={[message]} />)
|
||||
const status = screen.getByRole(terminalStatus === 'cancelled' ? 'status' : 'alert', { name: 'Terminal detail' })
|
||||
expect(status.classList.contains('message__status--error')).toBe(terminalStatus === 'failed')
|
||||
expect(screen.getByText('Existing reply')).toBeVisible()
|
||||
expect(screen.queryByRole('button', { name: '重新编辑' })).not.toBeInTheDocument()
|
||||
for (const extra of [
|
||||
{ contextCompression: { state: 'completed' as const, estimatedBeforeTokens: 20_000, estimatedAfterTokens: 8_000 } },
|
||||
{ knowledgeRetrieval: { mode: 'always' as const, state: 'succeeded' as const, libraryCount: 1,
|
||||
resultCount: 2, durationMs: 20, usedChannels: ['fts' as const], warnings: [] } }
|
||||
]) {
|
||||
view.rerender(<ChatTimeline {...props} messages={[{ ...message, ...extra }]} />)
|
||||
expect(screen.getByRole(terminalStatus === 'cancelled' ? 'status' : 'alert', { name: 'Terminal detail' }))
|
||||
.toHaveAttribute('aria-live', terminalStatus === 'cancelled' ? 'polite' : 'assertive')
|
||||
}
|
||||
view.rerender(<ChatTimeline {...props} messages={[message]} retryContent=" " />)
|
||||
expect(screen.queryByRole('button', { name: '重新编辑' })).not.toBeInTheDocument()
|
||||
view.rerender(<ChatTimeline {...props} messages={[message]} retryContent="Original prompt" />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新编辑' }))
|
||||
expect(callbacks.onRetry).toHaveBeenLastCalledWith('Original prompt')
|
||||
})
|
||||
|
||||
it.each(['请求已取消', 'Provider failed'])('deduplicates only the exact legacy fallback shape (%s) without removing reply text', (detail) => {
|
||||
const props = { ...callbacks, artifactById: new Map(), conversationId: 'legacy',
|
||||
hiddenMessageCount: 0, isUnusedConversation: false, locale: 'zh-CN' as const,
|
||||
messageStartIndex: 0, totalMessageCount: 1 }
|
||||
const message: Message = { id: 'old', role: 'assistant', content: detail,
|
||||
state: 'error', status: detail, createdAt: 1 }
|
||||
const view = render(<ChatTimeline {...props} messages={[message]} />)
|
||||
expect(screen.getAllByText(detail)).toHaveLength(1)
|
||||
expect(screen.getByText(detail).closest('.message__content')).not.toBeNull()
|
||||
const blocks: Message['blocks'] = [
|
||||
{ id: 'reasoning', type: 'reasoning', content: 'Preserved reasoning' },
|
||||
{ id: 'fallback', type: 'text', content: message.content }
|
||||
]
|
||||
view.rerender(<ChatTimeline {...props} messages={[{ ...message, blocks }]} />)
|
||||
expect(screen.getAllByText(detail)).toHaveLength(1)
|
||||
// A body that merely quotes or includes the status is never stripped.
|
||||
view.rerender(<ChatTimeline {...props} messages={[{ ...message, content: `The message says ${detail}` }]} />)
|
||||
expect(screen.getByText(`The message says ${detail}`)).toBeVisible()
|
||||
const role = detail === '请求已取消' ? 'status' : 'alert'
|
||||
expect(screen.getByRole(role, { name: detail }).classList.contains('message__status--error')).toBe(role === 'alert')
|
||||
// Explicit failed events must override even the old cancellation labels.
|
||||
view.rerender(<ChatTimeline {...props} messages={[{ ...message, terminalStatus: 'failed' }]} />)
|
||||
expect(screen.getByRole('alert', { name: detail })).toHaveClass('message__status--error')
|
||||
expect(screen.getByText(detail, { selector: '.message__content span' })).toBeVisible()
|
||||
// Content and blocks disagree: do not treat this as the old fallback.
|
||||
view.rerender(<ChatTimeline {...props} messages={[{ ...message, blocks: [{ id: 'real', type: 'text', content: 'Real output' }] }]} />)
|
||||
expect(screen.getByText('Real output')).toBeVisible()
|
||||
expect(screen.getByRole(role, { name: detail })).toBeVisible()
|
||||
// Multiple text blocks or a non-final text block are not the old fallback.
|
||||
for (const nextBlocks of [
|
||||
[...blocks, { id: 'later', type: 'reasoning' as const, content: 'Later reasoning' }],
|
||||
[{ id: 'earlier', type: 'text' as const, content: 'Earlier output' }, ...blocks]
|
||||
]) {
|
||||
view.rerender(<ChatTimeline {...props} messages={[{ ...message, blocks: nextBlocks }]} />)
|
||||
expect(screen.getByRole(role, { name: detail })).toBeVisible()
|
||||
expect(screen.getByText(detail, { selector: '.message__content span' })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
it('captures only nonempty settled assistant replies, including incomplete replies', () => {
|
||||
const onAddToNote = vi.fn()
|
||||
const messages: Message[] = [
|
||||
|
||||
@@ -41,6 +41,7 @@ import { AgentQuestionCard } from './AgentQuestionCard'
|
||||
import { MarkdownRenderer } from './MarkdownRenderer'
|
||||
import { formatTime, type TimeFormatLocale } from './time-format'
|
||||
import { formatCompactTokens } from './token-format'
|
||||
import { isCancelledMessage } from './message-terminal-status'
|
||||
|
||||
export type ToolActivity = ConversationToolActivity
|
||||
|
||||
@@ -61,6 +62,7 @@ export type Message = {
|
||||
displayCaptureTruncated?: boolean
|
||||
createdAt: number
|
||||
state: 'streaming' | 'complete' | 'error'
|
||||
terminalStatus?: ConversationMessage['terminalStatus']
|
||||
status?: string
|
||||
runtimeChecklist?: RuntimeChecklist
|
||||
contextCompression?: ConversationMessage['contextCompression']
|
||||
@@ -706,7 +708,19 @@ function ChatMessageRowView({
|
||||
subagent.state === 'queued' || subagent.state === 'running')
|
||||
const activeCompression = compressionMarkers.find((marker) =>
|
||||
marker.state === 'compressing')
|
||||
const statusText = message.role === 'assistant' && message.state === 'streaming'
|
||||
// Old snapshots may contain the fallback status as the entire reply. Keep
|
||||
// the body (it could be genuine output) and omit only the duplicate status.
|
||||
const duplicateLegacyStatus = message.role === 'assistant' &&
|
||||
message.state === 'error' && message.terminalStatus === undefined &&
|
||||
Boolean(message.status) && message.content === message.status &&
|
||||
(!message.blocks?.length || (
|
||||
message.blocks.at(-1)?.type === 'text' &&
|
||||
message.blocks.every(block => block.type !== 'text' ||
|
||||
(block === message.blocks?.at(-1) && block.content === message.status))
|
||||
))
|
||||
const failed = message.state === 'error' && !isCancelledMessage(message)
|
||||
const statusText = duplicateLegacyStatus ? undefined
|
||||
: message.role === 'assistant' && message.state === 'streaming'
|
||||
? question
|
||||
? t('chat.status.waitingForAnswer')
|
||||
: message.approval
|
||||
@@ -1233,21 +1247,21 @@ function ChatMessageRowView({
|
||||
aria-atomic="true"
|
||||
aria-label={statusText}
|
||||
aria-live={
|
||||
message.knowledgeRetrieval || compressionMarkers.length > 0
|
||||
message.state !== 'error' && (message.knowledgeRetrieval || compressionMarkers.length > 0)
|
||||
? undefined
|
||||
: message.state === 'error'
|
||||
: failed
|
||||
? 'assertive'
|
||||
: 'polite'
|
||||
}
|
||||
className={
|
||||
message.state === 'error'
|
||||
failed
|
||||
? 'message__status message__status--error'
|
||||
: 'message__status'
|
||||
}
|
||||
role={
|
||||
message.knowledgeRetrieval || compressionMarkers.length > 0
|
||||
message.state !== 'error' && (message.knowledgeRetrieval || compressionMarkers.length > 0)
|
||||
? undefined
|
||||
: message.state === 'error'
|
||||
: failed
|
||||
? 'alert'
|
||||
: 'status'
|
||||
}
|
||||
@@ -1259,7 +1273,7 @@ function ChatMessageRowView({
|
||||
{statusText}
|
||||
</div>
|
||||
)}
|
||||
{canRetry && (
|
||||
{canRetry && Boolean(retryContent?.trim()) && (
|
||||
<button
|
||||
className="message-retry"
|
||||
onClick={() => {
|
||||
|
||||
@@ -18,6 +18,17 @@ const message: Message = {
|
||||
}
|
||||
|
||||
describe('RuntimeChecklistStrip', () => {
|
||||
it('uses the structured cancellation result when no status text was saved', () => {
|
||||
const { rerender } = render(<RuntimeChecklistStrip messages={[
|
||||
{ ...message, state: 'error', terminalStatus: 'cancelled' }
|
||||
]} />)
|
||||
expect(screen.getByRole('button', { name: /执行清单/ })).toHaveTextContent('任务已取消')
|
||||
rerender(<RuntimeChecklistStrip messages={[
|
||||
{ ...message, state: 'error', terminalStatus: 'failed' }
|
||||
]} />)
|
||||
expect(screen.getByRole('button', { name: /执行清单/ })).not.toHaveTextContent('任务已取消')
|
||||
})
|
||||
|
||||
it('is read-only, preserves collapse and focus during full replacements, and retains terminal item states', () => {
|
||||
const { rerender } = render(<RuntimeChecklistStrip messages={[message]} />)
|
||||
const toggle = screen.getByRole('button', { name: /执行清单/ })
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CheckCircle2, ChevronDown, ChevronUp, Circle, CircleDashed, XCircle } f
|
||||
import { useId, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Message } from './ChatTimeline'
|
||||
import { isCancelledMessage } from './message-terminal-status'
|
||||
import './runtime-checklist.css'
|
||||
|
||||
const statusIcons = {
|
||||
@@ -34,7 +35,8 @@ function Checklist({ message }: { message: Message }): React.JSX.Element {
|
||||
const completed = items.reduce((count, item) => count + Number(item.status === 'completed'), 0)
|
||||
const current = items.find(item => item.status === 'in_progress')
|
||||
const result = message.state === 'streaming' ? undefined : message.status || t(
|
||||
message.state === 'error' ? 'runtimeChecklist.failed' : 'runtimeChecklist.finished'
|
||||
isCancelledMessage(message) ? 'chat.status.taskCancelled'
|
||||
: message.state === 'error' ? 'runtimeChecklist.failed' : 'runtimeChecklist.finished'
|
||||
)
|
||||
const summary = t('runtimeChecklist.count', { completed, total: items.length })
|
||||
return (
|
||||
|
||||
@@ -390,7 +390,7 @@ export const app = {
|
||||
graph: 'Graph'
|
||||
}
|
||||
},
|
||||
retry: 'Edit and send again',
|
||||
retry: 'Edit again',
|
||||
copyMessage: 'Copy this response',
|
||||
loadEarlierMessages: 'Load earlier messages ({{count}} remaining)',
|
||||
scrollToBottom: 'Scroll to bottom',
|
||||
@@ -408,6 +408,7 @@ export const app = {
|
||||
savingImage: 'Image generated; saving the result',
|
||||
taskCompleted: 'Task completed',
|
||||
taskFailed: 'Task failed',
|
||||
taskCancelled: 'Task cancelled',
|
||||
runtimeCompleted: 'Agent Runtime completed its response',
|
||||
activityIncomplete:
|
||||
'The parent request ended before this activity reported completion',
|
||||
|
||||
@@ -373,7 +373,7 @@ export const app = {
|
||||
graph: '图谱'
|
||||
}
|
||||
},
|
||||
retry: '重新编辑并发送',
|
||||
retry: '重新编辑',
|
||||
copyMessage: '复制此回复',
|
||||
loadEarlierMessages: '加载更早的消息(还剩 {{count}} 条)',
|
||||
scrollToBottom: '到底部',
|
||||
@@ -391,6 +391,7 @@ export const app = {
|
||||
savingImage: '图片已生成,正在保存结果',
|
||||
taskCompleted: '任务执行完成',
|
||||
taskFailed: '任务执行失败',
|
||||
taskCancelled: '任务已取消',
|
||||
runtimeCompleted: 'Agent Runtime 已完成响应',
|
||||
activityIncomplete: '父请求已结束,但该活动未报告完成状态',
|
||||
answerSubmitted: '回答已提交,OpenCode 正在继续执行',
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ConversationMessage } from '../../shared/assistant-contracts'
|
||||
|
||||
export function isCancelledMessage(message: Pick<ConversationMessage,
|
||||
'state' | 'terminalStatus' | 'status'>): boolean {
|
||||
if (message.state !== 'error') return false
|
||||
if (message.terminalStatus !== undefined) return message.terminalStatus === 'cancelled'
|
||||
// Released snapshots lack terminalStatus. Only recognize exact old labels;
|
||||
// every new terminal event supplies the structured value above.
|
||||
return message.status === '请求已取消' || message.status === '已取消' ||
|
||||
message.status === '远端 Runtime 请求已取消'
|
||||
}
|
||||
@@ -16543,7 +16543,6 @@ textarea.field-control {
|
||||
.runtime-picker__menu > strong,
|
||||
.runtime-picker__menu > button > small,
|
||||
.message__meta span,
|
||||
.message__status,
|
||||
.settings-panel__description,
|
||||
.settings-tabs button small,
|
||||
.settings-section__title small,
|
||||
|
||||
@@ -395,6 +395,7 @@ export const conversationMessageSchema = z
|
||||
displayCaptureTruncated: z.boolean().optional(),
|
||||
createdAt: z.number().int().nonnegative(),
|
||||
state: z.enum(['streaming', 'complete', 'error']),
|
||||
terminalStatus: z.enum(['failed', 'cancelled']).optional(),
|
||||
status: z.string().max(4_000).optional(),
|
||||
contextCompression:
|
||||
conversationContextCompressionMarkerSchema.optional(),
|
||||
|
||||
Reference in New Issue
Block a user