Skip to content

Commit 0b98aea

Browse files
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.
1 parent 1148641 commit 0b98aea

4 files changed

Lines changed: 67 additions & 4 deletions

File tree

ui/src/ui/app-chat.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
resetChatAttachmentPayloadStoreForTest,
1111
} from "./chat/attachment-payload-store.ts";
1212
import type { executeSlashCommand } from "./chat/slash-command-executor.ts";
13+
import { loadSessions } from "./controllers/sessions.ts";
1314
import type { GatewaySessionRow, SessionsListResult } from "./types.ts";
1415

1516
type ExecuteSlashCommand = typeof executeSlashCommand;
@@ -2116,6 +2117,43 @@ describe("handleSendChat", () => {
21162117
expect(userMessage.role).toBe("user");
21172118
});
21182119

2120+
it("keeps ACK-completed sends idle when sessions.list returns a stale active row", async () => {
2121+
const request = vi.fn(async (method: string, params?: unknown) => {
2122+
if (method === "chat.send") {
2123+
const payload = requireRecord(params, "chat send payload");
2124+
return { runId: payload.idempotencyKey, status: "ok" };
2125+
}
2126+
if (method === "chat.history") {
2127+
return { messages: [] };
2128+
}
2129+
if (method === "sessions.list") {
2130+
return createSessionsResult([
2131+
row("agent:main", { hasActiveRun: true, status: "running", startedAt: 1 }),
2132+
]);
2133+
}
2134+
throw new Error(`Unexpected request: ${method}`);
2135+
});
2136+
const host = makeHost({
2137+
client: { request } as unknown as ChatHost["client"],
2138+
chatMessage: "already done",
2139+
sessionsResult: createSessionsResult([
2140+
row("agent:main", { hasActiveRun: true, status: "running", startedAt: 1 }),
2141+
]),
2142+
});
2143+
2144+
await handleSendChat(host);
2145+
await Promise.resolve();
2146+
await loadSessions(host as unknown as Parameters<typeof loadSessions>[0]);
2147+
2148+
expect(host.chatRunId).toBeNull();
2149+
expect(host.chatStream).toBeNull();
2150+
expect(hasAbortableSessionRun(host)).toBe(false);
2151+
expect(host.sessionsResult?.sessions[0]).toMatchObject({
2152+
hasActiveRun: false,
2153+
status: "done",
2154+
});
2155+
});
2156+
21192157
it("keeps delayed chat.send ACK effects scoped to the submitted session", async () => {
21202158
const sent = createDeferred<unknown>();
21212159
const request = vi.fn((method: string) => {

ui/src/ui/app-chat.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -928,14 +928,16 @@ async function sendQueuedChatMessage(
928928
reconcileChatRunLifecycle(
929929
host as unknown as Parameters<typeof reconcileChatRunLifecycle>[0],
930930
{
931+
outcome: "done",
931932
sessionStatus: "done",
932933
runId: ack.runId,
933934
sessionKey,
934935
clearLocalRun: true,
935936
clearChatStream: true,
936937
clearToolStream: true,
937938
clearSideResultTerminalRuns: true,
938-
clearRunStatus: true,
939+
publishRunStatus: false,
940+
armLocalTerminalReconcile: true,
939941
},
940942
);
941943
void loadChatHistory(host as unknown as ChatState);

ui/src/ui/controllers/chat.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1538,6 +1538,20 @@ describe("sendChatMessage", () => {
15381538
expect(state.chatRunId).toBeNull();
15391539
expect(state.chatStream).toBeNull();
15401540
expect(state.chatStreamStartedAt).toBeNull();
1541+
const runState = state as ChatState & {
1542+
chatRunStatus?: unknown;
1543+
lastLocalTerminalReconcile?: unknown;
1544+
};
1545+
expect(runState.chatRunStatus).toMatchObject({
1546+
phase: "done",
1547+
runId: "gateway-complete-run",
1548+
sessionKey: "main",
1549+
});
1550+
expect(runState.lastLocalTerminalReconcile).toMatchObject({
1551+
phase: "done",
1552+
runId: "gateway-complete-run",
1553+
sessionKey: "main",
1554+
});
15411555
});
15421556

15431557
it("serializes non-image chat attachments as files", async () => {

ui/src/ui/controllers/chat.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -949,9 +949,18 @@ export async function sendChatMessage(
949949
try {
950950
const ack = await requestChatSend(state, { message: msg, attachments, runId });
951951
if (ack.status === "ok") {
952-
state.chatRunId = null;
953-
state.chatStream = null;
954-
state.chatStreamStartedAt = null;
952+
reconcileChatRunLifecycle(
953+
state as unknown as Parameters<typeof reconcileChatRunLifecycle>[0],
954+
{
955+
outcome: "done",
956+
sessionStatus: "done",
957+
runId: ack.runId,
958+
sessionKey: state.sessionKey,
959+
clearLocalRun: true,
960+
clearChatStream: true,
961+
armLocalTerminalReconcile: true,
962+
},
963+
);
955964
} else {
956965
state.chatRunId = ack.runId;
957966
}

0 commit comments

Comments
 (0)