fix(ui): reconcile completed chat sends

Fixes #87699.\n\nRoutes ACK-completed Control UI chat sends through the existing run lifecycle reconciliation path so stale selected-session rows cannot re-enable the composer/Stop state after the conversation has already completed.\n\nVerification: focused UI/unit tests, Control UI E2E chat-flow test, autoreview clean, Testbox changed gate tbx_01kt68xvz17fcnmd3wj6f7pk6f, and PR CI run 26872484363 green after failed-job rerun for transient runner setup failures.
This commit is contained in:
zhang-guiping
2026-06-03 16:34:13 +08:00
committed by GitHub
parent 114864185b
commit 0b98aea71a
4 changed files with 67 additions and 4 deletions

View File

@@ -10,6 +10,7 @@ import {
resetChatAttachmentPayloadStoreForTest,
} from "./chat/attachment-payload-store.ts";
import type { executeSlashCommand } from "./chat/slash-command-executor.ts";
import { loadSessions } from "./controllers/sessions.ts";
import type { GatewaySessionRow, SessionsListResult } from "./types.ts";
type ExecuteSlashCommand = typeof executeSlashCommand;
@@ -2116,6 +2117,43 @@ describe("handleSendChat", () => {
expect(userMessage.role).toBe("user");
});
it("keeps ACK-completed sends idle when sessions.list returns a stale active row", async () => {
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "chat.send") {
const payload = requireRecord(params, "chat send payload");
return { runId: payload.idempotencyKey, status: "ok" };
}
if (method === "chat.history") {
return { messages: [] };
}
if (method === "sessions.list") {
return createSessionsResult([
row("agent:main", { hasActiveRun: true, status: "running", startedAt: 1 }),
]);
}
throw new Error(`Unexpected request: ${method}`);
});
const host = makeHost({
client: { request } as unknown as ChatHost["client"],
chatMessage: "already done",
sessionsResult: createSessionsResult([
row("agent:main", { hasActiveRun: true, status: "running", startedAt: 1 }),
]),
});
await handleSendChat(host);
await Promise.resolve();
await loadSessions(host as unknown as Parameters<typeof loadSessions>[0]);
expect(host.chatRunId).toBeNull();
expect(host.chatStream).toBeNull();
expect(hasAbortableSessionRun(host)).toBe(false);
expect(host.sessionsResult?.sessions[0]).toMatchObject({
hasActiveRun: false,
status: "done",
});
});
it("keeps delayed chat.send ACK effects scoped to the submitted session", async () => {
const sent = createDeferred<unknown>();
const request = vi.fn((method: string) => {

View File

@@ -928,6 +928,7 @@ async function sendQueuedChatMessage(
reconcileChatRunLifecycle(
host as unknown as Parameters<typeof reconcileChatRunLifecycle>[0],
{
outcome: "done",
sessionStatus: "done",
runId: ack.runId,
sessionKey,
@@ -935,7 +936,8 @@ async function sendQueuedChatMessage(
clearChatStream: true,
clearToolStream: true,
clearSideResultTerminalRuns: true,
clearRunStatus: true,
publishRunStatus: false,
armLocalTerminalReconcile: true,
},
);
void loadChatHistory(host as unknown as ChatState);

View File

@@ -1538,6 +1538,20 @@ describe("sendChatMessage", () => {
expect(state.chatRunId).toBeNull();
expect(state.chatStream).toBeNull();
expect(state.chatStreamStartedAt).toBeNull();
const runState = state as ChatState & {
chatRunStatus?: unknown;
lastLocalTerminalReconcile?: unknown;
};
expect(runState.chatRunStatus).toMatchObject({
phase: "done",
runId: "gateway-complete-run",
sessionKey: "main",
});
expect(runState.lastLocalTerminalReconcile).toMatchObject({
phase: "done",
runId: "gateway-complete-run",
sessionKey: "main",
});
});
it("serializes non-image chat attachments as files", async () => {

View File

@@ -949,9 +949,18 @@ export async function sendChatMessage(
try {
const ack = await requestChatSend(state, { message: msg, attachments, runId });
if (ack.status === "ok") {
state.chatRunId = null;
state.chatStream = null;
state.chatStreamStartedAt = null;
reconcileChatRunLifecycle(
state as unknown as Parameters<typeof reconcileChatRunLifecycle>[0],
{
outcome: "done",
sessionStatus: "done",
runId: ack.runId,
sessionKey: state.sessionKey,
clearLocalRun: true,
clearChatStream: true,
armLocalTerminalReconcile: true,
},
);
} else {
state.chatRunId = ack.runId;
}