fix: show accurate request status in message footers
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s

Requests could appear to keep working while OpenCode waited to retry, and
retry backoff was described as an active attempt. Report native OpenCode
retry timing, distinguish direct-model backoff from dispatch, and show
preparation, tool activity, pending input, and terminal states accurately.

Keep only the message-footer indicator static. Conversation-list activity
indicators, send/stop controls, retry limits, and command replay behavior
remain unchanged. Align the feature documentation with verified behavior.

Validation: 3618 tests passed, 61 skipped; typecheck and lint passed.
Isolated desktop validation used five real text-model calls across three
profiles, with controlled local fault injection for retry transitions.

Release note: 消息底部现在准确区分请求准备、等待重试、开始重试和需要回答的状态,不再用闪烁暗示仍有进展;会话列表闪点及发送、停止行为保持不变。
This commit is contained in:
mesalogo
2026-09-08 22:47:06 +08:00
parent 9b7cf730d0
commit c82d0161a9
18 changed files with 289 additions and 37 deletions
+4
View File
@@ -133,6 +133,10 @@ describe('ContinueAgentRuntime', () => {
const events = await collectEvents(runtime)
expect(events[0]).toMatchObject({
type: 'status',
message: 'Continue 正在处理请求'
})
expect(mocks.detectRuntimeBinary).toHaveBeenCalledWith({
binaryPath: '',
bundledPath: undefined,
+1 -1
View File
@@ -504,7 +504,7 @@ export class ContinueAgentRuntime implements AgentRuntime {
yield {
requestId: request.requestId,
type: 'status',
message: 'Continue 正在生成回复'
message: 'Continue 正在处理请求'
}
const execute = request.workMode === 'execute'
+13 -6
View File
@@ -1483,9 +1483,16 @@ describe('ModelAgentRuntime', () => {
expect(events).toContainEqual(
expect.objectContaining({
type: 'status',
message: '模型网络请求失败,正在重试(1/3)'
message: '模型网络请求失败,等待重试(1/3,0.5 秒后)'
})
)
const retryWaiting = events.findIndex((event) =>
event.type === 'status' && event.message.includes('等待重试'))
const retryStarted = events.findIndex((event) =>
event.type === 'status' && event.message.includes('正在重试请求'))
const responseStarted = events.findIndex((event) => event.type === 'text')
expect(retryStarted).toBeGreaterThan(retryWaiting)
expect(responseStarted).toBeGreaterThan(retryStarted)
expect(events).toContainEqual(
expect.objectContaining({
type: 'text',
@@ -1561,7 +1568,7 @@ describe('ModelAgentRuntime', () => {
)
expect(events).not.toContainEqual(
expect.objectContaining({
message: expect.stringContaining('正在重试')
message: expect.stringContaining('等待重试')
})
)
})
@@ -1598,7 +1605,7 @@ describe('ModelAgentRuntime', () => {
expect(fetcher).toHaveBeenCalledOnce()
expect(events).not.toContainEqual(
expect.objectContaining({
message: expect.stringContaining('正在重试')
message: expect.stringContaining('等待重试')
})
)
})
@@ -1628,12 +1635,12 @@ describe('ModelAgentRuntime', () => {
)
await expect(stream.next()).resolves.toMatchObject({
value: { type: 'status', message: 'sonnet-5 正在思考' }
value: { type: 'status', message: 'sonnet-5 请求处理中' }
})
await expect(stream.next()).resolves.toMatchObject({
value: {
type: 'status',
message: '模型网络请求失败,正在重试(1/3)'
message: '模型网络请求失败,等待重试(1/3,0.5 秒后)'
}
})
const pendingRetry = stream.next()
@@ -2051,7 +2058,7 @@ describe('ModelAgentRuntime', () => {
expect(events).toContainEqual(
expect.objectContaining({
type: 'status',
message: '模型网络请求失败,正在重试(1/3)'
message: '模型网络请求失败,等待重试(1/3,0.5 秒后)'
})
)
expect(events).toContainEqual(
+12 -3
View File
@@ -1898,13 +1898,22 @@ export class ModelAgentRuntime implements AgentRuntime {
requestId,
type: 'status',
message:
`模型网络请求失败,正在重试(${retryCount + 1}/` +
`${modelRequestRetryDelaysMs.length})`
`模型网络请求失败,等待重试(${retryCount + 1}/` +
`${modelRequestRetryDelaysMs.length},` +
`${modelRequestRetryDelaysMs[retryCount]! / 1_000} 秒后)`
}
await waitForModelRetry(
modelRequestRetryDelaysMs[retryCount]!,
signal
)
signal.throwIfAborted()
yield {
requestId,
type: 'status',
message:
`${this.options.model} 正在重试请求(${retryCount + 1}/` +
`${modelRequestRetryDelaysMs.length}),等待响应`
}
}
throw new Error('模型请求重试状态无效')
}
@@ -3957,7 +3966,7 @@ export class ModelAgentRuntime implements AgentRuntime {
yield {
requestId: request.requestId,
type: 'status',
message: `${this.options.model} 正在思考`
message: `${this.options.model} 请求处理中`
}
const system = [
+46
View File
@@ -288,6 +288,52 @@ async function collectRun(
}
describe("OpenCodeRuntime embedded launcher", () => {
it("forwards retry scheduling and resumes only on a matching native busy event", async () => {
const next = Date.now() + 120_000;
const setup = runClient([
{
type: "session.status",
properties: { sessionID: "another-session", status: {
type: "retry", attempt: 9, message: "Unrelated retry", next,
} },
},
{
type: "session.status",
properties: { sessionID: "session-1", status: {
type: "retry", attempt: 2, message: "HTTP 503", next,
} },
},
{
type: "session.status",
properties: { sessionID: "another-session", status: { type: "busy" } },
},
{
type: "session.status",
properties: { sessionID: "session-1", status: { type: "busy" } },
},
{
type: "session.status",
properties: { sessionID: "session-1", status: { type: "busy" } },
},
{ type: "session.idle", properties: { sessionID: "session-1" } },
]);
const runtime = embeddedRuntime(setup.client);
try {
const events = await collectRun(runtime);
const statuses = events.filter((event) => event.type === "status");
expect(statuses.map((event) => event.message)).toEqual([
"OpenCode 正在处理请求",
`OpenCode 等待第 2 次重试(计划 ${
new Date(next).toLocaleTimeString("zh-CN", { hour12: false })
}):HTTP 503`,
"OpenCode 已开始重试,等待后续进展",
]);
expect(events.at(-1)).toMatchObject({ type: "done" });
} finally {
await runtime.dispose();
}
});
it("checks embedded availability without starting OpenCode", async () => {
const child = fakeChild();
const {
+27
View File
@@ -2466,6 +2466,7 @@ export class OpenCodeRuntime implements AgentRuntime {
return false;
};
const reportedMessageIds = new Set<string>();
let waitingForRetry = false;
for await (const event of subscription.stream) {
const childProgress = subagentProgress.update(event);
if (
@@ -2479,6 +2480,32 @@ export class OpenCodeRuntime implements AgentRuntime {
if (task) task.subagent = childProgress;
yield childProgress;
}
if (
event.type === "session.status" &&
event.properties.sessionID === sessionId
) {
const status = event.properties.status;
if (status.type === "retry") {
waitingForRetry = true;
const scheduled = Number.isSafeInteger(status.next) && status.next > 0
? `(计划 ${new Date(status.next).toLocaleTimeString("zh-CN", { hour12: false })})`
: "";
yield {
requestId: request.requestId,
type: "status",
message: `OpenCode 等待第 ${status.attempt} 次重试${scheduled}:${
safeToolErrorDetail(status.message, 1_000) || "模型请求失败"
}`,
};
} else if (status.type === "busy" && waitingForRetry) {
waitingForRetry = false;
yield {
requestId: request.requestId,
type: "status",
message: "OpenCode 已开始重试,等待后续进展",
};
}
}
if (
event.type === "message.updated" &&
event.properties.sessionID === sessionId &&
+45 -10
View File
@@ -3378,9 +3378,9 @@ describe("App", () => {
});
expect(
screen
.getByText("正在连接 Agent Runtime")
.getByText("正在准备请求")
.querySelector(".message__status-dot"),
).toHaveClass("message__status-dot--active");
).not.toHaveClass("message__status-dot--active");
const userMessage = screen
.getAllByText("帮我分析项目")
.map((element) => element.closest("article"))
@@ -3824,6 +3824,41 @@ describe("App", () => {
expect(screen.getByText("正在分析真实推理内容")).toBeVisible();
});
it("keeps request status truthful through retry, progress, and completion", async () => {
render(<App />);
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
target: { value: "检查请求状态" },
});
await waitFor(() => expect(screen.getByLabelText("发送")).toBeEnabled());
fireEvent.click(screen.getByLabelText("发送"));
await waitFor(() => expect(run).toHaveBeenCalledOnce());
const request = run.mock.calls[0]![0];
const retry = "OpenCode 等待第 2 次重试(计划 12:34:56):HTTP 503";
expect(screen.getByRole("status", { name: "正在准备请求" })).toBeVisible();
act(() => agentListener?.({
requestId: request.requestId, type: "status", message: retry,
}));
const footer = screen.getByRole("status", { name: retry });
expect(footer).toBeVisible();
expect(footer.querySelector(".message__status-dot--active")).toBeNull();
expect(screen.queryByText("正在准备请求")).not.toBeInTheDocument();
act(() => agentListener?.({
requestId: request.requestId, type: "text", delta: "恢复后的实际回复",
}));
expect(screen.queryByText(retry)).not.toBeInTheDocument();
expect(screen.getByRole("status", { name: "等待后续进展" })).toBeVisible();
act(() => agentListener?.({
requestId: request.requestId, type: "done",
}));
expect(screen.queryByText("等待后续进展")).not.toBeInTheDocument();
expect(screen.getByRole("status", { name: "任务执行完成" })).toBeVisible();
act(() => agentListener?.({
requestId: request.requestId, type: "status", message: retry,
}));
expect(screen.queryByText(retry)).not.toBeInTheDocument();
expect(screen.getByRole("status", { name: "任务执行完成" })).toBeVisible();
});
it("updates context usage after model responses and keeps compression status", async () => {
const settings = await api.settings.getRuntime();
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
@@ -3903,11 +3938,11 @@ describe("App", () => {
agentListener?.({
requestId: request.requestId,
type: "status",
message: "sonnet-5 正在思考",
message: "sonnet-5 请求处理中",
});
});
expect(screen.getByText("正在压缩较早对话…")).toBeInTheDocument();
expect(screen.getByText("sonnet-5 正在思考")).toBeInTheDocument();
expect(screen.queryByText("sonnet-5 请求处理中")).not.toBeInTheDocument();
act(() => {
agentListener?.({
@@ -6281,9 +6316,9 @@ describe("App", () => {
}
expect(
screen
.getByText("正在连接 Agent Runtime")
.getByText("正在准备请求")
.querySelector(".message__status-dot"),
).toHaveClass("message__status-dot--active");
).not.toHaveClass("message__status-dot--active");
expect(screen.getByLabelText("加入待发送队列")).toBeInTheDocument();
vi.mocked(api.conversationQueue.ready).mockClear();
return {
@@ -6350,7 +6385,7 @@ describe("App", () => {
).toBeInTheDocument();
await waitFor(() =>
expect(
screen.queryByText("正在连接 Agent Runtime"),
screen.queryByText("正在准备请求"),
).not.toBeInTheDocument(),
);
expect(document.querySelector(".message__status-dot--active")).toBeNull();
@@ -6397,7 +6432,7 @@ describe("App", () => {
await screen.findByText("远端 Agent 执行失败"),
).toBeInTheDocument();
expect(
screen.queryByText("正在连接 Agent Runtime"),
screen.queryByText("正在准备请求"),
).not.toBeInTheDocument();
expect(document.querySelector(".message__status-dot--active")).toBeNull();
await waitFor(() =>
@@ -6427,7 +6462,7 @@ describe("App", () => {
expect(api.conversations.list).toHaveBeenCalledTimes(2),
);
// The local streaming copy still wins over a non-terminal snapshot.
expect(screen.getByText("正在连接 Agent Runtime")).toBeInTheDocument();
expect(screen.getByText("正在准备请求")).toBeInTheDocument();
expect(screen.getByLabelText("加入待发送队列")).toBeInTheDocument();
expect(api.conversationQueue.ready).not.toHaveBeenCalled();
@@ -6447,7 +6482,7 @@ describe("App", () => {
await screen.findByText("聚焦后读到的最终回复"),
).toBeInTheDocument();
expect(
screen.queryByText("正在连接 Agent Runtime"),
screen.queryByText("正在准备请求"),
).not.toBeInTheDocument();
await waitFor(() =>
expect(api.conversationQueue.ready).toHaveBeenCalledWith(
+2 -1
View File
@@ -4431,6 +4431,7 @@ function App(): React.JSX.Element {
...message,
tools,
blocks,
status: undefined,
};
});
} else if (event.type === "subagent") {
@@ -7015,7 +7016,7 @@ function App(): React.JSX.Element {
blocks: [],
createdAt: Date.now(),
state: "streaming",
status: t("runtime.connecting"),
status: t("chat.status.preparingRequest"),
};
activeRuns.current.set(requestId, {
+42
View File
@@ -66,6 +66,48 @@ describe('ChatTimeline', () => {
vi.clearAllMocks()
})
it('shows only known waiting and tool states in a static message footer', () => {
const props = {
artifactById: new Map(), conversationId: 'status-conversation',
hiddenMessageCount: 0, isUnusedConversation: false, locale: 'zh-CN' as const,
messageStartIndex: 0, ...callbacks, retryContent: '', totalMessageCount: 1
}
const message: Message = {
id: 'status-message', role: 'assistant', content: '已有内容',
createdAt: Date.now(), state: 'streaming'
}
const { rerender } = render(<ChatTimeline {...props} messages={[message]} />)
expect(screen.getByRole('status', { name: '等待后续进展' })).toBeVisible()
expect(document.querySelector('.message__status-dot--active')).toBeNull()
const tool = { callId: 'read-1', name: 'read', summary: 'Read file' }
rerender(<ChatTimeline {...props} messages={[{
...message, tools: [{ ...tool, state: 'pending' }]
}]} />)
expect(screen.getByRole('status', { name: '工具待执行:read' })).toBeVisible()
rerender(<ChatTimeline {...props} messages={[{
...message, tools: [{ ...tool, state: 'running' }]
}]} />)
expect(screen.getByRole('status', { name: '工具执行中:read' })).toBeVisible()
rerender(<ChatTimeline {...props} messages={[{
...message, tools: [{ ...tool, state: 'completed' }]
}]} />)
expect(screen.getByRole('status', { name: '等待后续进展' })).toBeVisible()
rerender(<ChatTimeline {...props} messages={[{
...message, status: 'OpenCode 正在处理请求',
question: {
requestId: 'question-request', type: 'question', questionId: 'q-1',
questions: [{ header: '确认', question: '继续吗?', options: [], custom: true, multiple: false }]
}
}]} />)
expect(screen.getByRole('status', { name: '等待你的回答' })).toBeVisible()
expect(screen.queryByText('OpenCode 正在处理请求')).not.toBeInTheDocument()
rerender(<ChatTimeline {...props} messages={[{
...message, state: 'error', status: '模型请求失败'
}]} />)
expect(screen.getByRole('alert', { name: '模型请求失败' })).toBeVisible()
expect(screen.queryByText('等待后续进展')).not.toBeInTheDocument()
})
it('uses actual message heights for the conversation scroll range', () => {
const rule = stylesheet.match(/\.message\s*\{([^}]*)\}/u)?.[1]
+25 -8
View File
@@ -525,6 +525,27 @@ function ChatMessageRowView({
? t('chat.contextCompression.agentFailed')
: t('chat.contextCompression.failed')
const activeTool = message.tools?.find((tool) =>
tool.state === 'pending' || tool.state === 'running')
const activeSubagent = message.subagents?.some((subagent) =>
subagent.state === 'queued' || subagent.state === 'running')
const activeCompression = compressionMarkers.find((marker) =>
marker.state === 'compressing')
const statusText = message.role === 'assistant' && message.state === 'streaming'
? message.question
? t('chat.status.waitingForAnswer')
: message.approval
? t('chat.status.waitingForApproval')
: activeCompression
? undefined
: message.status || (activeTool
? t(activeTool.state === 'running'
? 'chat.status.runningTool' : 'chat.status.pendingTool', { name: activeTool.name })
: activeSubagent
? t('chat.status.waitingForSubagent')
: t('chat.status.waitingForProgress'))
: message.status
return (
<>
<article
@@ -1024,10 +1045,10 @@ function ChatMessageRowView({
{t('chat.status.displayCaptureTruncated')}
</div>
)}
{message.status && (
{statusText && (
<div
aria-atomic="true"
aria-label={message.status}
aria-label={statusText}
aria-live={
message.knowledgeRetrieval || compressionMarkers.length > 0
? undefined
@@ -1050,13 +1071,9 @@ function ChatMessageRowView({
>
<span
aria-hidden="true"
className={
message.state === 'streaming'
? 'message__status-dot message__status-dot--active'
: 'message__status-dot'
}
className="message__status-dot"
/>
{message.status}
{statusText}
</div>
)}
{canRetry && (
@@ -335,6 +335,13 @@ export const app = {
loadEarlierMessages: 'Load earlier messages ({{count}} remaining)',
scrollToBottom: 'Scroll to bottom',
status: {
preparingRequest: 'Preparing request',
waitingForProgress: 'Waiting for further progress',
waitingForAnswer: 'Waiting for your answer',
waitingForApproval: 'Waiting for your confirmation',
pendingTool: 'Tool pending: {{name}}',
runningTool: 'Tool running: {{name}}',
waitingForSubagent: 'Waiting for subtasks to finish',
responseTruncated: 'The response was too long and was truncated locally',
displayCaptureTruncated:
'Later activity details were not retained locally; the Runtime task continued',
@@ -318,6 +318,13 @@ export const app = {
loadEarlierMessages: '加载更早的消息(还剩 {{count}} 条)',
scrollToBottom: '到底部',
status: {
preparingRequest: '正在准备请求',
waitingForProgress: '等待后续进展',
waitingForAnswer: '等待你的回答',
waitingForApproval: '等待你的确认',
pendingTool: '工具待执行:{{name}}',
runningTool: '工具执行中:{{name}}',
waitingForSubagent: '等待子任务完成',
responseTruncated: '回答过长,已在本地截断显示',
displayCaptureTruncated:
'活动记录过多,后续详情未在本地保留;Runtime 任务仍继续执行',