fix(tui): preserve optimistic user messages during active runs

This commit is contained in:
Vignesh Natarajan
2026-03-28 19:31:49 -07:00
parent a0407c7254
commit 61a0b02931
7 changed files with 52 additions and 4 deletions

View File

@@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai
- 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.
- 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.
- 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.
- 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.
## 2026.3.28

View File

@@ -34,6 +34,7 @@ function createHarness(params?: {
const state = {
currentSessionKey: "agent:main:main",
activeChatRunId: params?.activeChatRunId ?? null,
pendingOptimisticUserMessage: false,
isConnected: params?.isConnected ?? true,
sessionInfo: {},
};
@@ -126,6 +127,16 @@ describe("tui command handlers", () => {
expect(requestRender).toHaveBeenCalled();
});
it("defers local run binding until gateway events provide a real run id", async () => {
const { handleCommand, noteLocalRunId, state } = createHarness();
await handleCommand("/context");
expect(noteLocalRunId).not.toHaveBeenCalled();
expect(state.activeChatRunId).toBeNull();
expect(state.pendingOptimisticUserMessage).toBe(true);
});
it("sends /btw without hijacking the active main run", async () => {
const setActivityStatus = vi.fn();
const { handleCommand, sendChat, addUser, noteLocalRunId, noteLocalBtwRunId, state } =
@@ -173,7 +184,7 @@ describe("tui command handlers", () => {
it("reports send failures and marks activity status as error", async () => {
const setActivityStatus = vi.fn();
const { handleCommand, addSystem } = createHarness({
const { handleCommand, addSystem, state } = createHarness({
sendChat: vi.fn().mockRejectedValue(new Error("gateway down")),
setActivityStatus,
});
@@ -182,6 +193,7 @@ describe("tui command handlers", () => {
expect(addSystem).toHaveBeenCalledWith("send failed: Error: gateway down");
expect(setActivityStatus).toHaveBeenLastCalledWith("error");
expect(state.pendingOptimisticUserMessage).toBe(false);
});
it("sanitizes control sequences in /new and /reset failures", async () => {

View File

@@ -72,7 +72,6 @@ export function createCommandHandlers(context: CommandHandlerContext) {
setActivityStatus,
formatSessionKey,
applySessionInfoFromPatch,
noteLocalRunId,
noteLocalBtwRunId,
forgetLocalRunId,
forgetLocalBtwRunId,
@@ -520,8 +519,7 @@ export function createCommandHandlers(context: CommandHandlerContext) {
try {
if (!isBtw) {
chatLog.addUser(text);
noteLocalRunId(runId);
state.activeChatRunId = runId;
state.pendingOptimisticUserMessage = true;
setActivityStatus("sending");
} else {
noteLocalBtwRunId?.(runId);
@@ -547,6 +545,7 @@ export function createCommandHandlers(context: CommandHandlerContext) {
forgetLocalRunId?.(state.activeChatRunId);
}
if (!isBtw) {
state.pendingOptimisticUserMessage = false;
state.activeChatRunId = null;
}
chatLog.addSystem(`${isBtw ? "btw failed" : "send failed"}: ${String(err)}`);

View File

@@ -58,6 +58,7 @@ describe("tui-event-handlers: handleAgentEvent", () => {
currentSessionKey: "agent:main:main",
currentSessionId: "session-1",
activeChatRunId: "run-1",
pendingOptimisticUserMessage: false,
historyLoaded: true,
sessionInfo: { verboseLevel: "on" },
initialSessionApplied: true,
@@ -126,6 +127,7 @@ describe("tui-event-handlers: handleAgentEvent", () => {
state,
setActivityStatus: context.setActivityStatus,
loadHistory: context.loadHistory,
noteLocalRunId: context.noteLocalRunId,
isLocalRunId: context.isLocalRunId,
forgetLocalRunId: context.forgetLocalRunId,
isLocalBtwRunId: context.isLocalBtwRunId,
@@ -466,6 +468,24 @@ describe("tui-event-handlers: handleAgentEvent", () => {
expect(loadHistory).toHaveBeenCalledTimes(1);
});
it("binds optimistic pending messages to the first gateway run id and skips history reload", () => {
const { state, loadHistory, isLocalRunId, handleChatEvent } = createHandlersHarness({
state: { activeChatRunId: null, pendingOptimisticUserMessage: true },
});
handleChatEvent({
runId: "run-gateway",
sessionKey: state.currentSessionKey,
state: "final",
message: { content: [{ type: "text", text: "done" }] },
});
expect(state.pendingOptimisticUserMessage).toBe(false);
expect(state.activeChatRunId).toBeNull();
expect(isLocalRunId("run-gateway")).toBe(false);
expect(loadHistory).not.toHaveBeenCalled();
});
function createConcurrentRunHarness(localContent = "partial") {
const { state, chatLog, setActivityStatus, loadHistory, handleChatEvent } =
createHandlersHarness({

View File

@@ -33,6 +33,7 @@ type EventHandlerContext = {
setActivityStatus: (text: string) => void;
refreshSessionInfo?: () => Promise<void>;
loadHistory?: () => Promise<void>;
noteLocalRunId?: (runId: string) => void;
isLocalRunId?: (runId: string) => boolean;
forgetLocalRunId?: (runId: string) => void;
clearLocalRunIds?: () => void;
@@ -50,6 +51,7 @@ export function createEventHandlers(context: EventHandlerContext) {
setActivityStatus,
refreshSessionInfo,
loadHistory,
noteLocalRunId,
isLocalRunId,
forgetLocalRunId,
clearLocalRunIds,
@@ -95,6 +97,7 @@ export function createEventHandlers(context: EventHandlerContext) {
sessionRuns.clear();
streamAssembler = new TuiStreamAssembler();
pendingHistoryRefresh = false;
state.pendingOptimisticUserMessage = false;
clearLocalRunIds?.();
clearLocalBtwRunIds?.();
btw.clear();
@@ -231,6 +234,10 @@ export function createEventHandlers(context: EventHandlerContext) {
noteSessionRun(evt.runId);
if (!state.activeChatRunId && !isLocalBtwRunId?.(evt.runId)) {
state.activeChatRunId = evt.runId;
if (state.pendingOptimisticUserMessage) {
noteLocalRunId?.(evt.runId);
state.pendingOptimisticUserMessage = false;
}
}
if (evt.state === "delta") {
const displayText = streamAssembler.ingestDelta(evt.runId, evt.message, state.showThinking);

View File

@@ -108,6 +108,7 @@ export type TuiStateAccess = {
currentSessionKey: string;
currentSessionId: string | null;
activeChatRunId: string | null;
pendingOptimisticUserMessage?: boolean;
historyLoaded: boolean;
sessionInfo: SessionInfo;
initialSessionApplied: boolean;

View File

@@ -203,6 +203,7 @@ export async function runTui(opts: TuiOptions) {
let initialSessionApplied = false;
let currentSessionId: string | null = null;
let activeChatRunId: string | null = null;
let pendingOptimisticUserMessage = false;
let historyLoaded = false;
let isConnected = false;
let wasDisconnected = false;
@@ -274,6 +275,12 @@ export async function runTui(opts: TuiOptions) {
set activeChatRunId(value) {
activeChatRunId = value;
},
get pendingOptimisticUserMessage() {
return pendingOptimisticUserMessage;
},
set pendingOptimisticUserMessage(value) {
pendingOptimisticUserMessage = value;
},
get historyLoaded() {
return historyLoaded;
},
@@ -712,6 +719,7 @@ export async function runTui(opts: TuiOptions) {
setActivityStatus,
refreshSessionInfo,
loadHistory,
noteLocalRunId,
isLocalRunId,
forgetLocalRunId,
clearLocalRunIds,