diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts index 3d17812e4f91..7814a132da8a 100644 --- a/src/tui/tui-command-handlers.test.ts +++ b/src/tui/tui-command-handlers.test.ts @@ -8,6 +8,11 @@ import { TUI_RECENT_SESSIONS_ACTIVE_MINUTES, TUI_SESSION_PICKER_LIMIT, } from "./tui-session-list-policy.js"; +import { + getPendingSubmitAcceptedRunId, + getPendingSubmitDraft, + type TuiPendingSubmit, +} from "./tui-submit-state.js"; import type { SessionInfo } from "./tui-types.js"; type LoadHistoryMock = ReturnType & (() => Promise); @@ -95,8 +100,7 @@ function createHarness(params?: { setActivityStatus?: SetActivityStatusMock; isConnected?: boolean; activeChatRunId?: string | null; - pendingOptimisticUserMessage?: boolean; - pendingChatRunId?: string | null; + pendingSubmit?: TuiPendingSubmit | null; activityStatus?: string; opts?: { local?: boolean }; currentSessionId?: string | null; @@ -158,9 +162,7 @@ function createHarness(params?: { currentSessionKey: params?.currentSessionKey ?? "agent:main:main", currentSessionId: params?.currentSessionId ?? null, activeChatRunId: params?.activeChatRunId ?? null, - pendingOptimisticUserMessage: params?.pendingOptimisticUserMessage ?? false, - pendingChatRunId: params?.pendingChatRunId ?? null, - pendingSubmitDraft: null as { runId: string; text: string } | null, + pendingSubmit: params?.pendingSubmit ?? null, activityStatus: params?.activityStatus ?? "idle", isConnected: params?.isConnected ?? true, sessionInfo: params?.sessionInfo ?? {}, @@ -333,7 +335,10 @@ describe("tui command handlers", () => { expect(harness.rekeyPendingUser).toHaveBeenCalledWith(localRunId, "r-accepted"); expect(harness.addPendingUser).toHaveBeenCalledTimes(1); expect(harness.dropPendingUser).not.toHaveBeenCalled(); - expect(harness.state.pendingSubmitDraft).toEqual({ runId: "r-accepted", text: "hello" }); + expect(getPendingSubmitDraft(harness.state)).toEqual({ + runId: "r-accepted", + text: "hello", + }); }); it("does not re-arm the submit draft when the accepted run already emitted events", async () => { @@ -346,7 +351,7 @@ describe("tui command handlers", () => { // The accepted run already registered, so the draft must not be re-armed — // otherwise a later abort would drop a row whose reply already rendered. expect(harness.rekeyPendingUser).toHaveBeenCalledWith(expect.any(String), "r-accepted"); - expect(harness.state.pendingSubmitDraft).toBeNull(); + expect(getPendingSubmitDraft(harness.state)).toBeNull(); }); it("clears the submit draft when the accepted run already completed", async () => { @@ -360,7 +365,7 @@ describe("tui command handlers", () => { expect(harness.addPendingUser).toHaveBeenCalledTimes(1); expect(harness.dropPendingUser).not.toHaveBeenCalled(); - expect(harness.state.pendingSubmitDraft).toBeNull(); + expect(harness.state.pendingSubmit).toBeNull(); }); it("passes the current backing session id when sending to the gateway", async () => { @@ -665,7 +670,7 @@ describe("tui command handlers", () => { const sentRunId = (firstMockArg(sendChat, "sendChat") as { runId: string }).runId; expect(noteLocalRunId).toHaveBeenCalledWith(sentRunId); expect(state.activeChatRunId).toBeNull(); - expect(state.pendingOptimisticUserMessage).toBe(true); + expect(getPendingSubmitAcceptedRunId(state)).toBe(sentRunId); }); it("tracks the in-flight runId so escape can abort during the wait", async () => { @@ -680,21 +685,20 @@ describe("tui command handlers", () => { expect(typeof sentRunId).toBe("string"); expect(sentRunId.length).toBeGreaterThan(0); expect(state.activeChatRunId).toBeNull(); - expect(state.pendingChatRunId).toBe(sentRunId); + expect(getPendingSubmitAcceptedRunId(state)).toBe(sentRunId); }); it("does not reintroduce the pending runId when an early event already consumed it", async () => { const sendChat = vi.fn(); const { handleCommand, state } = createHarness({ sendChat }); sendChat.mockImplementation(async (opts: { runId: string }) => { - state.pendingOptimisticUserMessage = false; + state.pendingSubmit = null; return { runId: opts.runId }; }); await handleCommand("hello"); - expect(state.pendingChatRunId).toBeNull(); - expect(state.pendingOptimisticUserMessage).toBe(false); + expect(state.pendingSubmit).toBeNull(); }); it("tracks the backend-accepted runId when it differs from the generated runId", async () => { @@ -704,7 +708,7 @@ describe("tui command handlers", () => { await handleCommand("hello"); const sentRunId = (firstMockArg(sendChat, "sendChat") as { runId: string }).runId; - expect(state.pendingChatRunId).toBe("run-accepted"); + expect(getPendingSubmitAcceptedRunId(state)).toBe("run-accepted"); expect(forgetLocalRunId).toHaveBeenCalledWith(sentRunId); expect(noteLocalRunId).toHaveBeenCalledWith("run-accepted"); }); @@ -728,9 +732,7 @@ describe("tui command handlers", () => { const sentRunId = (firstMockArg(sendChat, "sendChat") as { runId: string }).runId; expect(dropPendingUser).toHaveBeenCalledWith(sentRunId); - expect(state.pendingSubmitDraft).toBeNull(); - expect(state.pendingChatRunId).toBeNull(); - expect(state.pendingOptimisticUserMessage).toBe(false); + expect(state.pendingSubmit).toBeNull(); expect(addSystem).toHaveBeenCalledWith( "send failed: Chat failed before the run started; try again.", ); @@ -756,9 +758,7 @@ describe("tui command handlers", () => { expect(addSystem).toHaveBeenCalledWith( "send failed: Chat failed before the run started; try again.", ); - expect(state.pendingSubmitDraft).toBeNull(); - expect(state.pendingChatRunId).toBeNull(); - expect(state.pendingOptimisticUserMessage).toBe(false); + expect(state.pendingSubmit).toBeNull(); expect(setActivityStatus).toHaveBeenLastCalledWith("error"); expect(loadHistory).toHaveBeenCalledTimes(1); }); @@ -777,9 +777,7 @@ describe("tui command handlers", () => { await handleCommand("hello"); expect(dropPendingUser).not.toHaveBeenCalled(); - expect(state.pendingSubmitDraft).toBeNull(); - expect(state.pendingChatRunId).toBeNull(); - expect(state.pendingOptimisticUserMessage).toBe(false); + expect(state.pendingSubmit).toBeNull(); expect(setActivityStatus).toHaveBeenLastCalledWith("idle"); expect(loadHistory).toHaveBeenCalledTimes(1); }); @@ -816,8 +814,7 @@ describe("tui command handlers", () => { "btw failed: Chat failed before the run started; try again.", ); expect(state.activeChatRunId).toBe("run-main"); - expect(state.pendingChatRunId).toBeNull(); - expect(state.pendingOptimisticUserMessage).toBe(false); + expect(state.pendingSubmit).toBeNull(); }, ); @@ -844,8 +841,7 @@ describe("tui command handlers", () => { expect(forgetLocalBtwRunId).toHaveBeenCalledWith("run-accepted-btw"); expect(addSystem).not.toHaveBeenCalled(); expect(state.activeChatRunId).toBe("run-main"); - expect(state.pendingChatRunId).toBeNull(); - expect(state.pendingOptimisticUserMessage).toBe(false); + expect(state.pendingSubmit).toBeNull(); }); it("tracks the backend-accepted runId for a detached non-terminal ack", async () => { @@ -873,8 +869,7 @@ describe("tui command handlers", () => { expect(noteLocalBtwRunId).toHaveBeenCalledWith("run-accepted-btw"); expect(addSystem).not.toHaveBeenCalled(); expect(state.activeChatRunId).toBe("run-main"); - expect(state.pendingChatRunId).toBeNull(); - expect(state.pendingOptimisticUserMessage).toBe(false); + expect(state.pendingSubmit).toBeNull(); }); it("does not reintroduce a backend-accepted runId after an early terminal event", async () => { @@ -894,8 +889,7 @@ describe("tui command handlers", () => { expect(consumeCompletedRunForPendingSend).toHaveBeenCalledWith("run-accepted"); expect(forgetLocalRunId).toHaveBeenCalledWith(sentRunId); expect(noteLocalRunId).not.toHaveBeenCalledWith("run-accepted"); - expect(state.pendingChatRunId).toBeNull(); - expect(state.pendingOptimisticUserMessage).toBe(false); + expect(state.pendingSubmit).toBeNull(); expect(setActivityStatus).toHaveBeenCalledWith("idle"); expect(flushPendingHistoryRefreshIfIdle).toHaveBeenCalledTimes(1); }); @@ -915,8 +909,7 @@ describe("tui command handlers", () => { const sentRunId = (firstMockArg(sendChatMock, "sendChat") as { runId: string }).runId; expect(dropPendingUser).toHaveBeenCalledWith(sentRunId); - expect(state.pendingChatRunId).toBeNull(); - expect(state.pendingOptimisticUserMessage).toBe(false); + expect(state.pendingSubmit).toBeNull(); }); it("sends /btw without hijacking the active main run", async () => { @@ -1039,26 +1032,30 @@ describe("tui command handlers", () => { it.each([ { activeChatRunId: "active-run", - pendingChatRunId: null, - pendingOptimisticUserMessage: false, + pendingSubmit: null, activityStatus: "running", }, { activeChatRunId: null, - pendingChatRunId: "pending-run", - pendingOptimisticUserMessage: false, + pendingSubmit: { + phase: "accepted" as const, + runId: "pending-run", + draftText: null, + }, activityStatus: "sending", }, { activeChatRunId: null, - pendingChatRunId: null, - pendingOptimisticUserMessage: true, + pendingSubmit: { + phase: "sending" as const, + runId: "pending-run", + draftText: "pending", + }, activityStatus: "sending", }, { activeChatRunId: null, - pendingChatRunId: null, - pendingOptimisticUserMessage: false, + pendingSubmit: null, activityStatus: "finishing context", }, ])("blocks /new while the current session lifecycle is unfinished", async (runState) => { @@ -1228,7 +1225,7 @@ describe("tui command handlers", () => { expect(addSystem).toHaveBeenCalledWith("send failed: Error: gateway down"); expect(setActivityStatus).toHaveBeenLastCalledWith("error"); - expect(state.pendingOptimisticUserMessage).toBe(false); + expect(state.pendingSubmit).toBeNull(); }); it("sanitizes control sequences in /new and /reset failures", async () => { @@ -1294,7 +1291,7 @@ describe("tui command handlers", () => { ); expect(requestRender).toHaveBeenCalled(); expect(state.activeChatRunId).toBe("run-active"); - expect(state.pendingChatRunId).toEqual(expect.any(String)); + expect(getPendingSubmitAcceptedRunId(state)).toEqual(expect.any(String)); }); it("forwards gateway slash prompts while a run is active", async () => { @@ -1352,7 +1349,11 @@ describe("tui command handlers", () => { it("rejects normal sends while a queued submit is pending registration", async () => { const { handleCommand, sendChat, addUser, addSystem } = createHarness({ activeChatRunId: "run-active", - pendingChatRunId: "run-queued", + pendingSubmit: { + phase: "accepted", + runId: "run-queued", + draftText: "queued", + }, activityStatus: "waiting", }); @@ -1415,7 +1416,11 @@ describe("tui command handlers", () => { it("blocks sends while optimistic user message admission is pending", async () => { const { handleCommand, sendChat, addSystem } = createHarness({ activeChatRunId: "run-active", - pendingOptimisticUserMessage: true, + pendingSubmit: { + phase: "sending", + runId: "run-pending", + draftText: "pending", + }, activityStatus: "sending", }); @@ -1510,7 +1515,11 @@ describe("tui command handlers", () => { const runAuthFlow = vi.fn().mockResolvedValue({ exitCode: 0, signal: null }); const { handleCommand, addSystem } = createHarness({ opts: { local: true }, - pendingOptimisticUserMessage: true, + pendingSubmit: { + phase: "sending", + runId: "run-pending", + draftText: "pending", + }, runAuthFlow, }); @@ -1822,7 +1831,11 @@ describe("tui command handlers", () => { it("blocks /queue while optimistic user message is pending", async () => { const { handleCommand, sendChat, addSystem } = createHarness({ activeChatRunId: "run-active", - pendingOptimisticUserMessage: true, + pendingSubmit: { + phase: "sending", + runId: "run-pending", + draftText: "pending", + }, activityStatus: "sending", }); diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 0529a3e1dc2b..f14f8e47b21d 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -34,6 +34,13 @@ import { TUI_SESSION_PICKER_LIMIT, } from "./tui-session-list-policy.js"; import { formatStatusSummary } from "./tui-status-summary.js"; +import { + acceptPendingSubmit, + beginPendingSubmit, + clearPendingSubmit, + disconnectedTuiChatSubmitMessage, + hasPendingSubmit, +} from "./tui-submit-state.js"; import type { AgentSummary, GatewayStatusSummary, @@ -162,8 +169,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { tui.requestRender(); }; - const hasTrackedAbortTarget = () => - Boolean(state.activeChatRunId || state.pendingChatRunId || state.pendingOptimisticUserMessage); + const hasTrackedAbortTarget = () => Boolean(state.activeChatRunId || hasPendingSubmit(state)); const hasUnsafeSessionRollover = () => hasTrackedAbortTarget() || state.activityStatus === "finishing context"; @@ -383,7 +389,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { chatLog.addSystem("auth login is only available in local embedded mode"); break; } - if (state.activeChatRunId || state.pendingOptimisticUserMessage) { + if (state.activeChatRunId || hasPendingSubmit(state)) { chatLog.addSystem("abort the current run before /auth"); break; } @@ -803,11 +809,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { const sendMessage = async (text: string) => { if (!state.isConnected) { - chatLog.addSystem( - opts.local - ? "local runtime not ready — message not sent" - : "not connected to gateway — message not sent", - ); + chatLog.addSystem(disconnectedTuiChatSubmitMessage(opts.local === true)); setActivityStatus("disconnected"); tui.requestRender(); return; @@ -818,9 +820,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { return; } const isBtw = isBtwCommand(text); - const busy = Boolean( - state.activeChatRunId || state.pendingChatRunId || state.pendingOptimisticUserMessage, - ); + const busy = Boolean(state.activeChatRunId || hasPendingSubmit(state)); if ( isSlashStopCommand(text) || (hasTrackedAbortTarget() && busy && isChatStopCommandText(text)) @@ -830,7 +830,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { } // The Gateway owns queue policy. TUI only serializes pending RPC admission; // an already-active run must not suppress steer/followup/collect/interrupt. - if (!isBtw && (state.pendingOptimisticUserMessage || state.pendingChatRunId)) { + if (!isBtw && hasPendingSubmit(state)) { addBlockedChatSubmitNotice(chatLog); tui.requestRender(); return; @@ -838,18 +838,12 @@ export function createCommandHandlers(context: CommandHandlerContext) { const runId = randomUUID(); try { if (!isBtw) { - if ( - opts.local === true && - state.activeChatRunId && - !state.pendingChatRunId && - !state.pendingOptimisticUserMessage - ) { + if (opts.local === true && state.activeChatRunId && !hasPendingSubmit(state)) { chatLog.reserveAssistantSlot(state.activeChatRunId); } chatLog.addPendingUser(runId, text); - state.pendingSubmitDraft = { runId, text }; + beginPendingSubmit(state, runId, text); noteLocalRunId?.(runId); - state.pendingOptimisticUserMessage = true; setActivityStatus("sending"); } else { noteLocalBtwRunId?.(runId); @@ -892,23 +886,22 @@ export function createCommandHandlers(context: CommandHandlerContext) { acceptedRunId !== runId && !terminalAck && (consumeCompletedRunForPendingSend?.(acceptedRunId) ?? false); + acceptPendingSubmit({ + state, + provisionalRunId: runId, + acceptedRunId, + // A run observed before its ACK owns its rendered row already. + preserveDraft: !(isRunObserved?.(acceptedRunId) || terminalAck), + }); if (acceptedRunId !== runId) { forgetLocalRunId?.(runId); if (!acceptedRunAlreadyCompleted && !terminalAck) { noteLocalRunId?.(acceptedRunId); } - if (state.pendingSubmitDraft?.runId === runId) { - // If the accepted run already emitted events or the ACK is already terminal, - // re-arming the draft would let a later abort drop a row whose lifecycle ended. - state.pendingSubmitDraft = - isRunObserved?.(acceptedRunId) || terminalAck ? null : { runId: acceptedRunId, text }; - } chatLog.rekeyPendingUser(runId, acceptedRunId); } if (terminalAck) { - if (state.pendingSubmitDraft?.runId === acceptedRunId) { - state.pendingSubmitDraft = null; - } + clearPendingSubmit(state, acceptedRunId); forgetLocalRunId?.(acceptedRunId); if (terminalAckFailure) { chatLog.dropPendingUser(acceptedRunId); @@ -916,8 +909,6 @@ export function createCommandHandlers(context: CommandHandlerContext) { if (state.activeChatRunId === acceptedRunId) { state.activeChatRunId = null; } - state.pendingOptimisticUserMessage = false; - state.pendingChatRunId = null; await loadHistory(); if (terminalAckFailure) { chatLog.addSystem(`send failed: ${TERMINAL_CHAT_SEND_FAILURE_MESSAGE}`); @@ -928,17 +919,12 @@ export function createCommandHandlers(context: CommandHandlerContext) { tui.requestRender(); return; } - if (state.pendingOptimisticUserMessage) { + if (hasPendingSubmit(state)) { if (acceptedRunAlreadyCompleted) { - if (state.pendingSubmitDraft?.runId === acceptedRunId) { - state.pendingSubmitDraft = null; - } - state.pendingOptimisticUserMessage = false; - state.pendingChatRunId = null; + clearPendingSubmit(state, acceptedRunId); setActivityStatus("idle"); flushPendingHistoryRefreshIfIdle?.(); } else { - state.pendingChatRunId = acceptedRunId; setActivityStatus("waiting"); } tui.requestRender(); @@ -955,16 +941,12 @@ export function createCommandHandlers(context: CommandHandlerContext) { forgetLocalRunId?.(runId); } if (!isBtw) { - state.pendingOptimisticUserMessage = false; - state.pendingChatRunId = null; // Only clear the failed send's ownership. A queued run may have // terminalized or handed ownership off while the RPC was pending. if (state.activeChatRunId === runId) { state.activeChatRunId = null; } - if (state.pendingSubmitDraft?.runId === runId) { - state.pendingSubmitDraft = null; - } + clearPendingSubmit(state, runId); chatLog.dropPendingUser(runId); } chatLog.addSystem(`${isBtw ? "btw failed" : "send failed"}: ${String(err)}`); diff --git a/src/tui/tui-event-handlers.test.ts b/src/tui/tui-event-handlers.test.ts index 33ebb67a0941..4f27654ffecf 100644 --- a/src/tui/tui-event-handlers.test.ts +++ b/src/tui/tui-event-handlers.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE } from "../shared/assistant-error-format.js"; import { createEventHandlers } from "./tui-event-handlers.js"; +import { getPendingSubmitAcceptedRunId, type TuiPendingSubmit } from "./tui-submit-state.js"; import type { AgentEvent, BtwEvent, @@ -71,6 +72,14 @@ function requireFinalizedAssistantText(chatLog: MockChatLog, index = 0): string return String(call[0]); } +function sendingSubmit(runId: string, draftText = "pending"): TuiPendingSubmit { + return { phase: "sending", runId, draftText }; +} + +function acceptedSubmit(runId: string, draftText: string | null = "pending"): TuiPendingSubmit { + return { phase: "accepted", runId, draftText }; +} + describe("tui-event-handlers: handleAgentEvent", () => { const makeState = (overrides?: Partial): TuiStateAccess => ({ agentDefaultId: "main", @@ -81,7 +90,7 @@ describe("tui-event-handlers: handleAgentEvent", () => { currentSessionKey: "agent:main:main", currentSessionId: "session-1", activeChatRunId: "run-1", - pendingOptimisticUserMessage: false, + pendingSubmit: null, historyLoaded: true, sessionInfo: { verboseLevel: "on" }, initialSessionApplied: true, @@ -510,7 +519,7 @@ describe("tui-event-handlers: handleAgentEvent", () => { const { state, tui, handleAgentEvent } = createHandlersHarness({ state: { activeChatRunId: null, - pendingChatRunId: "run-pending", + pendingSubmit: acceptedSubmit("run-pending"), sessionInfo: { verboseLevel: "on", modelProvider: "llamaforge", @@ -540,8 +549,7 @@ describe("tui-event-handlers: handleAgentEvent", () => { { state: { activeChatRunId: null, - pendingChatRunId: "run-pending", - pendingOptimisticUserMessage: true, + pendingSubmit: acceptedSubmit("run-pending"), }, }, ); @@ -553,20 +561,37 @@ describe("tui-event-handlers: handleAgentEvent", () => { }); expect(state.activeChatRunId).toBe("run-pending"); - expect(state.pendingChatRunId).toBeNull(); - expect(state.pendingOptimisticUserMessage).toBe(false); + expect(state.pendingSubmit).toBeNull(); expect(isLocalRunId("run-pending")).toBe(true); expect(setActivityStatus).toHaveBeenCalledWith("finishing context"); expect(tui.requestRender).toHaveBeenCalled(); }); + it("does not claim another client's lifecycle event as the pending local run", () => { + const { state, handleAgentEvent, isLocalRunId } = createHandlersHarness({ + state: { + activeChatRunId: null, + pendingSubmit: acceptedSubmit("run-pending"), + }, + }); + + handleAgentEvent({ + runId: "run-remote", + stream: "lifecycle", + data: { phase: "start" }, + }); + + expect(state.activeChatRunId).toBeNull(); + expect(state.pendingSubmit?.runId).toBe("run-pending"); + expect(isLocalRunId("run-remote")).toBe(false); + }); + it("does not reload history after lifecycle binds a gateway pending run", () => { const { state, chatLog, loadHistory, handleAgentEvent, handleChatEvent, isLocalRunId } = createHandlersHarness({ state: { activeChatRunId: null, - pendingChatRunId: "run-pending", - pendingOptimisticUserMessage: true, + pendingSubmit: acceptedSubmit("run-pending"), }, }); @@ -583,8 +608,7 @@ describe("tui-event-handlers: handleAgentEvent", () => { message: { content: [{ type: "text", text: "done" }] }, }); - expect(state.pendingChatRunId).toBeNull(); - expect(state.pendingOptimisticUserMessage).toBe(false); + expect(state.pendingSubmit).toBeNull(); expect(isLocalRunId("run-pending")).toBe(false); expect(chatLog.finalizeAssistant).toHaveBeenCalledWith("done", "run-pending"); expect(loadHistory).not.toHaveBeenCalled(); @@ -596,8 +620,7 @@ describe("tui-event-handlers: handleAgentEvent", () => { state: { currentSessionKey: "agent:main:initial", activeChatRunId: null, - pendingChatRunId: "run-pending", - pendingOptimisticUserMessage: true, + pendingSubmit: acceptedSubmit("run-pending"), }, }); noteLocalRunId("run-pending"); @@ -610,8 +633,7 @@ describe("tui-event-handlers: handleAgentEvent", () => { message: { content: [{ type: "text", text: "done" }] }, }); - expect(state.pendingChatRunId).toBeNull(); - expect(state.pendingOptimisticUserMessage).toBe(false); + expect(state.pendingSubmit).toBeNull(); expect(isLocalRunId("run-pending")).toBe(false); expect(chatLog.finalizeAssistant).toHaveBeenCalledWith("done", "run-pending"); expect(loadHistory).not.toHaveBeenCalled(); @@ -993,9 +1015,7 @@ describe("tui-event-handlers: handleAgentEvent", () => { }); noteLocalRunId("run-local"); state.activeChatRunId = "run-stale"; - state.pendingChatRunId = "run-pending"; - state.pendingOptimisticUserMessage = true; - state.pendingSubmitDraft = { runId: "run-pending", text: "pending" }; + state.pendingSubmit = acceptedSubmit("run-pending"); state.activityStatus = "streaming"; loadHistory.mockClear(); refreshSessionInfo.mockClear(); @@ -1012,9 +1032,7 @@ describe("tui-event-handlers: handleAgentEvent", () => { } satisfies SessionChangedEvent); expect(state.activeChatRunId).toBeNull(); - expect(state.pendingChatRunId).toBeNull(); - expect(state.pendingSubmitDraft).toBeNull(); - expect(state.pendingOptimisticUserMessage).toBe(false); + expect(state.pendingSubmit).toBeNull(); expect(state.activityStatus).toBe("idle"); expect(state.currentSessionId).toBe("session-after"); expect(state.sessionInfo.updatedAt).toBe(200); @@ -1245,7 +1263,7 @@ describe("tui-event-handlers: handleAgentEvent", () => { it("binds optimistic pending messages to the first gateway run id and skips history reload", () => { const { state, loadHistory, noteLocalRunId, isLocalRunId, handleChatEvent } = createHandlersHarness({ - state: { activeChatRunId: null, pendingOptimisticUserMessage: true }, + state: { activeChatRunId: null, pendingSubmit: sendingSubmit("run-gateway") }, }); noteLocalRunId("run-gateway"); @@ -1256,7 +1274,7 @@ describe("tui-event-handlers: handleAgentEvent", () => { message: { content: [{ type: "text", text: "done" }] }, }); - expect(state.pendingOptimisticUserMessage).toBe(false); + expect(state.pendingSubmit).toBeNull(); expect(state.activeChatRunId).toBeNull(); expect(isLocalRunId("run-gateway")).toBe(false); expect(loadHistory).not.toHaveBeenCalled(); @@ -1271,7 +1289,10 @@ describe("tui-event-handlers: handleAgentEvent", () => { }; const { state, noteLocalRunId, handleChatEvent } = createHandlersHarness({ chatLog: chatLog as unknown as HandlerChatLog, - state: { activeChatRunId: null, pendingOptimisticUserMessage: true }, + state: { + activeChatRunId: null, + pendingSubmit: sendingSubmit("run-gateway", "queued hello"), + }, }); noteLocalRunId("run-gateway"); @@ -1282,14 +1303,14 @@ describe("tui-event-handlers: handleAgentEvent", () => { message: { content: "working" }, }); - expect(state.pendingOptimisticUserMessage).toBe(false); + expect(state.pendingSubmit).toBeNull(); expect(chatLog.countPendingUsers()).toBe(1); expect(chatLog.render(120).join("\n")).toContain("queued hello"); }); it("does not bind unknown gateway run ids while an optimistic message is pending", () => { const { state, loadHistory, isLocalRunId, handleChatEvent } = createHandlersHarness({ - state: { activeChatRunId: null, pendingOptimisticUserMessage: true }, + state: { activeChatRunId: null, pendingSubmit: sendingSubmit("run-pending") }, }); handleChatEvent({ @@ -1299,7 +1320,7 @@ describe("tui-event-handlers: handleAgentEvent", () => { message: { content: [{ type: "text", text: "done" }] }, }); - expect(state.pendingOptimisticUserMessage).toBe(true); + expect(state.pendingSubmit).toEqual(sendingSubmit("run-pending")); expect(state.activeChatRunId).toBeNull(); expect(isLocalRunId("run-unknown")).toBe(false); expect(loadHistory).not.toHaveBeenCalled(); @@ -1309,8 +1330,7 @@ describe("tui-event-handlers: handleAgentEvent", () => { const { state, chatLog, loadHistory, isLocalRunId, handleChatEvent } = createHandlersHarness({ state: { activeChatRunId: "run-active", - pendingChatRunId: "run-pending", - pendingOptimisticUserMessage: true, + pendingSubmit: acceptedSubmit("run-pending"), }, }); @@ -1321,8 +1341,7 @@ describe("tui-event-handlers: handleAgentEvent", () => { message: { content: [{ type: "text", text: "done" }] }, }); - expect(state.pendingChatRunId).toBeNull(); - expect(state.pendingOptimisticUserMessage).toBe(false); + expect(state.pendingSubmit).toBeNull(); expect(state.activeChatRunId).toBe("run-active"); expect(isLocalRunId("run-pending")).toBe(false); expect(chatLog.finalizeAssistant).toHaveBeenCalledWith("done", "run-pending"); @@ -1334,8 +1353,7 @@ describe("tui-event-handlers: handleAgentEvent", () => { createHandlersHarness({ state: { activeChatRunId: null, - pendingChatRunId: "run-pending", - pendingOptimisticUserMessage: true, + pendingSubmit: acceptedSubmit("run-pending"), }, }); noteLocalRunId("run-pending"); @@ -1347,8 +1365,7 @@ describe("tui-event-handlers: handleAgentEvent", () => { message: { content: [{ type: "text", text: "other done" }] }, }); - expect(state.pendingChatRunId).toBe("run-pending"); - expect(state.pendingOptimisticUserMessage).toBe(true); + expect(getPendingSubmitAcceptedRunId(state)).toBe("run-pending"); expect(isLocalRunId("run-other")).toBe(false); expect(loadHistory).not.toHaveBeenCalled(); @@ -1359,8 +1376,7 @@ describe("tui-event-handlers: handleAgentEvent", () => { message: { content: [{ type: "text", text: "done" }] }, }); - expect(state.pendingChatRunId).toBeNull(); - expect(state.pendingOptimisticUserMessage).toBe(false); + expect(state.pendingSubmit).toBeNull(); expect(chatLog.finalizeAssistant).toHaveBeenCalledWith("done", "run-pending"); expect(loadHistory).toHaveBeenCalledTimes(1); }); @@ -1370,8 +1386,7 @@ describe("tui-event-handlers: handleAgentEvent", () => { createHandlersHarness({ state: { activeChatRunId: "run-active", - pendingChatRunId: "run-pending", - pendingOptimisticUserMessage: true, + pendingSubmit: acceptedSubmit("run-pending"), }, }); noteLocalRunId("run-active"); @@ -1384,20 +1399,18 @@ describe("tui-event-handlers: handleAgentEvent", () => { message: { content: [{ type: "text", text: "active done" }] }, }); - expect(state.pendingChatRunId).toBe("run-pending"); - expect(state.pendingOptimisticUserMessage).toBe(true); + expect(getPendingSubmitAcceptedRunId(state)).toBe("run-pending"); expect(isLocalRunId("run-active")).toBe(false); expect(isLocalRunId("run-pending")).toBe(true); expect(loadHistory).not.toHaveBeenCalled(); }); - it("binds an early final to the optimistic message before pendingChatRunId is assigned", () => { + it("binds an early final before submit acceptance is recorded", () => { const { state, chatLog, loadHistory, noteLocalRunId, isLocalRunId, handleChatEvent } = createHandlersHarness({ state: { activeChatRunId: "run-active", - pendingChatRunId: null, - pendingOptimisticUserMessage: true, + pendingSubmit: sendingSubmit("run-early-final"), }, }); noteLocalRunId("run-early-final"); @@ -1409,20 +1422,18 @@ describe("tui-event-handlers: handleAgentEvent", () => { message: { content: [{ type: "text", text: "done" }] }, }); - expect(state.pendingChatRunId).toBeNull(); - expect(state.pendingOptimisticUserMessage).toBe(false); + expect(state.pendingSubmit).toBeNull(); expect(state.activeChatRunId).toBe("run-active"); expect(isLocalRunId("run-early-final")).toBe(false); expect(chatLog.finalizeAssistant).toHaveBeenCalledWith("done", "run-early-final"); expect(loadHistory).not.toHaveBeenCalled(); }); - it("clears pendingChatRunId when an event for that runId arrives", () => { + it("clears the accepted pending submit when its event arrives", () => { const { state, handleChatEvent } = createHandlersHarness({ state: { activeChatRunId: null, - pendingOptimisticUserMessage: true, - pendingChatRunId: "run-pending", + pendingSubmit: acceptedSubmit("run-pending"), }, }); @@ -1433,7 +1444,7 @@ describe("tui-event-handlers: handleAgentEvent", () => { message: { content: "hi" }, }); - expect(state.pendingChatRunId).toBeNull(); + expect(state.pendingSubmit).toBeNull(); expect(state.activeChatRunId).toBe("run-pending"); }); @@ -2285,7 +2296,7 @@ describe("tui-event-handlers: streaming watchdog", () => { currentSessionKey: "agent:main:main", currentSessionId: "session-1", activeChatRunId: null, - pendingOptimisticUserMessage: false, + pendingSubmit: null, historyLoaded: true, sessionInfo: { verboseLevel: "on" }, initialSessionApplied: true, diff --git a/src/tui/tui-event-handlers.ts b/src/tui/tui-event-handlers.ts index 018285b74bab..69643b7e1f8c 100644 --- a/src/tui/tui-event-handlers.ts +++ b/src/tui/tui-event-handlers.ts @@ -11,6 +11,12 @@ import { sanitizeRenderableText, } from "./tui-formatters.js"; import { TuiStreamAssembler } from "./tui-stream-assembler.js"; +import { + clearPendingSubmit, + clearPendingSubmitDraft, + getPendingSubmitAcceptedRunId, + hasPendingSubmit, +} from "./tui-submit-state.js"; import type { AgentEvent, BtwEvent, @@ -158,12 +164,7 @@ export function createEventHandlers(context: EventHandlerContext) { }; const flushPendingHistoryRefreshIfIdle = () => { - if ( - !pendingHistoryRefresh || - state.activeChatRunId || - state.pendingChatRunId || - state.pendingOptimisticUserMessage - ) { + if (!pendingHistoryRefresh || state.activeChatRunId || hasPendingSubmit(state)) { return; } pendingHistoryRefresh = false; @@ -215,9 +216,7 @@ export function createEventHandlers(context: EventHandlerContext) { postFinalizingRuns.clear(); streamAssembler = new TuiStreamAssembler(); pendingHistoryRefresh = false; - state.pendingOptimisticUserMessage = false; - state.pendingChatRunId = null; - state.pendingSubmitDraft = null; + clearPendingSubmit(state); reconnectPendingRunId = null; clearLocalRunIds?.(); clearLocalBtwRunIds?.(); @@ -287,7 +286,7 @@ export function createEventHandlers(context: EventHandlerContext) { return; } lastSessionKey = state.currentSessionKey; - if (state.activeChatRunId || state.pendingChatRunId || state.pendingOptimisticUserMessage) { + if (state.activeChatRunId || hasPendingSubmit(state)) { return; } clearTrackedRunState(); @@ -346,9 +345,7 @@ export function createEventHandlers(context: EventHandlerContext) { }; const markSubmittedRunRegistered = (runId: string) => { - if (state.pendingSubmitDraft?.runId === runId) { - state.pendingSubmitDraft = null; - } + clearPendingSubmitDraft(state, runId); }; const noteFinalizedRun = (runId: string, opts?: { displayedFinal?: boolean }) => { @@ -496,7 +493,7 @@ export function createEventHandlers(context: EventHandlerContext) { if ( params.requireActiveOrPending === true && !wasActiveRun && - state.pendingChatRunId !== runId + getPendingSubmitAcceptedRunId(state) !== runId ) { return false; } @@ -541,7 +538,8 @@ export function createEventHandlers(context: EventHandlerContext) { wasPendingChatRun?: boolean; }, ) => { - const isPendingChatRun = opts?.wasPendingChatRun === true || state.pendingChatRunId === runId; + const isPendingChatRun = + opts?.wasPendingChatRun === true || getPendingSubmitAcceptedRunId(state) === runId; const isLocalRun = isLocalRunId?.(runId) ?? false; if (isLocalRun) { forgetLocalRunId?.(runId); @@ -556,7 +554,7 @@ export function createEventHandlers(context: EventHandlerContext) { return; } } - if (!isPendingChatRun && (state.pendingChatRunId || state.pendingOptimisticUserMessage)) { + if (!isPendingChatRun && hasPendingSubmit(state)) { pendingHistoryRefresh = true; return; } @@ -697,22 +695,22 @@ export function createEventHandlers(context: EventHandlerContext) { chatLog.dismissPendingSystem(evt.runId); noteSessionRun(evt.runId); markSubmittedRunRegistered(evt.runId); - const isPendingChatRun = state.pendingChatRunId === evt.runId; + const isPendingChatRun = getPendingSubmitAcceptedRunId(state) === evt.runId; const isLocalChatRun = isLocalRunId?.(evt.runId) ?? false; const isLocalBtwRun = isLocalBtwRunId?.(evt.runId) ?? false; const isNewOptimisticRun = - state.pendingOptimisticUserMessage && + hasPendingSubmit(state) && !isLocalBtwRun && (isPendingChatRun || (isLocalChatRun && evt.runId !== state.activeChatRunId)); if (isNewOptimisticRun) { noteLocalRunId?.(evt.runId); - state.pendingOptimisticUserMessage = false; + clearPendingSubmit(state, evt.runId); } if (!state.activeChatRunId && !isLocalBtwRun) { state.activeChatRunId = evt.runId; } if (isPendingChatRun) { - state.pendingChatRunId = null; + clearPendingSubmit(state, evt.runId); } if (evt.state === "delta") { // Arm watchdog and mark streaming on every delta, even when the visible @@ -887,8 +885,9 @@ export function createEventHandlers(context: EventHandlerContext) { if (state.activeChatRunId) { runIds.add(state.activeChatRunId); } - if (state.pendingChatRunId) { - runIds.add(state.pendingChatRunId); + const pendingRunId = getPendingSubmitAcceptedRunId(state); + if (pendingRunId) { + runIds.add(pendingRunId); } const finalizedRunIds = new Set(finalizedRuns.keys()); const displayedRunIds = new Set(finalizedRunsWithDisplay.keys()); @@ -995,7 +994,7 @@ export function createEventHandlers(context: EventHandlerContext) { // when none is held, so a concurrent user run keeps the indicator. const isUntrackedRun = evt.runId !== state.activeChatRunId && - evt.runId !== state.pendingChatRunId && + evt.runId !== getPendingSubmitAcceptedRunId(state) && !sessionRuns.has(evt.runId) && !finalizedRuns.has(evt.runId); if ( @@ -1017,7 +1016,7 @@ export function createEventHandlers(context: EventHandlerContext) { // active chat run id, not the session id. Tool results can arrive after the chat // final event, so accept finalized runs for tool updates. const isActiveRun = evt.runId === state.activeChatRunId; - const isPendingRun = evt.runId === state.pendingChatRunId; + const isPendingRun = evt.runId === getPendingSubmitAcceptedRunId(state); const isSessionRun = sessionRuns.has(evt.runId); if ((isActiveRun || isPendingRun || isSessionRun) && applyFallbackStepModelUpdate(evt)) { if (isActiveRun) { @@ -1070,14 +1069,12 @@ export function createEventHandlers(context: EventHandlerContext) { } if (evt.stream === "lifecycle") { if (isPendingRun) { + // Exact run ownership matters: concurrent clients share this event stream. noteSessionRun(evt.runId); markSubmittedRunRegistered(evt.runId); state.activeChatRunId = evt.runId; - state.pendingChatRunId = null; - if (state.pendingOptimisticUserMessage) { - noteLocalRunId?.(evt.runId); - state.pendingOptimisticUserMessage = false; - } + noteLocalRunId?.(evt.runId); + clearPendingSubmit(state, evt.runId); } const phase = typeof evt.data?.phase === "string" ? evt.data.phase : ""; if (phase && phase !== "error") { diff --git a/src/tui/tui-pty-local.e2e.test.ts b/src/tui/tui-pty-local.e2e.test.ts index a689ecb51533..bda3483be5db 100644 --- a/src/tui/tui-pty-local.e2e.test.ts +++ b/src/tui/tui-pty-local.e2e.test.ts @@ -91,6 +91,12 @@ const GATEWAY_SCENARIOS = { holdFirstResponse: true, followupReplyText: "FOLLOWUP_RUN_COMPLETE", }, + reconnect: { + agentId: "tui-pty-reconnect", + modelId: "tui-pty-reconnect", + toolsProfile: "minimal", + replyText: "RECONNECTED_RUN_COMPLETE", + }, } as const satisfies Record; type GatewayScenarioId = keyof typeof GATEWAY_SCENARIOS; @@ -978,6 +984,52 @@ describe("TUI PTY real backends", () => { }); } + registerGatewayTest( + "preserves a disconnected draft across a real Gateway restart", + async ({ onTestFinished }) => { + const fixture = await startGatewayModeTui("reconnect", onTestFinished); + let gatewayStopped = false; + try { + await fixture.run.waitForOutput("gateway connected", LOCAL_STARTUP_TIMEOUT_MS); + const disconnectOffset = fixture.run.output().length; + await fixture.gateway.stopGateway(); + gatewayStopped = true; + await waitForOutputAfter(fixture.run, "gateway disconnected", disconnectOffset); + + await fixture.run.write("send preserved draft after restart\r"); + await fixture.run.waitForOutput("not connected to gateway — message not sent"); + expect(fixture.mockModel.requests()).toHaveLength(0); + + const reconnectOffset = fixture.run.output().length; + await fixture.gateway.startGateway(); + gatewayStopped = false; + await waitForOutputAfter(fixture.run, "gateway reconnected", reconnectOffset); + await fixture.run.write("\r", { delay: false }); + await waitFor({ + timeoutMs: LOCAL_OUTPUT_TIMEOUT_MS, + read: () => (fixture.mockModel.requests().length === 1 ? true : null), + onTimeout: () => + new Error( + `preserved prompt did not reach the model after restart\n${fixture.gateway.logs()}\n${fixture.run.output()}`, + ), + }); + expect(JSON.stringify(fixture.mockModel.requests()[0]?.body)).toContain( + "send preserved draft after restart", + ); + await fixture.run.waitForOutput("RECONNECTED_RUN_COMPLETE"); + + await fixture.run.write("/exit\r", { delay: false }); + expect((await fixture.run.waitForExit()).exitCode).toBe(0); + } finally { + if (gatewayStopped) { + await fixture.gateway.startGateway(); + } + await fixture.cleanup(); + } + }, + LOCAL_TEST_TIMEOUT_MS, + ); + registerGatewayTest( "creates and adopts a fresh session through the real Gateway backend", async ({ onTestFinished }) => { diff --git a/src/tui/tui-session-actions.test.ts b/src/tui/tui-session-actions.test.ts index 96f59d7183bd..985b7bd43c45 100644 --- a/src/tui/tui-session-actions.test.ts +++ b/src/tui/tui-session-actions.test.ts @@ -3,9 +3,23 @@ import { describe, expect, it, vi } from "vitest"; import type { TuiBackend } from "./tui-backend.js"; import { createSessionActions } from "./tui-session-actions.js"; import { TUI_SESSION_LOOKUP_LIMIT } from "./tui-session-list-policy.js"; +import { + getPendingSubmitAcceptedRunId, + getPendingSubmitDraft, + type TuiPendingSubmit, +} from "./tui-submit-state.js"; import type { TuiStateAccess } from "./tui-types.js"; describe("tui session actions", () => { + const sendingSubmit = (runId: string, draftText = "pending"): TuiPendingSubmit => ({ + phase: "sending", + runId, + draftText, + }); + const acceptedSubmit = ( + runId: string, + draftText: string | null = "pending", + ): TuiPendingSubmit => ({ phase: "accepted", runId, draftText }); const createBtwPresenter = () => ({ clear: vi.fn(), showResult: vi.fn(), @@ -20,6 +34,7 @@ describe("tui session actions", () => { currentSessionKey: "agent:main:main", currentSessionId: null, activeChatRunId: null, + pendingSubmit: null, historyLoaded: false, sessionInfo: {}, initialSessionApplied: true, @@ -695,6 +710,7 @@ describe("tui session actions", () => { currentSessionKey: "agent:main:brand-new", currentSessionId: null, activeChatRunId: null, + pendingSubmit: null, historyLoaded: false, sessionInfo: {}, initialSessionApplied: true, @@ -782,8 +798,7 @@ describe("tui session actions", () => { }); const state = createBaseState({ activeChatRunId: null, - pendingChatRunId: null, - pendingOptimisticUserMessage: true, + pendingSubmit: sendingSubmit("run-pending"), }); const { setSession } = createTestSessionActions({ @@ -796,8 +811,7 @@ describe("tui session actions", () => { await setSession("agent:main:other"); - expect(state.pendingOptimisticUserMessage).toBe(false); - expect(state.pendingChatRunId).toBeNull(); + expect(state.pendingSubmit).toBeNull(); }); it("applies reset mutation result without reloading gateway history", () => { @@ -872,13 +886,13 @@ describe("tui session actions", () => { expect(addSystem).not.toHaveBeenCalled(); }); - it("uses session-scoped abort when only pendingChatRunId is set", async () => { + it("uses session-scoped abort when only an accepted pending submit is tracked", async () => { const abortChat = vi.fn().mockResolvedValue({ ok: true, aborted: true }); const addSystem = vi.fn(); const setActivityStatus = vi.fn(); const state = createBaseState({ activeChatRunId: null, - pendingChatRunId: "run-pending", + pendingSubmit: acceptedSubmit("run-pending", null), }); const { abortActive } = createSessionActions({ @@ -907,7 +921,7 @@ describe("tui session actions", () => { sessionKey: "agent:main:main", }); expect(addSystem).not.toHaveBeenCalledWith("no active run"); - expect(state.pendingChatRunId).toBeNull(); + expect(state.pendingSubmit).toBeNull(); expect(setActivityStatus).toHaveBeenCalledWith("aborted"); }); @@ -916,9 +930,7 @@ describe("tui session actions", () => { const dropPendingUser = vi.fn(); const state = createBaseState({ activeChatRunId: null, - pendingChatRunId: "run-1", - pendingOptimisticUserMessage: true, - pendingSubmitDraft: { runId: "run-1", text: "hello" }, + pendingSubmit: acceptedSubmit("run-1", "hello"), }); const { abortActive } = createTestSessionActions({ @@ -934,8 +946,7 @@ describe("tui session actions", () => { await abortActive(); expect(dropPendingUser).toHaveBeenCalledWith("run-1"); - expect(state.pendingSubmitDraft).toBeNull(); - expect(state.pendingOptimisticUserMessage).toBe(false); + expect(state.pendingSubmit).toBeNull(); }); it("keeps the optimistic row when aborting a run that already registered", async () => { @@ -943,9 +954,7 @@ describe("tui session actions", () => { const dropPendingUser = vi.fn(); const state = createBaseState({ activeChatRunId: null, - pendingChatRunId: "run-1", - pendingOptimisticUserMessage: true, - pendingSubmitDraft: null, + pendingSubmit: acceptedSubmit("run-1", null), }); const { abortActive } = createTestSessionActions({ @@ -972,8 +981,7 @@ describe("tui session actions", () => { const dropPendingUser = vi.fn(); const state = createBaseState({ activeChatRunId: "run-active", - pendingChatRunId: null, - pendingSubmitDraft: null, + pendingSubmit: null, }); const { abortActive } = createTestSessionActions({ @@ -1005,9 +1013,7 @@ describe("tui session actions", () => { const dropPendingUser = vi.fn(); const state = createBaseState({ activeChatRunId: "run-active", - pendingChatRunId: "run-queued", - pendingOptimisticUserMessage: true, - pendingSubmitDraft: { runId: "run-queued", text: "queued" }, + pendingSubmit: acceptedSubmit("run-queued", "queued"), }); const { abortActive } = createTestSessionActions({ client: { listSessions: vi.fn(), abortChat } as unknown as TuiBackend, @@ -1021,8 +1027,7 @@ describe("tui session actions", () => { const pendingAbort = abortActive(); await vi.waitFor(() => expect(abortChat).toHaveBeenCalledOnce()); - state.pendingChatRunId = null; - state.pendingSubmitDraft = null; + state.pendingSubmit = null; resolveAbort?.({ ok: true, aborted: true, runIds: ["run-active", "run-queued"] }); await pendingAbort; @@ -1035,7 +1040,7 @@ describe("tui session actions", () => { const state = createBaseState({ currentAgentId: "work", currentSessionKey: "global", - pendingChatRunId: "run-work-global", + pendingSubmit: acceptedSubmit("run-work-global", null), }); const { abortActive } = createTestSessionActions({ @@ -1077,9 +1082,7 @@ describe("tui session actions", () => { const abortChat = vi.fn().mockResolvedValue({ ok: true, aborted: false }); const dropPendingUser = vi.fn(); const state = createBaseState({ - pendingChatRunId: "run-pending", - pendingOptimisticUserMessage: true, - pendingSubmitDraft: { runId: "run-pending", text: "hello" }, + pendingSubmit: acceptedSubmit("run-pending", "hello"), }); const { abortActive } = createTestSessionActions({ @@ -1094,9 +1097,8 @@ describe("tui session actions", () => { await abortActive(); - expect(state.pendingChatRunId).toBe("run-pending"); - expect(state.pendingOptimisticUserMessage).toBe(true); - expect(state.pendingSubmitDraft).toEqual({ runId: "run-pending", text: "hello" }); + expect(getPendingSubmitAcceptedRunId(state)).toBe("run-pending"); + expect(getPendingSubmitDraft(state)).toEqual({ runId: "run-pending", text: "hello" }); expect(dropPendingUser).not.toHaveBeenCalled(); }); @@ -1106,7 +1108,7 @@ describe("tui session actions", () => { const requestRender = vi.fn(); const state = createBaseState({ activeChatRunId: "run-finishing", - pendingChatRunId: null, + pendingSubmit: null, activityStatus: "finishing context", }); @@ -1136,7 +1138,7 @@ describe("tui session actions", () => { const setActivityStatus = vi.fn(); const state = createBaseState({ activeChatRunId: "run-finishing", - pendingChatRunId: null, + pendingSubmit: null, activityStatus: "finishing context", }); @@ -1161,8 +1163,7 @@ describe("tui session actions", () => { const setActivityStatus = vi.fn(); const state = createBaseState({ activeChatRunId: "run-finishing", - pendingChatRunId: "run-queued", - pendingOptimisticUserMessage: true, + pendingSubmit: acceptedSubmit("run-queued", null), activityStatus: "waiting", }); @@ -1178,8 +1179,7 @@ describe("tui session actions", () => { expect(abortChat).toHaveBeenCalledWith({ sessionKey: "agent:main:main", }); - expect(state.pendingChatRunId).toBeNull(); - expect(state.pendingOptimisticUserMessage).toBe(false); + expect(state.pendingSubmit).toBeNull(); expect(setActivityStatus).toHaveBeenCalledWith("aborted"); }); @@ -1188,7 +1188,7 @@ describe("tui session actions", () => { const setActivityStatus = vi.fn(); const state = createBaseState({ activeChatRunId: "run-active", - pendingChatRunId: "run-queued", + pendingSubmit: acceptedSubmit("run-queued", null), activityStatus: "waiting", }); @@ -1204,7 +1204,7 @@ describe("tui session actions", () => { expect(abortChat).toHaveBeenCalledWith({ sessionKey: "agent:main:main", }); - expect(state.pendingChatRunId).toBeNull(); + expect(state.pendingSubmit).toBeNull(); expect(setActivityStatus).toHaveBeenCalledWith("aborted"); }); @@ -1213,7 +1213,7 @@ describe("tui session actions", () => { const setActivityStatus = vi.fn(); const state = createBaseState({ activeChatRunId: "run-active", - pendingChatRunId: "run-queued", + pendingSubmit: acceptedSubmit("run-queued", null), activityStatus: "waiting", }); @@ -1231,7 +1231,7 @@ describe("tui session actions", () => { expect(abortChat).toHaveBeenCalledWith({ sessionKey: "agent:main:main", }); - expect(state.pendingChatRunId).toBeNull(); + expect(state.pendingSubmit).toBeNull(); expect(setActivityStatus).toHaveBeenCalledWith("aborted"); }); @@ -1322,9 +1322,7 @@ describe("tui session actions", () => { restorePendingUsers: vi.fn(), }; const state = createBaseState({ - pendingChatRunId: "run-pending", - pendingOptimisticUserMessage: true, - pendingSubmitDraft: { runId: "run-pending", text: "persisted" }, + pendingSubmit: acceptedSubmit("run-pending", "persisted"), }); const { loadHistory: runLoadHistory } = createTestSessionActions({ @@ -1335,9 +1333,7 @@ describe("tui session actions", () => { await runLoadHistory(); - expect(state.pendingChatRunId).toBeNull(); - expect(state.pendingOptimisticUserMessage).toBe(false); - expect(state.pendingSubmitDraft).toBeNull(); + expect(state.pendingSubmit).toBeNull(); }); it("keeps a pending submit when reconnect history has not accepted it", async () => { @@ -1351,9 +1347,7 @@ describe("tui session actions", () => { restorePendingUsers: vi.fn(), }; const state = createBaseState({ - pendingChatRunId: "run-pending", - pendingOptimisticUserMessage: true, - pendingSubmitDraft: { runId: "run-pending", text: "not persisted" }, + pendingSubmit: acceptedSubmit("run-pending", "not persisted"), }); const { loadHistory: runLoadHistory } = createTestSessionActions({ @@ -1364,9 +1358,11 @@ describe("tui session actions", () => { await runLoadHistory(); - expect(state.pendingChatRunId).toBe("run-pending"); - expect(state.pendingOptimisticUserMessage).toBe(true); - expect(state.pendingSubmitDraft).toEqual({ runId: "run-pending", text: "not persisted" }); + expect(getPendingSubmitAcceptedRunId(state)).toBe("run-pending"); + expect(getPendingSubmitDraft(state)).toEqual({ + runId: "run-pending", + text: "not persisted", + }); }); it("force-renders after rebuilding chat history so transient status rows are cleared", async () => { diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index 28ee3c642084..f2d599c9e901 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -14,7 +14,7 @@ import type { ChatLog } from "./components/chat-log.js"; import type { TuiAgentsList, TuiBackend, TuiSessionMutationResult } from "./tui-backend.js"; import { asString, extractTextFromMessage, isCommandMessage } from "./tui-formatters.js"; import { TUI_SESSION_LOOKUP_LIMIT } from "./tui-session-list-policy.js"; -import { reconcilePendingSubmitHistory } from "./tui-submit.js"; +import * as submit from "./tui-submit-state.js"; import type { SessionInfo, TuiHistoryLoadResult, TuiOptions, TuiStateAccess } from "./tui-types.js"; type SessionActionBtwPresenter = { @@ -541,7 +541,7 @@ export function createSessionActions(context: SessionActionContext) { ); } } - reconcilePendingSubmitHistory(state, chatLog.reconcilePendingUsers(historyUsers)); + submit.reconcilePendingSubmitHistory(state, chatLog.reconcilePendingUsers(historyUsers)); chatLog.restorePendingUsers(); // Restore a run still streaming for this session+agent that the gateway // reports as in-flight. Its live deltas were delivered to a per-agent key @@ -582,9 +582,7 @@ export function createSessionActions(context: SessionActionContext) { updateAgentFromSessionKey(nextKey); state.currentSessionKey = nextKey; state.activeChatRunId = null; - state.pendingChatRunId = null; - state.pendingOptimisticUserMessage = false; - state.pendingSubmitDraft = null; + submit.clearPendingSubmit(state); setActivityStatus("idle"); state.currentSessionId = null; // Session keys can move backwards in updatedAt ordering; drop previous session freshness @@ -604,15 +602,15 @@ export function createSessionActions(context: SessionActionContext) { opts.local === true && state.activityStatus === "finishing context" && !params?.preferActive && - !state.pendingChatRunId + !submit.getPendingSubmitAcceptedRunId(state) ) { chatLog.addSystem("agent is finishing context; wait for it to finish before aborting"); tui.requestRender(); return; } - const abortsPendingRun = Boolean(state.pendingChatRunId); + const pendingRunId = submit.getPendingSubmitAcceptedRunId(state); + const abortsPendingRun = Boolean(pendingRunId); const activeRunId = state.activeChatRunId; - const pendingRunId = state.pendingChatRunId; const sessionAbortParams = { sessionKey: state.currentSessionKey, ...(state.currentSessionKey === "global" ? { agentId: state.currentAgentId } : {}), @@ -628,19 +626,20 @@ export function createSessionActions(context: SessionActionContext) { return; } for (const runId of result.runIds ?? []) { - const stillTracked = state.activeChatRunId === runId || state.pendingChatRunId === runId; + const stillTracked = + state.activeChatRunId === runId || submit.getPendingSubmitAcceptedRunId(state) === runId; // The active prompt is already persisted. Pending/queued prompts may // terminalize while the RPC is in flight, so inspect their live state. if (runId !== activeRunId && !stillTracked) { chatLog.dropPendingUser(runId); } } - state.pendingChatRunId = null; if (abortsPendingRun) { - state.pendingOptimisticUserMessage = false; - if (pendingRunId && state.pendingSubmitDraft?.runId === pendingRunId) { + // Re-read after abortChat: an event may already have dropped the queued row. + const pendingDraft = submit.getPendingSubmitDraft(state); + submit.clearPendingSubmit(state, pendingRunId ?? undefined); + if (pendingRunId && pendingDraft?.runId === pendingRunId) { chatLog.dropPendingUser(pendingRunId); - state.pendingSubmitDraft = null; } } setActivityStatus("aborted"); diff --git a/src/tui/tui-submit-state.test.ts b/src/tui/tui-submit-state.test.ts new file mode 100644 index 000000000000..036fb166d53f --- /dev/null +++ b/src/tui/tui-submit-state.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest"; +import { + acceptPendingSubmit, + beginPendingSubmit, + clearPendingSubmit, + clearPendingSubmitDraft, + disconnectedTuiChatSubmitMessage, + getPendingSubmitAcceptedRunId, + getPendingSubmitDraft, + reconcilePendingSubmitHistory, + resolveTuiChatSubmitAdmission, + type TuiPendingSubmit, +} from "./tui-submit-state.js"; + +type State = { pendingSubmit: TuiPendingSubmit | null }; + +describe("resolveTuiChatSubmitAdmission", () => { + it.each([ + { + name: "idle", + isConnected: true, + activeChatRunId: null, + pendingSubmit: null, + message: "hello", + expected: "allowed", + }, + { + name: "active run", + isConnected: true, + activeChatRunId: "run-active", + pendingSubmit: null, + message: "follow up", + expected: "allowed", + }, + { + name: "disconnected", + isConnected: false, + activeChatRunId: null, + pendingSubmit: null, + message: "send after reconnect", + expected: "disconnected", + }, + { + name: "sending", + isConnected: true, + activeChatRunId: null, + pendingSubmit: { phase: "sending", runId: "run-send", draftText: "hello" }, + message: "another", + expected: "pending", + }, + { + name: "accepted", + isConnected: true, + activeChatRunId: null, + pendingSubmit: { phase: "accepted", runId: "run-pending", draftText: "hello" }, + message: "another", + expected: "pending", + }, + { + name: "stop active run", + isConnected: true, + activeChatRunId: "run-active", + pendingSubmit: null, + message: "please stop", + expected: "allowed", + }, + { + name: "stop accepted run", + isConnected: true, + activeChatRunId: null, + pendingSubmit: { phase: "accepted", runId: "run-pending", draftText: null }, + message: "please stop", + expected: "allowed", + }, + ] as const)("returns $expected while $name", ({ expected, ...params }) => { + expect(resolveTuiChatSubmitAdmission(params)).toBe(expected); + }); +}); + +describe("pending submit transitions", () => { + it("moves one submit through sending, accepted, registered, and reconciled states", () => { + const state: State = { pendingSubmit: null }; + + beginPendingSubmit(state, "run-local", "hello"); + expect(state.pendingSubmit).toEqual({ + phase: "sending", + runId: "run-local", + draftText: "hello", + }); + expect(getPendingSubmitAcceptedRunId(state)).toBeNull(); + expect(getPendingSubmitDraft(state)).toEqual({ runId: "run-local", text: "hello" }); + + expect( + acceptPendingSubmit({ + state, + provisionalRunId: "run-local", + acceptedRunId: "run-accepted", + preserveDraft: true, + }), + ).toBe(true); + expect(state.pendingSubmit).toEqual({ + phase: "accepted", + runId: "run-accepted", + draftText: "hello", + }); + expect(getPendingSubmitAcceptedRunId(state)).toBe("run-accepted"); + + expect(clearPendingSubmitDraft(state, "run-accepted")).toBe(true); + expect(state.pendingSubmit).toEqual({ + phase: "accepted", + runId: "run-accepted", + draftText: null, + }); + expect(reconcilePendingSubmitHistory(state, ["other-run"])).toBe(false); + expect(reconcilePendingSubmitHistory(state, ["run-accepted"])).toBe(true); + expect(state.pendingSubmit).toBeNull(); + }); + + it("does not re-arm a submit cleared by an event before its ACK", () => { + const state: State = { pendingSubmit: null }; + beginPendingSubmit(state, "run-local", "hello"); + expect(clearPendingSubmit(state, "run-local")).toBe(true); + + expect( + acceptPendingSubmit({ + state, + provisionalRunId: "run-local", + acceptedRunId: "run-accepted", + preserveDraft: true, + }), + ).toBe(false); + expect(state.pendingSubmit).toBeNull(); + }); + + it("does not accept an already accepted submit again", () => { + const state: State = { + pendingSubmit: { phase: "accepted", runId: "run-accepted", draftText: null }, + }; + + expect( + acceptPendingSubmit({ + state, + provisionalRunId: "run-accepted", + acceptedRunId: "run-other", + preserveDraft: false, + }), + ).toBe(false); + expect(state.pendingSubmit?.runId).toBe("run-accepted"); + }); + + it("keeps draft ownership while a submit is still sending", () => { + const state: State = { + pendingSubmit: { phase: "sending", runId: "run-sending", draftText: "hello" }, + }; + + expect(clearPendingSubmitDraft(state, "run-sending")).toBe(false); + expect(state.pendingSubmit?.draftText).toBe("hello"); + }); + + it("clears only the run that owns the pending state", () => { + const state: State = { + pendingSubmit: { phase: "accepted", runId: "run-current", draftText: "hello" }, + }; + + expect(clearPendingSubmit(state, "run-other")).toBe(false); + expect(state.pendingSubmit?.runId).toBe("run-current"); + }); +}); + +describe("disconnectedTuiChatSubmitMessage", () => { + it("uses the connection message for the selected runtime", () => { + expect(disconnectedTuiChatSubmitMessage(false)).toBe( + "not connected to gateway — message not sent", + ); + expect(disconnectedTuiChatSubmitMessage(true)).toBe( + "local runtime not ready — message not sent", + ); + }); +}); diff --git a/src/tui/tui-submit-state.ts b/src/tui/tui-submit-state.ts new file mode 100644 index 000000000000..d6eed6ea02f7 --- /dev/null +++ b/src/tui/tui-submit-state.ts @@ -0,0 +1,105 @@ +import { isChatStopCommandText } from "../gateway/chat-abort.js"; + +export type TuiPendingSubmit = + | { phase: "sending"; runId: string; draftText: string } + | { phase: "accepted"; runId: string; draftText: string | null }; + +export type TuiChatSubmitAdmission = "allowed" | "disconnected" | "pending"; + +type PendingSubmitState = { pendingSubmit: TuiPendingSubmit | null }; + +export function beginPendingSubmit(state: PendingSubmitState, runId: string, text: string): void { + state.pendingSubmit = { phase: "sending", runId, draftText: text }; +} + +export function acceptPendingSubmit(params: { + state: PendingSubmitState; + provisionalRunId: string; + acceptedRunId: string; + preserveDraft: boolean; +}): boolean { + const pending = params.state.pendingSubmit; + if (!pending || pending.phase !== "sending" || pending.runId !== params.provisionalRunId) { + return false; + } + params.state.pendingSubmit = { + phase: "accepted", + runId: params.acceptedRunId, + draftText: params.preserveDraft ? pending.draftText : null, + }; + return true; +} + +export function clearPendingSubmit(state: PendingSubmitState, runId?: string): boolean { + const pending = state.pendingSubmit; + if (!pending || (runId !== undefined && pending.runId !== runId)) { + return false; + } + state.pendingSubmit = null; + return true; +} + +export function clearPendingSubmitDraft(state: PendingSubmitState, runId: string): boolean { + const pending = state.pendingSubmit; + if (pending?.phase !== "accepted" || pending.runId !== runId || pending.draftText === null) { + return false; + } + state.pendingSubmit = { ...pending, draftText: null }; + return true; +} + +export function hasPendingSubmit(state: PendingSubmitState): boolean { + return state.pendingSubmit !== null; +} + +export function getPendingSubmitAcceptedRunId(state: PendingSubmitState): string | null { + return state.pendingSubmit?.phase === "accepted" ? state.pendingSubmit.runId : null; +} + +export function getPendingSubmitDraft( + state: PendingSubmitState, +): { runId: string; text: string } | null { + const pending = state.pendingSubmit; + if (!pending || pending.draftText === null) { + return null; + } + return { runId: pending.runId, text: pending.draftText }; +} + +export function reconcilePendingSubmitHistory( + state: PendingSubmitState, + reconciledRunIds: readonly string[], +): boolean { + const runId = state.pendingSubmit?.runId; + if (!runId || !new Set(reconciledRunIds).has(runId)) { + return false; + } + // History proves the Gateway accepted this submit even if reconnect hid + // its registration event. Release the admission gate or the idle TUI stays blocked. + state.pendingSubmit = null; + return true; +} + +export function resolveTuiChatSubmitAdmission(params: { + isConnected: boolean; + activeChatRunId: string | null; + pendingSubmit: TuiPendingSubmit | null; + message: string; +}): TuiChatSubmitAdmission { + if (!params.isConnected) { + return "disconnected"; + } + if ( + isChatStopCommandText(params.message) && + (params.activeChatRunId || params.pendingSubmit?.phase === "accepted") + ) { + return "allowed"; + } + return params.pendingSubmit ? "pending" : "allowed"; +} + +export function disconnectedTuiChatSubmitMessage(local: boolean): string { + return local + ? "local runtime not ready — message not sent" + : "not connected to gateway — message not sent"; +} diff --git a/src/tui/tui-submit-test-helpers.ts b/src/tui/tui-submit-test-helpers.ts index 5cc98ec964dc..378f3ef3a254 100644 --- a/src/tui/tui-submit-test-helpers.ts +++ b/src/tui/tui-submit-test-helpers.ts @@ -1,5 +1,6 @@ // Provides test helpers for TUI submit handler scenarios. import { vi } from "vitest"; +import type { TuiChatSubmitAdmission } from "./tui-submit-state.js"; import { createEditorSubmitHandler } from "./tui-submit.js"; // Test harness for submit-handler specs without constructing a full TUI. @@ -13,7 +14,7 @@ type SubmitHarness = { handleCommand: MockFn; sendMessage: MockFn; handleBangLine: MockFn; - canSubmitMessage: MockFn; + admitMessage: MockFn; onBlockedMessageSubmit: MockFn; onSubmitError: MockFn; onSubmit: (text: string) => void; @@ -21,7 +22,7 @@ type SubmitHarness = { /** Creates editor/command/message mocks wired to the real submit handler. */ export function createSubmitHarness(params?: { - canSubmitMessage?: (value: string) => boolean; + admitMessage?: (value: string) => TuiChatSubmitAdmission; }): SubmitHarness { const editor = { setText: vi.fn(), @@ -30,7 +31,7 @@ export function createSubmitHarness(params?: { const handleCommand = vi.fn(); const sendMessage = vi.fn(); const handleBangLine = vi.fn(); - const canSubmitMessage = vi.fn(params?.canSubmitMessage ?? (() => true)); + const admitMessage = vi.fn(params?.admitMessage ?? (() => "allowed" as const)); const onBlockedMessageSubmit = vi.fn(); const onSubmitError = vi.fn(); const onSubmit = createEditorSubmitHandler({ @@ -39,7 +40,7 @@ export function createSubmitHarness(params?: { sendMessage, handleBangLine, onSubmitError, - canSubmitMessage, + admitMessage, onBlockedMessageSubmit, }); return { @@ -47,7 +48,7 @@ export function createSubmitHarness(params?: { handleCommand, sendMessage, handleBangLine, - canSubmitMessage, + admitMessage, onBlockedMessageSubmit, onSubmitError, onSubmit, diff --git a/src/tui/tui-submit.ts b/src/tui/tui-submit.ts index 1918a482e2e6..784ffc2d95da 100644 --- a/src/tui/tui-submit.ts +++ b/src/tui/tui-submit.ts @@ -1,48 +1,9 @@ // Handles TUI input submission and command dispatch. import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; -import { isChatStopCommandText } from "../gateway/chat-abort.js"; -import type { TuiStateAccess } from "./tui-types.js"; +import type { TuiChatSubmitAdmission } from "./tui-submit-state.js"; export type TuiSubmitAction = "local shell" | "command" | "message"; -export function canSubmitTuiChatMessage(params: { - isConnected?: boolean; - activeChatRunId?: string | null; - pendingChatRunId?: string | null; - pendingOptimisticUserMessage?: boolean; - message?: string; -}): boolean { - if (params.isConnected === false) { - return false; - } - const stopText = params.message ? isChatStopCommandText(params.message) : false; - if (stopText && (params.activeChatRunId || params.pendingChatRunId)) { - return true; - } - return !params.pendingChatRunId && params.pendingOptimisticUserMessage !== true; -} - -export function reconcilePendingSubmitHistory( - state: TuiStateAccess, - reconciledRunIds: readonly string[], -): void { - const reconciledRunIdSet = new Set(reconciledRunIds); - const pendingAdmissionRunId = state.pendingChatRunId; - const pendingDraftRunId = state.pendingSubmitDraft?.runId; - if ( - (pendingAdmissionRunId && reconciledRunIdSet.has(pendingAdmissionRunId)) || - (pendingDraftRunId && reconciledRunIdSet.has(pendingDraftRunId)) - ) { - // History proves the Gateway accepted this submit even if reconnect hid - // its registration event. Release the admission gate or the idle TUI stays blocked. - state.pendingChatRunId = null; - state.pendingOptimisticUserMessage = false; - if (pendingDraftRunId && reconciledRunIdSet.has(pendingDraftRunId)) { - state.pendingSubmitDraft = null; - } - } -} - function runSubmitAction( action: TuiSubmitAction, run: () => Promise | void, @@ -66,8 +27,11 @@ export function createEditorSubmitHandler(params: { sendMessage: (value: string) => Promise | void; handleBangLine: (value: string) => Promise | void; onSubmitError: (action: TuiSubmitAction, error: unknown) => void; - canSubmitMessage?: (value: string) => boolean; - onBlockedMessageSubmit?: (value: string) => void; + admitMessage?: (value: string) => TuiChatSubmitAdmission; + onBlockedMessageSubmit?: ( + value: string, + reason: Exclude, + ) => void; }) { return (text: string) => { const raw = text; @@ -97,9 +61,10 @@ export function createEditorSubmitHandler(params: { return; } - if (params.canSubmitMessage && !params.canSubmitMessage(value)) { + const admission = params.admitMessage?.(value) ?? "allowed"; + if (admission !== "allowed") { params.editor.setText(value); - params.onBlockedMessageSubmit?.(value); + params.onBlockedMessageSubmit?.(value, admission); return; } diff --git a/src/tui/tui-types.ts b/src/tui/tui-types.ts index 9746f831eac3..e71dc7c11016 100644 --- a/src/tui/tui-types.ts +++ b/src/tui/tui-types.ts @@ -2,6 +2,7 @@ import type { FastMode } from "@openclaw/normalization-core/string-coerce"; // Defines shared TUI state, backend, and event types. import type { SessionGoal } from "../config/sessions/types.js"; import type { GatewayAgentRuntime } from "../shared/session-types.js"; +import type { TuiPendingSubmit } from "./tui-submit-state.js"; export type TuiOptions = { local?: boolean; @@ -168,9 +169,7 @@ export type TuiStateAccess = { currentSessionKey: string; currentSessionId: string | null; activeChatRunId: string | null; - pendingOptimisticUserMessage?: boolean; - pendingChatRunId?: string | null; - pendingSubmitDraft?: { runId: string; text: string } | null; + pendingSubmit: TuiPendingSubmit | null; queuedMessages?: QueuedMessage[]; historyLoaded: boolean; sessionInfo: SessionInfo; diff --git a/src/tui/tui.submit-handler.test.ts b/src/tui/tui.submit-handler.test.ts index 3ae602518614..710d89731d34 100644 --- a/src/tui/tui.submit-handler.test.ts +++ b/src/tui/tui.submit-handler.test.ts @@ -54,7 +54,7 @@ describe("createEditorSubmitHandler", () => { it("preserves normal message drafts when chat is busy", () => { const { editor, sendMessage, handleCommand, handleBangLine, onBlockedMessageSubmit, onSubmit } = createSubmitHarness({ - canSubmitMessage: () => false, + admitMessage: () => "pending", }); onSubmit(" wait, use c++ instead "); @@ -64,16 +64,18 @@ describe("createEditorSubmitHandler", () => { expect(sendMessage).not.toHaveBeenCalled(); expect(handleCommand).not.toHaveBeenCalled(); expect(handleBangLine).not.toHaveBeenCalled(); - expect(onBlockedMessageSubmit).toHaveBeenCalledWith("wait, use c++ instead"); + expect(onBlockedMessageSubmit).toHaveBeenCalledWith("wait, use c++ instead", "pending"); }); it("passes the submitted text to the busy gate", () => { - const canSubmitMessage = vi.fn((value: string) => value === "please stop"); - const { sendMessage, onSubmit } = createSubmitHarness({ canSubmitMessage }); + const admitMessage = vi.fn((value: string) => + value === "please stop" ? ("allowed" as const) : ("pending" as const), + ); + const { sendMessage, onSubmit } = createSubmitHarness({ admitMessage }); onSubmit("please stop"); - expect(canSubmitMessage).toHaveBeenCalledWith("please stop"); + expect(admitMessage).toHaveBeenCalledWith("please stop"); expect(sendMessage).toHaveBeenCalledWith("please stop"); }); @@ -89,7 +91,7 @@ describe("createEditorSubmitHandler", () => { sendMessage, handleBangLine: vi.fn(), onSubmitError: vi.fn(), - canSubmitMessage: () => false, + admitMessage: () => "pending", onBlockedMessageSubmit, }); @@ -97,13 +99,13 @@ describe("createEditorSubmitHandler", () => { expect(editor.getText()).toBe("wait, use c++ instead"); expect(sendMessage).not.toHaveBeenCalled(); - expect(onBlockedMessageSubmit).toHaveBeenCalledWith("wait, use c++ instead"); + expect(onBlockedMessageSubmit).toHaveBeenCalledWith("wait, use c++ instead", "pending"); }); it("continues to route slash commands while chat is busy", () => { const { editor, handleCommand, sendMessage, onBlockedMessageSubmit, onSubmit } = createSubmitHarness({ - canSubmitMessage: () => false, + admitMessage: () => "pending", }); onSubmit("/abort"); diff --git a/src/tui/tui.test.ts b/src/tui/tui.test.ts index 588680cb9ff1..d6b04205c3cd 100644 --- a/src/tui/tui.test.ts +++ b/src/tui/tui.test.ts @@ -6,7 +6,6 @@ import { MAX_TIMER_TIMEOUT_MS } from "../infra/parse-finite-number.js"; import { MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE } from "../shared/assistant-error-format.js"; import { withEnv } from "../test-utils/env.js"; import { getSlashCommands, parseCommand } from "./commands.js"; -import { canSubmitTuiChatMessage } from "./tui-submit.js"; import { createBackspaceDeduper, createDeferredTuiFinish, @@ -126,73 +125,6 @@ describe("tui slash commands", () => { }); }); -describe("canSubmitTuiChatMessage", () => { - it("allows submit when no run registration is pending", () => { - expect(canSubmitTuiChatMessage({})).toBe(true); - }); - - it("allows submit while a run is active so the backend owns queue policy", () => { - expect( - canSubmitTuiChatMessage({ - activeChatRunId: "run-active", - }), - ).toBe(true); - }); - - it("blocks message submit while disconnected so the editor preserves the draft", () => { - expect( - canSubmitTuiChatMessage({ - isConnected: false, - message: "send after reconnect", - }), - ).toBe(false); - }); - - it("allows stop text while a run is active", () => { - expect( - canSubmitTuiChatMessage({ - activeChatRunId: "run-active", - message: "please stop", - }), - ).toBe(true); - }); - - it("allows stop text while a queued run is pending", () => { - expect( - canSubmitTuiChatMessage({ - activeChatRunId: "run-active", - pendingChatRunId: "run-queued", - message: "please stop", - }), - ).toBe(true); - }); - - it("blocks submits with pending optimistic state", () => { - expect( - canSubmitTuiChatMessage({ - pendingOptimisticUserMessage: true, - }), - ).toBe(false); - }); - - it("blocks submits with a pending chat run id", () => { - expect( - canSubmitTuiChatMessage({ - pendingChatRunId: "run-pending", - }), - ).toBe(false); - }); - - it("blocks submit while optimistic state is pending during an active run", () => { - expect( - canSubmitTuiChatMessage({ - activeChatRunId: "run-active", - pendingOptimisticUserMessage: true, - }), - ).toBe(false); - }); -}); - describe("isTuiBusyActivityStatus", () => { it("treats finishing context as a visible busy status", () => { expect(isTuiBusyActivityStatus("finishing context")).toBe(true); diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 0d08d31976ea..77db8ede28ef 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -61,7 +61,12 @@ import { createTuiPluginApprovalController } from "./tui-plugin-approvals.js"; import { createSessionActions } from "./tui-session-actions.js"; import { TUI_SESSION_LOOKUP_LIMIT } from "./tui-session-list-policy.js"; import { - canSubmitTuiChatMessage, + disconnectedTuiChatSubmitMessage, + resolveTuiChatSubmitAdmission, + type TuiChatSubmitAdmission, + type TuiPendingSubmit, +} from "./tui-submit-state.js"; +import { createEditorSubmitHandler, createSubmitBurstCoalescer, shouldEnableWindowsGitBashPasteFallback, @@ -545,9 +550,7 @@ export async function runTui(opts: RunTuiOptions): Promise { let rememberedSessionApplied = false; let currentSessionId: string | null = null; let activeChatRunId: string | null = null; - let pendingOptimisticUserMessage = false; - let pendingChatRunId: string | null = null; - let pendingSubmitDraft: { runId: string; text: string } | null = null; + let pendingSubmit: TuiPendingSubmit | null = null; let historyLoaded = false; let isConnected = false; let wasDisconnected = false; @@ -630,23 +633,11 @@ export async function runTui(opts: RunTuiOptions): Promise { set activeChatRunId(value) { activeChatRunId = value; }, - get pendingOptimisticUserMessage() { - return pendingOptimisticUserMessage; + get pendingSubmit() { + return pendingSubmit; }, - set pendingOptimisticUserMessage(value) { - pendingOptimisticUserMessage = value; - }, - get pendingChatRunId() { - return pendingChatRunId; - }, - set pendingChatRunId(value) { - pendingChatRunId = value ?? null; - }, - get pendingSubmitDraft() { - return pendingSubmitDraft; - }, - set pendingSubmitDraft(value) { - pendingSubmitDraft = value ?? null; + set pendingSubmit(value) { + pendingSubmit = value; }, get historyLoaded() { return historyLoaded; @@ -1448,23 +1439,21 @@ export async function runTui(opts: RunTuiOptions): Promise { closeOverlay, }); updateAutocompleteProvider(); - const canSubmitChatMessage = (message: string) => - canSubmitTuiChatMessage({ + const admitChatMessage = (message: string) => + resolveTuiChatSubmitAdmission({ isConnected: state.isConnected, activeChatRunId: state.activeChatRunId, - pendingChatRunId: state.pendingChatRunId, - pendingOptimisticUserMessage: state.pendingOptimisticUserMessage, + pendingSubmit: state.pendingSubmit, message, }); - const notifyBlockedChatSubmit = () => { - if (state.isConnected) { + const notifyBlockedChatSubmit = ( + _message: string, + reason: Exclude, + ) => { + if (reason === "pending") { addBlockedChatSubmitNotice(chatLog); } else { - chatLog.addSystem( - opts.local - ? "local runtime not ready — message not sent" - : "not connected to gateway — message not sent", - ); + chatLog.addSystem(disconnectedTuiChatSubmitMessage(isLocalMode)); setActivityStatus("disconnected"); } tui.requestRender(); @@ -1480,7 +1469,7 @@ export async function runTui(opts: RunTuiOptions): Promise { sendMessage, handleBangLine: runLocalShellLine, onSubmitError: notifySubmitError, - canSubmitMessage: canSubmitChatMessage, + admitMessage: admitChatMessage, onBlockedMessageSubmit: notifyBlockedChatSubmit, }); editor.onSubmit = createSubmitBurstCoalescer({