refactor(tui): centralize pending submission lifecycle (#107242)

* refactor(tui): centralize pending submit state

* fix(tui): keep submit state dependency leaf
This commit is contained in:
Peter Steinberger
2026-07-14 00:54:00 -07:00
committed by GitHub
parent f4b7a19624
commit b0104bd2cd
15 changed files with 615 additions and 393 deletions

View File

@@ -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<typeof vi.fn> & (() => Promise<void>);
@@ -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",
});

View File

@@ -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)}`);

View File

@@ -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>): 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,

View File

@@ -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") {

View File

@@ -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<string, GatewayScenario>;
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 }) => {

View File

@@ -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 () => {

View File

@@ -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");

View File

@@ -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",
);
});
});

105
src/tui/tui-submit-state.ts Normal file
View File

@@ -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";
}

View File

@@ -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,

View File

@@ -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> | void,
@@ -66,8 +27,11 @@ export function createEditorSubmitHandler(params: {
sendMessage: (value: string) => Promise<void> | void;
handleBangLine: (value: string) => Promise<void> | void;
onSubmitError: (action: TuiSubmitAction, error: unknown) => void;
canSubmitMessage?: (value: string) => boolean;
onBlockedMessageSubmit?: (value: string) => void;
admitMessage?: (value: string) => TuiChatSubmitAdmission;
onBlockedMessageSubmit?: (
value: string,
reason: Exclude<TuiChatSubmitAdmission, "allowed">,
) => 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;
}

View File

@@ -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;

View File

@@ -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");

View File

@@ -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);

View File

@@ -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<TuiResult> {
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<TuiResult> {
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<TuiResult> {
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<TuiChatSubmitAdmission, "allowed">,
) => {
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<TuiResult> {
sendMessage,
handleBangLine: runLocalShellLine,
onSubmitError: notifySubmitError,
canSubmitMessage: canSubmitChatMessage,
admitMessage: admitChatMessage,
onBlockedMessageSubmit: notifyBlockedChatSubmit,
});
editor.onSubmit = createSubmitBurstCoalescer({