Skip to content

Commit f6b2ba4

Browse files
committed
fix(control-ui): coalesce duplicate chat submits
1 parent 8cddb6c commit f6b2ba4

5 files changed

Lines changed: 141 additions & 29 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
1818
- Sessions: ignore future-dated session activity timestamps during reset freshness checks and cap future `updatedAt` values at the merge boundary so clock-skewed messages cannot keep stale sessions alive forever. Fixes #72989. Thanks @martingarramon.
1919
- Plugins/CLI: allow managed plugin installs when the active extensions root is a symlink to a real state directory, while keeping nested target symlinks blocked and suppressing misleading hook-pack fallback errors for install-boundary failures. Fixes #72946. Thanks @mayank6136.
2020
- Gateway/startup: keep hot Gateway boot paths on leaf config imports and add max-RSS reporting to the gateway startup bench so low-memory startup regressions are visible before release. Thanks @vincentkoc.
21+
- WebChat: read `chat.history` from active transcript branches, drop stale streamed assistant tails once final history catches up, and coalesce duplicate in-flight Control UI submits, so rewritten prompts, completed replies, and rapid send events no longer render or process twice. Fixes #72975, #72963, and #72974. Thanks @dmagdici, @lhtpluto, and @Benjamin5281999.
2122
- WebChat/TTS: persist automatic final-mode TTS audio as a supplemental audio-only transcript update instead of adding a second assistant message with the same visible text. Fixes #72830. Thanks @lhtpluto.
2223
- Agents/LSP: terminate bundled stdio LSP process trees during runtime disposal and Gateway shutdown, so nested children such as `tsserver` do not survive stop or restart. Fixes #72357. Thanks @ai-hpc and @bittoby.
2324
- Diagnostics/OTEL: capture privacy-safe model-call request payload bytes, streamed response bytes, first-response latency, and total duration in diagnostic events, plugin hooks, stability snapshots, and OTEL model-call spans/metrics without logging raw model content. Fixes #33832. Thanks @wwh830.

docs/web/webchat.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket.
2424

2525
- The UI connects to the Gateway WebSocket and uses `chat.history`, `chat.send`, and `chat.inject`.
2626
- `chat.history` is bounded for stability: Gateway may truncate long text fields, omit heavy metadata, and replace oversized entries with `[chat.history omitted: message too large]`.
27+
- `chat.history` follows the active transcript branch for modern append-only session files, so abandoned rewrite branches and superseded prompt copies are not rendered in WebChat.
28+
- Control UI coalesces duplicate in-flight submits for the same session, message, and attachments before generating a new `chat.send` run id; the Gateway still dedupes repeated requests that reuse the same idempotency key.
2729
- `chat.history` is also display-normalized: runtime-only OpenClaw context,
2830
inbound envelope wrappers, inline delivery directive tags
2931
such as `[[reply_to_*]]` and `[[audio_as_voice]]`, plain-text tool-call XML

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

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,12 @@ function requestUrl(input: string | URL | Request): string {
4141
}
4242

4343
function makeHost(overrides?: Partial<ChatHost>): ChatHost {
44-
return {
44+
const host = {
4545
client: null,
4646
chatMessages: [],
4747
chatStream: null,
48+
chatStreamSegments: [],
49+
chatToolMessages: [],
4850
connected: true,
4951
chatLoading: false,
5052
chatMessage: "",
@@ -71,9 +73,13 @@ function makeHost(overrides?: Partial<ChatHost>): ChatHost {
7173
chatModelsLoading: false,
7274
chatModelCatalog: [],
7375
refreshSessionsAfterChat: new Set<string>(),
76+
toolStreamById: new Map(),
77+
toolStreamOrder: [],
78+
toolStreamSyncTimer: null,
7479
updateComplete: Promise.resolve(),
7580
...overrides,
7681
};
82+
return host as ChatHost;
7783
}
7884

7985
function createSessionsResult(sessions: GatewaySessionRow[]): SessionsListResult {
@@ -548,6 +554,32 @@ describe("handleSendChat", () => {
548554
expect(host.chatMessage).toBe("queued while busy");
549555
});
550556

557+
it("coalesces duplicate in-flight chat submits before the gateway acknowledges them", async () => {
558+
const sent = createDeferred<unknown>();
559+
const request = vi.fn((method: string) => {
560+
if (method === "chat.send") {
561+
return sent.promise;
562+
}
563+
throw new Error(`Unexpected request: ${method}`);
564+
});
565+
const host = makeHost({
566+
client: { request } as unknown as ChatHost["client"],
567+
});
568+
569+
const first = handleSendChat(host, "same prompt");
570+
const second = handleSendChat(host, "same prompt");
571+
572+
expect(request).toHaveBeenCalledTimes(1);
573+
expect(host.chatQueue).toEqual([]);
574+
expect(host.chatMessages).toHaveLength(1);
575+
576+
sent.resolve({ runId: host.chatRunId, status: "started" });
577+
await Promise.all([first, second]);
578+
579+
expect(request).toHaveBeenCalledTimes(1);
580+
expect(host.chatMessages).toHaveLength(1);
581+
});
582+
551583
it("restores the BTW draft when detached send fails", async () => {
552584
const host = makeHost({
553585
client: {

ui/src/ui/app-chat.ts

Lines changed: 83 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export type ChatHost = ChatInputHistoryState & {
6161
updateComplete?: Promise<unknown>;
6262
refreshSessionsAfterChat: Set<string>;
6363
pendingAbort?: { runId: string; sessionKey: string } | null;
64+
chatSubmitGuards?: Map<string, Promise<void>>;
6465
/** Callback for slash-command side effects that need app-level access. */
6566
onSlashAction?: (action: string) => void;
6667
};
@@ -225,6 +226,54 @@ async function sendChatMessageNow(
225226
return ok;
226227
}
227228

229+
function attachmentSubmitSignature(attachment: ChatAttachment): string {
230+
return JSON.stringify([
231+
attachment.id,
232+
attachment.mimeType,
233+
attachment.fileName ?? "",
234+
attachment.dataUrl.length,
235+
attachment.dataUrl.slice(0, 64),
236+
]);
237+
}
238+
239+
function chatSubmitKey(
240+
host: ChatHost,
241+
kind: "btw" | "message",
242+
message: string,
243+
attachments: ChatAttachment[],
244+
): string {
245+
return JSON.stringify([
246+
kind,
247+
host.sessionKey,
248+
message.trim(),
249+
attachments.map(attachmentSubmitSignature),
250+
]);
251+
}
252+
253+
async function withChatSubmitGuard<T>(
254+
host: ChatHost,
255+
key: string,
256+
run: () => Promise<T>,
257+
): Promise<T | undefined> {
258+
const guards = (host.chatSubmitGuards ??= new Map<string, Promise<void>>());
259+
if (guards.has(key)) {
260+
return undefined;
261+
}
262+
let releaseGuard!: () => void;
263+
const guard = new Promise<void>((resolve) => {
264+
releaseGuard = resolve;
265+
});
266+
guards.set(key, guard);
267+
try {
268+
return await run();
269+
} finally {
270+
releaseGuard();
271+
if (guards.get(key) === guard) {
272+
guards.delete(key);
273+
}
274+
}
275+
}
276+
228277
async function sendDetachedBtwMessage(
229278
host: ChatHost,
230279
message: string,
@@ -362,16 +411,19 @@ export async function handleSendChat(
362411
}
363412

364413
if (isBtwCommand(message)) {
365-
if (messageOverride == null) {
366-
recordNonTranscriptInputHistory(host, message);
367-
host.chatMessage = "";
368-
host.chatAttachments = [];
369-
resetChatInputHistoryNavigation(host);
370-
}
371-
await sendDetachedBtwMessage(host, message, {
372-
previousDraft: messageOverride == null ? previousDraft : undefined,
373-
attachments: hasAttachments ? attachmentsToSend : undefined,
374-
previousAttachments: messageOverride == null ? attachments : undefined,
414+
const submitKey = chatSubmitKey(host, "btw", message, attachmentsToSend);
415+
await withChatSubmitGuard(host, submitKey, async () => {
416+
if (messageOverride == null) {
417+
recordNonTranscriptInputHistory(host, message);
418+
host.chatMessage = "";
419+
host.chatAttachments = [];
420+
resetChatInputHistoryNavigation(host);
421+
}
422+
await sendDetachedBtwMessage(host, message, {
423+
previousDraft: messageOverride == null ? previousDraft : undefined,
424+
attachments: hasAttachments ? attachmentsToSend : undefined,
425+
previousAttachments: messageOverride == null ? attachments : undefined,
426+
});
375427
});
376428
return;
377429
}
@@ -407,27 +459,30 @@ export async function handleSendChat(
407459
}
408460

409461
const refreshSessions = isChatResetCommand(message);
410-
if (messageOverride == null) {
411-
host.chatMessage = "";
412-
host.chatAttachments = [];
413-
resetChatInputHistoryNavigation(host);
414-
}
415-
416-
if (isChatBusy(host)) {
462+
const submitKey = chatSubmitKey(host, "message", message, attachmentsToSend);
463+
await withChatSubmitGuard(host, submitKey, async () => {
417464
if (messageOverride == null) {
418-
recordNonTranscriptInputHistory(host, message);
465+
host.chatMessage = "";
466+
host.chatAttachments = [];
467+
resetChatInputHistoryNavigation(host);
468+
}
469+
470+
if (isChatBusy(host)) {
471+
if (messageOverride == null) {
472+
recordNonTranscriptInputHistory(host, message);
473+
}
474+
enqueueChatMessage(host, message, attachmentsToSend, refreshSessions);
475+
return;
419476
}
420-
enqueueChatMessage(host, message, attachmentsToSend, refreshSessions);
421-
return;
422-
}
423477

424-
await sendChatMessageNow(host, message, {
425-
previousDraft: messageOverride == null ? previousDraft : undefined,
426-
restoreDraft: Boolean(messageOverride && opts?.restoreDraft),
427-
attachments: hasAttachments ? attachmentsToSend : undefined,
428-
previousAttachments: messageOverride == null ? attachments : undefined,
429-
restoreAttachments: Boolean(messageOverride && opts?.restoreDraft),
430-
refreshSessions,
478+
await sendChatMessageNow(host, message, {
479+
previousDraft: messageOverride == null ? previousDraft : undefined,
480+
restoreDraft: Boolean(messageOverride && opts?.restoreDraft),
481+
attachments: hasAttachments ? attachmentsToSend : undefined,
482+
previousAttachments: messageOverride == null ? attachments : undefined,
483+
restoreAttachments: Boolean(messageOverride && opts?.restoreDraft),
484+
refreshSessions,
485+
});
431486
});
432487
}
433488

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,28 @@ describe("loadChatHistory", () => {
624624
});
625625

626626
describe("sendChatMessage", () => {
627+
it("does not start a second chat.send while the first send is awaiting ack", async () => {
628+
const sent = createDeferred<unknown>();
629+
const request = vi.fn(() => sent.promise);
630+
const state = createState({
631+
connected: true,
632+
client: { request } as unknown as ChatState["client"],
633+
});
634+
635+
const first = sendChatMessage(state, "hello");
636+
const activeRunId = state.chatRunId;
637+
const second = sendChatMessage(state, "hello");
638+
639+
expect(request).toHaveBeenCalledTimes(1);
640+
expect(state.chatMessages).toHaveLength(1);
641+
await expect(second).resolves.toBe(activeRunId);
642+
643+
sent.resolve({ runId: activeRunId, status: "started" });
644+
await expect(first).resolves.toBe(activeRunId);
645+
expect(request).toHaveBeenCalledTimes(1);
646+
expect(state.chatMessages).toHaveLength(1);
647+
});
648+
627649
it("serializes non-image chat attachments as files", async () => {
628650
const request = vi.fn().mockResolvedValue({ runId: "run-1", status: "started" });
629651
const state = createState({

0 commit comments

Comments
 (0)