Skip to content

Commit 61a0b02

Browse files
committed
fix(tui): preserve optimistic user messages during active runs
1 parent a0407c7 commit 61a0b02

7 files changed

Lines changed: 52 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai
2525
- Web UI/markdown: stop bare auto-links from swallowing adjacent CJK text while preserving valid mixed-script path and query characters in rendered links. (#48410) Thanks @jnuyao.
2626
- Memory/FTS: add configurable trigram tokenization plus short-CJK substring fallback so memory search can find Chinese, Japanese, and Korean text without breaking mixed long-and-short queries. Thanks @carrotRakko.
2727
- Hooks/config: accept runtime channel plugin ids in `hooks.mappings[].channel` (for example `feishu`) instead of rejecting non-core channels during config validation. (#56226) Thanks @AiKrai001.
28+
- TUI/chat: keep optimistic outbound user messages visible during active runs by deferring local-run binding until the first gateway chat event reveals the real run id, preventing premature history reloads from wiping pending local sends. (#54722) Thanks @seanturner001.
2829

2930
## 2026.3.28
3031

src/tui/tui-command-handlers.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ function createHarness(params?: {
3434
const state = {
3535
currentSessionKey: "agent:main:main",
3636
activeChatRunId: params?.activeChatRunId ?? null,
37+
pendingOptimisticUserMessage: false,
3738
isConnected: params?.isConnected ?? true,
3839
sessionInfo: {},
3940
};
@@ -126,6 +127,16 @@ describe("tui command handlers", () => {
126127
expect(requestRender).toHaveBeenCalled();
127128
});
128129

130+
it("defers local run binding until gateway events provide a real run id", async () => {
131+
const { handleCommand, noteLocalRunId, state } = createHarness();
132+
133+
await handleCommand("/context");
134+
135+
expect(noteLocalRunId).not.toHaveBeenCalled();
136+
expect(state.activeChatRunId).toBeNull();
137+
expect(state.pendingOptimisticUserMessage).toBe(true);
138+
});
139+
129140
it("sends /btw without hijacking the active main run", async () => {
130141
const setActivityStatus = vi.fn();
131142
const { handleCommand, sendChat, addUser, noteLocalRunId, noteLocalBtwRunId, state } =
@@ -173,7 +184,7 @@ describe("tui command handlers", () => {
173184

174185
it("reports send failures and marks activity status as error", async () => {
175186
const setActivityStatus = vi.fn();
176-
const { handleCommand, addSystem } = createHarness({
187+
const { handleCommand, addSystem, state } = createHarness({
177188
sendChat: vi.fn().mockRejectedValue(new Error("gateway down")),
178189
setActivityStatus,
179190
});
@@ -182,6 +193,7 @@ describe("tui command handlers", () => {
182193

183194
expect(addSystem).toHaveBeenCalledWith("send failed: Error: gateway down");
184195
expect(setActivityStatus).toHaveBeenLastCalledWith("error");
196+
expect(state.pendingOptimisticUserMessage).toBe(false);
185197
});
186198

187199
it("sanitizes control sequences in /new and /reset failures", async () => {

src/tui/tui-command-handlers.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,6 @@ export function createCommandHandlers(context: CommandHandlerContext) {
7272
setActivityStatus,
7373
formatSessionKey,
7474
applySessionInfoFromPatch,
75-
noteLocalRunId,
7675
noteLocalBtwRunId,
7776
forgetLocalRunId,
7877
forgetLocalBtwRunId,
@@ -520,8 +519,7 @@ export function createCommandHandlers(context: CommandHandlerContext) {
520519
try {
521520
if (!isBtw) {
522521
chatLog.addUser(text);
523-
noteLocalRunId(runId);
524-
state.activeChatRunId = runId;
522+
state.pendingOptimisticUserMessage = true;
525523
setActivityStatus("sending");
526524
} else {
527525
noteLocalBtwRunId?.(runId);
@@ -547,6 +545,7 @@ export function createCommandHandlers(context: CommandHandlerContext) {
547545
forgetLocalRunId?.(state.activeChatRunId);
548546
}
549547
if (!isBtw) {
548+
state.pendingOptimisticUserMessage = false;
550549
state.activeChatRunId = null;
551550
}
552551
chatLog.addSystem(`${isBtw ? "btw failed" : "send failed"}: ${String(err)}`);

src/tui/tui-event-handlers.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ describe("tui-event-handlers: handleAgentEvent", () => {
5858
currentSessionKey: "agent:main:main",
5959
currentSessionId: "session-1",
6060
activeChatRunId: "run-1",
61+
pendingOptimisticUserMessage: false,
6162
historyLoaded: true,
6263
sessionInfo: { verboseLevel: "on" },
6364
initialSessionApplied: true,
@@ -126,6 +127,7 @@ describe("tui-event-handlers: handleAgentEvent", () => {
126127
state,
127128
setActivityStatus: context.setActivityStatus,
128129
loadHistory: context.loadHistory,
130+
noteLocalRunId: context.noteLocalRunId,
129131
isLocalRunId: context.isLocalRunId,
130132
forgetLocalRunId: context.forgetLocalRunId,
131133
isLocalBtwRunId: context.isLocalBtwRunId,
@@ -466,6 +468,24 @@ describe("tui-event-handlers: handleAgentEvent", () => {
466468
expect(loadHistory).toHaveBeenCalledTimes(1);
467469
});
468470

471+
it("binds optimistic pending messages to the first gateway run id and skips history reload", () => {
472+
const { state, loadHistory, isLocalRunId, handleChatEvent } = createHandlersHarness({
473+
state: { activeChatRunId: null, pendingOptimisticUserMessage: true },
474+
});
475+
476+
handleChatEvent({
477+
runId: "run-gateway",
478+
sessionKey: state.currentSessionKey,
479+
state: "final",
480+
message: { content: [{ type: "text", text: "done" }] },
481+
});
482+
483+
expect(state.pendingOptimisticUserMessage).toBe(false);
484+
expect(state.activeChatRunId).toBeNull();
485+
expect(isLocalRunId("run-gateway")).toBe(false);
486+
expect(loadHistory).not.toHaveBeenCalled();
487+
});
488+
469489
function createConcurrentRunHarness(localContent = "partial") {
470490
const { state, chatLog, setActivityStatus, loadHistory, handleChatEvent } =
471491
createHandlersHarness({

src/tui/tui-event-handlers.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ type EventHandlerContext = {
3333
setActivityStatus: (text: string) => void;
3434
refreshSessionInfo?: () => Promise<void>;
3535
loadHistory?: () => Promise<void>;
36+
noteLocalRunId?: (runId: string) => void;
3637
isLocalRunId?: (runId: string) => boolean;
3738
forgetLocalRunId?: (runId: string) => void;
3839
clearLocalRunIds?: () => void;
@@ -50,6 +51,7 @@ export function createEventHandlers(context: EventHandlerContext) {
5051
setActivityStatus,
5152
refreshSessionInfo,
5253
loadHistory,
54+
noteLocalRunId,
5355
isLocalRunId,
5456
forgetLocalRunId,
5557
clearLocalRunIds,
@@ -95,6 +97,7 @@ export function createEventHandlers(context: EventHandlerContext) {
9597
sessionRuns.clear();
9698
streamAssembler = new TuiStreamAssembler();
9799
pendingHistoryRefresh = false;
100+
state.pendingOptimisticUserMessage = false;
98101
clearLocalRunIds?.();
99102
clearLocalBtwRunIds?.();
100103
btw.clear();
@@ -231,6 +234,10 @@ export function createEventHandlers(context: EventHandlerContext) {
231234
noteSessionRun(evt.runId);
232235
if (!state.activeChatRunId && !isLocalBtwRunId?.(evt.runId)) {
233236
state.activeChatRunId = evt.runId;
237+
if (state.pendingOptimisticUserMessage) {
238+
noteLocalRunId?.(evt.runId);
239+
state.pendingOptimisticUserMessage = false;
240+
}
234241
}
235242
if (evt.state === "delta") {
236243
const displayText = streamAssembler.ingestDelta(evt.runId, evt.message, state.showThinking);

src/tui/tui-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ export type TuiStateAccess = {
108108
currentSessionKey: string;
109109
currentSessionId: string | null;
110110
activeChatRunId: string | null;
111+
pendingOptimisticUserMessage?: boolean;
111112
historyLoaded: boolean;
112113
sessionInfo: SessionInfo;
113114
initialSessionApplied: boolean;

src/tui/tui.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ export async function runTui(opts: TuiOptions) {
203203
let initialSessionApplied = false;
204204
let currentSessionId: string | null = null;
205205
let activeChatRunId: string | null = null;
206+
let pendingOptimisticUserMessage = false;
206207
let historyLoaded = false;
207208
let isConnected = false;
208209
let wasDisconnected = false;
@@ -274,6 +275,12 @@ export async function runTui(opts: TuiOptions) {
274275
set activeChatRunId(value) {
275276
activeChatRunId = value;
276277
},
278+
get pendingOptimisticUserMessage() {
279+
return pendingOptimisticUserMessage;
280+
},
281+
set pendingOptimisticUserMessage(value) {
282+
pendingOptimisticUserMessage = value;
283+
},
277284
get historyLoaded() {
278285
return historyLoaded;
279286
},
@@ -712,6 +719,7 @@ export async function runTui(opts: TuiOptions) {
712719
setActivityStatus,
713720
refreshSessionInfo,
714721
loadHistory,
722+
noteLocalRunId,
715723
isLocalRunId,
716724
forgetLocalRunId,
717725
clearLocalRunIds,

0 commit comments

Comments
 (0)