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:
@@ -1,5 +1,38 @@
|
||||
# 工作栏实现与验证进度
|
||||
|
||||
## 2026-09-28:消息取消与失败展示
|
||||
|
||||
消息终态按[Runtime 交互边界](./runtime-interactions.md)保存和展示。取消使用结构化
|
||||
`terminalStatus`,保持已有回答;终止提示不再补入正文。“重新编辑”只回填并聚焦输入框,
|
||||
没有可回填文字时隐藏入口。旧快照只对精确重复的形态隐藏状态副本,保留原正文。
|
||||
深色主题已移除覆盖失败提示颜色的通用规则;检索和压缩记录不再屏蔽终态的无障碍角色。
|
||||
|
||||
Windows 本地验证:
|
||||
|
||||
- 本次修复完成时 `npm run typecheck`、`npm run lint`、`git diff --check` 通过。
|
||||
后续并行的应用入口修改新增 `App.test.tsx:14430` 的 `getByRole(..., { exact: true })`,
|
||||
最后一次类型检查报 TS2769;该选项不属于 `ByRoleOptions`。未修改这处他人新增代码,
|
||||
当前组合工作区不能记为类型检查通过。最后一次 lint 和 diff 检查通过。
|
||||
- `npm test`:5,367 通过、84 跳过、1 失败。失败项为
|
||||
`tests/magic-notes-layout.electron.test.ts` 的原生拖拽测试,90 秒超时;单独复跑仍超时。
|
||||
该文件未修改,本次检查不记为全量通过。
|
||||
- 相关五文件复跑为 447 通过、14 失败;14 项均在并行入口改名期间查找“应用”按钮失败。
|
||||
入口源码更新后,单独复跑 `src/renderer/src/App.test.tsx` 的 304 项全部通过。
|
||||
其余四文件(数据库、时间线、清单和终态 Electron 探针)的 157 项在上述复跑中通过。
|
||||
- `tests/terminal-status-probe.electron.test.ts` 通过真实 App、Preload、生产 IPC、
|
||||
直连 Runtime 和 SQLite 验证空回答取消、部分回答取消、HTTP 400 失败;检查提示唯一性、
|
||||
浅深主题 computed style、重新编辑回填与焦点,以及数据库重开和页面恢复。
|
||||
每轮使用 3 次本地确定性 HTTP 请求,外部模型调用 0 次。探针在结束时关闭 Runtime、
|
||||
HTTP 服务和窗口,并删除本轮独立临时目录。
|
||||
- 数据库回归覆盖恢复事件事务回滚、失败与取消重放、空正文和已有回答保留、重开后元数据。
|
||||
App 和时间线回归覆盖新事件结构化判定、旧消息精确去重、正常正文保留、无效按钮隐藏、
|
||||
活动及工具终态。
|
||||
|
||||
本次未连接远程主机,未创建远程资源。远端事件仍通过既有公共事件进入桌面的
|
||||
`reduceRecoveredAgentEvent`,本次仅修改其消息投影与元数据保存,不改变 Agent、SSH
|
||||
传输、取消执行、ACK 或重连控制;本地 SQLite 恢复测试覆盖该修改。未验证 Linux/macOS
|
||||
原生界面,不把本地探针记为远端取消生命周期验收。
|
||||
|
||||
## 2026-09-28:DS Web 资源符号链接与 macOS 发布恢复
|
||||
|
||||
Desktop `v0.15.0` 原生构建中,两个 macOS 目标均因包外符号链接未通过签名校验。
|
||||
|
||||
@@ -100,6 +100,16 @@ DeepSeek Harness 按调用 ID 保留名称、输入和摘要,结束事件省
|
||||
为静态,不凭请求尚未结束暗示仍有进展;会话列表闪点和发送、停止按钮保持原有行为。
|
||||
持久化快照中的同一条消息仍为生成中时,也不能覆盖本地已收到的终态,即使快照的
|
||||
会话更新时间更晚;否则连续发送或刷新会让已结束的请求重新显示停止按钮。
|
||||
- 失败和取消的终止提示只进入消息状态,不补写正文或文本块,已有回答、推理与工具输出
|
||||
保留。取消使用中性色和礼貌级 `status`,真实失败使用危险色和 `alert`;运行记录的
|
||||
取消结果标题为“任务已取消”。工具详情已表达同一失败时,不再复制到消息底部。
|
||||
- 最新失败或取消消息只有在上一条用户消息含可回填文字时显示“重新编辑”。点击只回填
|
||||
输入框并聚焦,由用户修改后自行发送;附件单独发送且无文字时不显示此按钮。
|
||||
- 实时事件和 Main 历史恢复均将事件的 `failed / cancelled` 写入消息元数据
|
||||
`terminalStatus`;既有 `state: error` 继续表示未成功终态,不改 SQLite 枚举。
|
||||
新事件不按提示文案识别取消。旧快照无此字段时,仅兼容已发布的精确取消标签。
|
||||
旧记录若正文与状态完全相同,且无文本块或唯一文本块位于末尾并等于该状态,展示时
|
||||
保留正文、隐藏重复状态;不修改持久数据,不删除正文中的同名句子或部分匹配内容。
|
||||
- Continue、DeepSeek Harness 和远端 ACP 保留其已接入的处理与工具事件,不伪造未接入
|
||||
的原生重试状态。直连模型的重试等待与请求开始文案见
|
||||
[直连模型 UI 设计](../direct-model-agent/ui-design.md#6-错误与通知);本次不改变任何
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createServer } from 'node:http'
|
||||
import { join } from 'node:path'
|
||||
import { registerIpcHandlers } from '../../src/main/ipc'
|
||||
import { AssistantDatabase } from '../../src/main/assistant/assistant-database'
|
||||
import { ApplicationSettingsStore } from '../../src/main/application-settings-store'
|
||||
import { createDefaultModelRuntime } from '../../src/main/agent/create-runtime'
|
||||
import { defaultRuntimeSettings } from '../../src/shared/contracts'
|
||||
import { ContextManager } from '../../src/main/context-manager'
|
||||
|
||||
const directory = process.env.GB_CAPTURE_DIRECTORY!
|
||||
app.setPath('userData', join(directory, 'profile'))
|
||||
app.whenReady().then(async () => {
|
||||
let calls = 0
|
||||
let scenario = 'empty'
|
||||
const server = createServer(async (req, res) => {
|
||||
for await (const chunk of req) { void chunk /* Drain the real model request. */ }
|
||||
calls++
|
||||
if (scenario === 'failed') { res.writeHead(400, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: { message: 'Probe failure' } })); return }
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
res.write(`data: ${JSON.stringify({ id: 'probe', choices: [{ index: 0, delta: { role: 'assistant', content: scenario === 'partial' ? 'PARTIAL_REPLY' : '' }, finish_reason: null }] })}\n\n`)
|
||||
})
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const modelBaseUrl = `http://127.0.0.1:${(server.address() as { port: number }).port}/v1`
|
||||
const settings = { ...defaultRuntimeSettings, workspacePath: directory, defaultModelProfileId: '', modelProfiles: [], embeddingConnections: [], modelBaseUrl,
|
||||
modelName: 'terminal-probe', modelProtocol: 'openai-chat-completions' as const, modelAuthentication: 'none' as const }
|
||||
const runtime = createDefaultModelRuntime(directory, settings)
|
||||
const win = new BrowserWindow({ show: true, width: 1100, height: 800, webPreferences: {
|
||||
sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false, preload: join(directory, 'preload.cjs')
|
||||
} })
|
||||
const database = new AssistantDatabase(join(directory, 'assistant.sqlite')); database.initialize(directory)
|
||||
const appSettings = new ApplicationSettingsStore(join(directory, 'application.json'))
|
||||
await appSettings.update({ checkUpdatesOnStartup: false, heartbeatEnabled: false })
|
||||
const dispose = registerIpcHandlers(win, runtime, 'CommandOrControl+Shift+Space',
|
||||
{ getResolvedSettings: async () => settings, getPublicSettings: async () => settings } as never,
|
||||
{ getEnabledBuiltinMcpServerIds: async () => [] } as never, new ContextManager(),
|
||||
{ snapshot: () => ({ libraries: [], sources: [], documents: [], entities: [], relations: [], evidence: [], tasks: [] }),
|
||||
database: { externalStore: { listBindings: () => [] } }, external: { listInstances: () => [] } } as never,
|
||||
database, { clear() {} } as never, {} as never, async () => {}, undefined, undefined, undefined, undefined, appSettings,
|
||||
undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined,
|
||||
{ getPending: async () => undefined } as never)
|
||||
const run = <T = unknown>(script: string): Promise<T> => win.webContents.executeJavaScript(script, true)
|
||||
async function wait(script: string) {
|
||||
for (let i = 0; i < 300; i++) { if (await run(script)) return; await new Promise(resolve => setTimeout(resolve, 50)) }
|
||||
throw new Error(`Timeout ${script}: ${await run('document.body.innerText')}`)
|
||||
}
|
||||
const row = 'document.querySelectorAll(".message--assistant")[document.querySelectorAll(".message--assistant").length-1]'
|
||||
try {
|
||||
await win.loadURL(process.env.GB_CAPTURE_URL!)
|
||||
for (scenario of ['empty', 'partial', 'failed']) {
|
||||
const expectedCalls = calls + 1
|
||||
await wait('!!document.querySelector(".composer textarea")')
|
||||
await run(`(() => { const e=document.querySelector('.composer textarea'); const set=Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set; set.call(e,${JSON.stringify('Probe ')}+${JSON.stringify(scenario)});e.dispatchEvent(new Event('input',{bubbles:true})); })()`)
|
||||
await wait('!document.querySelector(".send-button").disabled')
|
||||
await run('document.querySelector(".send-button").click()')
|
||||
for (let i = 0; calls < expectedCalls && i < 200; i++) await new Promise(resolve => setTimeout(resolve, 50))
|
||||
assert.equal(calls, expectedCalls)
|
||||
if (scenario !== 'failed') {
|
||||
if (scenario === 'partial') await wait(`${row}.textContent.includes('PARTIAL_REPLY')`)
|
||||
await wait('!!document.querySelector(".send-button--stop")')
|
||||
await run('document.querySelector(".send-button--stop").click()')
|
||||
}
|
||||
const role = scenario === 'failed' ? 'alert' : 'status'
|
||||
await wait(`${row}.querySelector('.message__status')?.getAttribute('role') === '${role}' && !!${row}.querySelector('.message-retry')`)
|
||||
const status = await run<string>(`${row}.querySelector('.message__status').textContent`)
|
||||
assert.equal(await run(`${row}.querySelectorAll('.message__content').length`), scenario === 'partial' ? 1 : 0)
|
||||
assert.equal(await run(`${row}.textContent.split(${JSON.stringify(status)}).length - 1`), 1)
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await run(`document.documentElement.dataset.theme='${theme}'`)
|
||||
assert.equal(await run(`${row}.querySelector('.message__status').classList.contains('message__status--error')`), scenario === 'failed')
|
||||
const colors = await run<string[]>(`(() => { const e=${row}.querySelector('.message__status'), c=document.createElement('span'); c.style.color='var(${scenario === 'failed' ? '--danger' : '--text-muted'})';e.append(c); const result=[getComputedStyle(e).color,getComputedStyle(c).color];c.remove();return result })()`)
|
||||
assert.equal(colors[0], colors[1])
|
||||
}
|
||||
await run(`${row}.querySelector('.message-retry').click()`)
|
||||
assert.equal(await run('document.activeElement === document.querySelector(".composer textarea")'), true)
|
||||
assert.equal(await run('document.querySelector(".composer textarea").value'), `Probe ${scenario}`)
|
||||
assert.equal(calls, expectedCalls)
|
||||
let saved
|
||||
for (let i = 0; i < 200; i++) {
|
||||
saved = database.listConversations()[0]?.messages.at(-1)
|
||||
if (saved?.terminalStatus === (scenario === 'failed' ? 'failed' : 'cancelled')) break
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
}
|
||||
assert.equal(saved?.terminalStatus, scenario === 'failed' ? 'failed' : 'cancelled')
|
||||
assert.equal(saved?.content, scenario === 'partial' ? 'PARTIAL_REPLY' : '')
|
||||
database.close(); database.initialize(directory)
|
||||
await win.loadURL(process.env.GB_CAPTURE_URL!)
|
||||
await wait(`${row}?.querySelector('.message__status')?.getAttribute('role') === '${role}'`)
|
||||
assert.equal(await run(`${row}.querySelectorAll('.message__content').length`), scenario === 'partial' ? 1 : 0)
|
||||
}
|
||||
console.log(JSON.stringify({ nativeElectron: true, productionIpc: true, sqliteReopened: true, themes: ['light', 'dark'], scenarios: 3, deterministicHttpCalls: calls, paidCalls: 0 }))
|
||||
} finally { dispose(); database.close(); await runtime.dispose(); server.closeAllConnections(); server.close(); win.destroy() }
|
||||
}).then(() => app.exit(0)).catch(error => { console.error(error); app.exit(1) })
|
||||
@@ -0,0 +1,46 @@
|
||||
// @vitest-environment node
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
import { createServer } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
it('validates terminal UI through production model HTTP, IPC, persistence and reload', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-terminal-probe-'))
|
||||
const server = await createServer({ configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'), plugins: [react(), {
|
||||
name: 'terminal-probe', configureServer(server) { server.middlewares.use('/capture.html', async (_req, res) => {
|
||||
res.setHeader('Content-Type', 'text/html'); res.end(await server.transformIndexHtml('/capture.html', '<html><body><div id="root"></div><script type="module" src="/tests/support/magic-notes-capture-renderer.tsx"></script></body></html>'))
|
||||
}) }
|
||||
}], server: { host: '127.0.0.1', port: 0, watch: null }, optimizeDeps: { entries: ['tests/support/magic-notes-capture-renderer.tsx'] } })
|
||||
try {
|
||||
const driver = join(directory, 'main.mjs')
|
||||
await build({ entryPoints: ['tests/support/terminal-status-probe-main.ts'], outfile: driver, bundle: true, platform: 'node', format: 'esm', external: ['electron'], plugins: [
|
||||
{ name: 'no-channels', setup(build) {
|
||||
build.onResolve({ filter: /channels\/channel-env$/ }, () => ({ path: 'channels', namespace: 'probe' }))
|
||||
build.onLoad({ filter: /.*/, namespace: 'probe' }, () => ({ contents: 'export const startEnvironmentChannels=()=>[];' }))
|
||||
} },
|
||||
{ name: 'external-packages', setup(build) { build.onResolve({ filter: /^[^./]/ }, async args => {
|
||||
if (args.pluginData?.resolved || /^[A-Za-z]:/.test(args.path) || args.path.startsWith('node:') || args.path === 'electron') return
|
||||
const result = await build.resolve(args.path, { resolveDir: resolve('.'), kind: 'import-statement', pluginData: { resolved: true } })
|
||||
return result.errors.length ? { errors: result.errors } : { path: result.external ? result.path : pathToFileURL(result.path).href, external: true }
|
||||
}) } }
|
||||
] })
|
||||
await build({ entryPoints: ['src/preload/index.ts'], outfile: join(directory, 'preload.cjs'), bundle: true, platform: 'node', format: 'cjs', external: ['electron'] })
|
||||
await build({ entryPoints: ['src/main/execution-stats-worker.ts'], outfile: join(directory, 'out/main/execution-stats-worker.js'), bundle: true, platform: 'node', format: 'cjs' })
|
||||
await server.listen()
|
||||
const bootstrap = join(directory, 'bootstrap.cjs')
|
||||
await writeFile(bootstrap, `import(${JSON.stringify(pathToFileURL(driver).href)}).catch(e=>{console.error(e);require('electron').app.exit(1)})`)
|
||||
const env = { ...process.env, GB_CAPTURE_DIRECTORY: directory, GB_CAPTURE_URL: server.resolvedUrls!.local[0] + 'capture.html' }
|
||||
delete (env as NodeJS.ProcessEnv).ELECTRON_RUN_AS_NODE
|
||||
const child = spawn(createRequire(import.meta.url)('electron'), [bootstrap], { env, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let output = ''; child.stdout.on('data', data => { output += data }); child.stderr.on('data', data => { output += data })
|
||||
const timeout = setTimeout(() => child.kill(), 100000)
|
||||
try { expect(await new Promise(resolve => child.once('exit', resolve)), output).toBe(0); console.log(output) }
|
||||
finally { clearTimeout(timeout) }
|
||||
} finally { await server.close(); await rm(directory, { recursive: true, force: true }) }
|
||||
}, 120000)
|
||||
Reference in New Issue
Block a user