mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-24 09:21:12 +00:00
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:
committed by
GitHub
parent
f4b7a19624
commit
b0104bd2cd
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
179
src/tui/tui-submit-state.test.ts
Normal file
179
src/tui/tui-submit-state.test.ts
Normal 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
105
src/tui/tui-submit-state.ts
Normal 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";
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user