fix(tui): keep reset transitions isolated from stale input (#116873)

* fix(tui): fence reset session transitions

* fix(tui): explain blocked reset input

* test(tui): isolate reset transition PTY proof

* fix(tui): preserve input across session transitions

* test(tui): synchronize preserved reset draft proof

* test(tui): control reset submit timing

* test(tui): close reset PTY with EOF

* test(tui): exit reset PTY without submit buffering

* test(tui): synchronize reset PTY shutdown

* test(tui): widen reset PTY exit window

* test(tui): stabilize reset transition PTY proof

* test(tui): keep reset PTY harness bounded

* test(tui): split reset PTY fixture wiring
This commit is contained in:
Peter Steinberger
2026-07-31 14:18:34 -07:00
committed by GitHub
parent f47542c592
commit 262f26355a
17 changed files with 675 additions and 129 deletions

View File

@@ -22,6 +22,7 @@ import {
getPendingSubmitDraft,
type TuiPendingSubmit,
} from "./tui-submit-state.js";
import { createEditorSubmitHandler, createSubmitBurstCoalescer } from "./tui-submit.js";
import type { SessionInfo } from "./tui-types.js";
type LoadHistoryMock = ReturnType<typeof vi.fn> & (() => Promise<void>);
@@ -200,7 +201,14 @@ function createHarness(params?: {
sessionInfo: params?.sessionInfo ?? {},
};
const { handleCommand, sendMessage, openSessionSelector } = createCommandHandlers({
const {
handleCommand,
sendMessage,
captureMessageAdmission,
resolveMessageAdmission,
reportBlockedMessageSubmit,
openSessionSelector,
} = createCommandHandlers({
client: {
sendChat,
getGatewayStatus,
@@ -249,6 +257,9 @@ function createHarness(params?: {
return {
handleCommand,
sendMessage,
captureMessageAdmission,
resolveMessageAdmission,
reportBlockedMessageSubmit,
getGatewayStatus,
listSessions,
listModels,
@@ -1517,16 +1528,21 @@ describe("tui command handlers", () => {
resolveCreate = resolve;
}),
);
const { handleCommand, sendMessage, sendChat, addSystem } = createHarness({ createSession });
const { handleCommand, sendMessage, resolveMessageAdmission, sendChat, addSystem } =
createHarness({ createSession });
const creating = handleCommand("/new");
await Promise.resolve();
expect(resolveMessageAdmission("must not reach parent")).toEqual({
status: "blocked",
reason: "session-transition",
command: "new",
});
await sendMessage("must not reach parent");
await handleCommand("/new");
expect(sendChat).not.toHaveBeenCalled();
expect(createSession).toHaveBeenCalledTimes(1);
expect(addSystem).toHaveBeenCalledWith("session change in progress; message not sent");
expect(addSystem).toHaveBeenCalledWith("session change in progress; wait for /new to finish");
if (!resolveCreate) {
@@ -1536,6 +1552,123 @@ describe("tui command handlers", () => {
await creating;
});
it("serializes input until /reset commits the replacement session", async () => {
const deferred = createDeferred<{
ok: true;
key: string;
entry: { sessionId: string };
}>();
const resetSession = vi.fn(() => deferred.promise);
const applySessionMutationResult = vi.fn().mockReturnValue(true);
const { handleCommand, sendMessage, resolveMessageAdmission, sendChat, addSystem } =
createHarness({
resetSession,
applySessionMutationResult,
});
const resetting = handleCommand("/reset");
await vi.waitFor(() => expect(resetSession).toHaveBeenCalledOnce());
expect(resolveMessageAdmission("must not reach the resetting session")).toEqual({
status: "blocked",
reason: "session-transition",
command: "reset",
});
await sendMessage("must not reach the resetting session");
await handleCommand("/reset");
expect(sendChat).not.toHaveBeenCalled();
expect(resetSession).toHaveBeenCalledOnce();
expect(addSystem).toHaveBeenCalledWith("session change in progress; wait for /reset to finish");
deferred.resolve({
ok: true,
key: "agent:main:main",
entry: { sessionId: "session-after-reset" },
});
await resetting;
});
it.each([
{ command: "new", capture: "before" },
{ command: "new", capture: "during" },
{ command: "reset", capture: "before" },
{ command: "reset", capture: "during" },
] as const)(
"keeps a submit captured $capture /$command blocked across the transition epoch",
async ({ command, capture }) => {
vi.useFakeTimers();
try {
const transitionResult = createDeferred<{
ok: true;
key: string;
entry: { sessionId: string };
}>();
const createSession = vi.fn(() => transitionResult.promise);
const resetSession = vi.fn(() => transitionResult.promise);
const applySessionMutationResult = vi.fn().mockReturnValue(true);
const harness = createHarness({
createSession,
resetSession,
applySessionMutationResult,
});
const editor = {
getText: vi.fn(() => ""),
setText: vi.fn(),
addToHistory: vi.fn(),
};
const submit = createEditorSubmitHandler({
editor,
handleCommand: harness.handleCommand,
sendMessage: harness.sendMessage,
handleBangLine: vi.fn(),
onSubmitError: vi.fn(),
admitMessage: harness.resolveMessageAdmission,
onBlockedMessageSubmit: harness.reportBlockedMessageSubmit,
});
const bufferedSubmit = createSubmitBurstCoalescer({
submit,
captureSnapshot: harness.captureMessageAdmission,
enabled: true,
burstWindowMs: 50,
});
if (capture === "before") {
bufferedSubmit("must remain in the editor");
}
const transitioning = harness.handleCommand(`/${command}`);
await Promise.resolve();
expect(command === "new" ? createSession : resetSession).toHaveBeenCalledOnce();
if (capture === "during") {
bufferedSubmit("must remain in the editor");
}
transitionResult.resolve({
ok: true,
key: command === "new" ? "agent:main:tui-next" : "agent:main:main",
entry: { sessionId: `session-after-${command}` },
});
await transitioning;
expect(harness.captureMessageAdmission()).toEqual({
sessionTransition: null,
sessionTransitionEpoch: 2,
});
expect(harness.resolveMessageAdmission("live admission is clear")).toEqual({
status: "allowed",
});
vi.advanceTimersByTime(50);
expect(harness.sendChat).not.toHaveBeenCalled();
expect(editor.setText).toHaveBeenCalledWith("must remain in the editor");
expect(harness.addSystem).toHaveBeenCalledWith(
`session change in progress; wait for /${command} to finish`,
);
} finally {
vi.useRealTimers();
}
},
);
it("reloads history after /reset when the backend does not return a session entry", async () => {
const loadHistory = vi.fn().mockResolvedValue(undefined);
const applySessionMutationResult = vi.fn().mockReturnValue(false);

View File

@@ -50,6 +50,10 @@ import {
clearPendingSubmit,
disconnectedTuiChatSubmitMessage,
hasPendingSubmit,
resolveTuiChatSubmitAdmission,
type TuiChatSubmitAdmission,
type TuiChatSubmitBlock,
type TuiChatSubmitSnapshot,
} from "./tui-submit-state.js";
import type {
AgentSummary,
@@ -165,7 +169,71 @@ export function createCommandHandlers(context: CommandHandlerContext) {
runAuthFlow,
requestExit,
} = context;
let sessionCreationInFlight = false;
let sessionTransition = {
active: null as "new" | "reset" | null,
boundary: null as "new" | "reset" | null,
epoch: 0,
};
// Hold one owner through the full identity transition so later input cannot
// target the session being retired while create/reset awaits the backend.
const beginSessionTransition = (command: "new" | "reset") => {
const epoch = sessionTransition.epoch + 1;
sessionTransition = { active: command, boundary: command, epoch };
return () => {
if (sessionTransition.active === command && sessionTransition.epoch === epoch) {
sessionTransition = { active: null, boundary: command, epoch: epoch + 1 };
}
};
};
const captureMessageAdmission = (): TuiChatSubmitSnapshot => ({
sessionTransition: sessionTransition.active,
sessionTransitionEpoch: sessionTransition.epoch,
});
const resolveMessageAdmission = (
message: string,
snapshot?: TuiChatSubmitSnapshot,
): TuiChatSubmitAdmission => {
const admission = resolveTuiChatSubmitAdmission({
isConnected: state.isConnected,
activeChatRunId: state.activeChatRunId,
pendingSubmit: state.pendingSubmit,
message,
});
if (admission.status === "blocked" && admission.reason === "disconnected") {
return admission;
}
const transitionCommand = snapshot
? (snapshot.sessionTransition ??
(snapshot.sessionTransitionEpoch !== sessionTransition.epoch
? (sessionTransition.active ?? sessionTransition.boundary)
: null))
: sessionTransition.active;
if (transitionCommand) {
return {
status: "blocked",
reason: "session-transition",
command: transitionCommand,
};
}
return admission.status === "blocked" && isBtwCommand(message)
? { status: "allowed" }
: admission;
};
const reportBlockedMessageSubmit = (_message: string, admission: TuiChatSubmitBlock) => {
if (admission.reason === "pending") {
addBlockedChatSubmitNotice(chatLog);
} else if (admission.reason === "disconnected") {
chatLog.addSystem(disconnectedTuiChatSubmitMessage(opts.local === true));
setActivityStatus("disconnected");
} else {
chatLog.addSystem(`session change in progress; wait for /${admission.command} to finish`);
}
tui.requestRender();
};
const addUnsupportedLocalCommand = (name: string) => {
chatLog.addSystem(`/${name} is not available in local embedded mode; message not sent`);
@@ -434,8 +502,10 @@ export function createCommandHandlers(context: CommandHandlerContext) {
if (!name) {
return;
}
if (sessionCreationInFlight && name !== "exit" && name !== "quit") {
chatLog.addSystem("session change in progress; wait for /new to finish");
if (sessionTransition.active && name !== "exit" && name !== "quit") {
chatLog.addSystem(
`session change in progress; wait for /${sessionTransition.active} to finish`,
);
tui.requestRender();
return;
}
@@ -797,11 +867,11 @@ export function createCommandHandlers(context: CommandHandlerContext) {
}
break;
}
case "new":
case "new": {
if (rejectUnsafeSessionRollover("new")) {
break;
}
sessionCreationInFlight = true;
const finishSessionTransition = beginSessionTransition("new");
try {
// Clear token counts immediately to avoid stale display (#1523)
state.sessionInfo.inputTokens = null;
@@ -825,15 +895,17 @@ export function createCommandHandlers(context: CommandHandlerContext) {
} catch (err) {
chatLog.addSystem(`new session failed: ${formatTuiErrorMessage(err)}`);
} finally {
sessionCreationInFlight = false;
finishSessionTransition();
}
break;
}
case "reset": {
if (rejectUnsafeSessionRollover("reset")) {
break;
}
const resetSelection = captureSessionSelection();
let resetResultSelection = resetSelection;
const finishSessionTransition = beginSessionTransition("reset");
try {
// Clear token counts immediately to avoid stale display (#1523)
state.sessionInfo.inputTokens = null;
@@ -866,6 +938,8 @@ export function createCommandHandlers(context: CommandHandlerContext) {
return;
}
chatLog.addSystem(`reset failed: ${formatTuiErrorMessage(err)}`);
} finally {
finishSessionTransition();
}
break;
}
@@ -897,15 +971,9 @@ export function createCommandHandlers(context: CommandHandlerContext) {
};
const sendMessage = async (text: string) => {
if (!state.isConnected) {
chatLog.addSystem(disconnectedTuiChatSubmitMessage(opts.local === true));
setActivityStatus("disconnected");
tui.requestRender();
return;
}
if (sessionCreationInFlight) {
chatLog.addSystem("session change in progress; message not sent");
tui.requestRender();
const admission = resolveMessageAdmission(text);
if (admission.status === "blocked") {
reportBlockedMessageSubmit(text, admission);
return;
}
const isBtw = isBtwCommand(text);
@@ -919,11 +987,6 @@ 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 && hasPendingSubmit(state)) {
addBlockedChatSubmitNotice(chatLog);
tui.requestRender();
return;
}
const runId = randomUUID();
const sendSelection = captureSessionSelection();
const sendSessionId = state.currentSessionId;
@@ -1113,6 +1176,9 @@ export function createCommandHandlers(context: CommandHandlerContext) {
return {
handleCommand,
sendMessage,
captureMessageAdmission,
resolveMessageAdmission,
reportBlockedMessageSubmit,
openModelSelector,
openAgentSelector,
openSessionSelector,

View File

@@ -3,6 +3,7 @@ import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { TUI_PTY_GAP_HISTORY_FIXTURE_SCRIPT } from "./tui-pty-gap-fixture-test-support.js";
import { TUI_PTY_RESET_FIXTURE } from "./tui-pty-reset-fixture-test-support.js";
import { TUI_PTY_SESSION_SUBSCRIPTION_FIXTURE_SCRIPT } from "./tui-pty-subscription-fixture-test-support.js";
import { sleep, type PtyRun } from "./tui-pty-test-support.js";
@@ -22,7 +23,7 @@ export async function writeTuiPtyFixtureScript(dir: string) {
await writeFile(
scriptPath,
`
import { appendFileSync } from "node:fs";
import { appendFileSync, existsSync } from "node:fs";
import { buildEmbeddedRunPayloads } from ${JSON.stringify(payloadsModuleUrl)};
import { getReplyPayloadMetadata } from ${JSON.stringify(replyPayloadModuleUrl)};
import { normalizeReplyPayloadsForDelivery } from ${JSON.stringify(outboundPayloadsModuleUrl)};
@@ -495,10 +496,7 @@ export async function writeTuiPtyFixtureScript(dir: string) {
return { ok: true, key, entry: { ...sessionEntry(key), sessionId: "created-session" } };
}
async resetSession(key: string, reason?: "new" | "reset") {
record("resetSession", { key, reason });
return {};
}
${TUI_PTY_RESET_FIXTURE.methods}
async getGatewayStatus() {
record("getGatewayStatus");
@@ -584,6 +582,7 @@ export async function writeTuiPtyFixtureScript(dir: string) {
message: initialMessage,
historyLimit: 5,
title: "openclaw tui pty fixture",
${TUI_PTY_RESET_FIXTURE.options}
});
}

View File

@@ -872,6 +872,7 @@ describe("TUI PTY real backends", () => {
},
waitForOutput: async () => output,
waitForExit: async () => ({ exitCode: 0, signal: 0 }),
forceKill: async () => {},
dispose: async () => {},
} satisfies PtyRun;

View File

@@ -0,0 +1,31 @@
// Generated fake-backend reset behavior for real PTY lifecycle proof.
export const TUI_PTY_RESET_FIXTURE = {
methods: `
async resetSession(key: string, reason?: "new" | "reset") {
record("resetSession", { key, reason });
const releasePath = process.env.OPENCLAW_TUI_PTY_RESET_RELEASE_PATH;
if (releasePath) {
while (!existsSync(releasePath)) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
return {
ok: true,
key,
entry: {
...sessionEntry(key),
displayName: "Reset session after",
sessionId: "reset-session-after",
},
};
}
return {};
}
`,
options: `
submitBurstWindowMs: (() => {
const value = Number(process.env.OPENCLAW_TUI_PTY_SUBMIT_BURST_WINDOW_MS ?? 0);
return value > 0 ? value : undefined;
})(),
onSubmitBurstCaptured: (value) => record("submitBurstCaptured", { value }),
`,
};

View File

@@ -4,10 +4,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const nodePtyMocks = vi.hoisted(() => ({
spawn: vi.fn(),
}));
const processTreeMocks = vi.hoisted(() => ({
signalProcessTree: vi.fn((_pid: number, _signal: string, opts?: { onComplete?: () => void }) =>
opts?.onComplete?.(),
),
}));
vi.mock("@lydell/node-pty", () => ({
spawn: nodePtyMocks.spawn,
}));
vi.mock("../process/kill-tree.js", () => ({
signalProcessTree: processTreeMocks.signalProcessTree,
}));
import { startPty } from "./tui-pty-test-support.js";
@@ -23,6 +31,7 @@ function createMockPty(overrides: Partial<IPty> = {}): IPty {
describe("TUI PTY test support", () => {
beforeEach(() => {
nodePtyMocks.spawn.mockReset();
processTreeMocks.signalProcessTree.mockClear();
});
it("applies fixture-specific terminal dimensions", () => {
@@ -198,4 +207,45 @@ describe("TUI PTY test support", () => {
expect(order).toEqual(["data-dispose", "kill", "exit", "exit-dispose"]);
expect(kill).toHaveBeenCalledWith("SIGTERM");
});
it("force-kills the PTY and waits for its exit event", async () => {
const order: string[] = [];
let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined;
processTreeMocks.signalProcessTree.mockImplementation((_pid, _signal, opts) => {
order.push("kill");
opts?.onComplete?.();
});
nodePtyMocks.spawn.mockReturnValue(
createMockPty({
pid: 4242,
onData: vi.fn(() => ({ dispose: () => order.push("data-dispose") })),
onExit: vi.fn((listener: typeof exitListener) => {
exitListener = listener;
return { dispose: () => order.push("exit-dispose") };
}),
}),
);
const run = startPty("node", [], {
cwd: process.cwd(),
env: {},
exitTimeoutMs: 1_000,
outputTimeoutMs: 1_000,
});
const forcedExit = run.forceKill();
expect(run.forceKill()).toBe(forcedExit);
expect(order).toEqual(["kill"]);
expect(processTreeMocks.signalProcessTree).toHaveBeenCalledWith(4242, "SIGKILL", {
onComplete: expect.any(Function),
});
order.push("exit");
exitListener?.({ exitCode: 0, signal: 9 });
await forcedExit;
expect(run.dispose()).toBe(forcedExit);
expect(order).toEqual(["kill", "exit", "data-dispose", "exit-dispose"]);
expect(processTreeMocks.signalProcessTree).toHaveBeenCalledTimes(1);
});
});

View File

@@ -4,6 +4,7 @@ import * as nodePty from "@lydell/node-pty";
import type { IPty } from "@lydell/node-pty";
import { AnsiSequenceStripper } from "../../packages/terminal-core/src/ansi-sequences.js";
import { toErrorObject } from "../infra/errors.js";
import { signalProcessTree } from "../process/kill-tree.js";
// Shared PTY harness utilities for fake-backend and local TUI smoke tests.
type PtyExitEvent = Parameters<Parameters<IPty["onExit"]>[0]>[0];
@@ -15,6 +16,8 @@ export type PtyRun = {
write: (data: string, opts?: { delay?: boolean }) => Promise<void>;
waitForOutput: (needle: string, timeoutMs?: number) => Promise<string>;
waitForExit: (timeoutMs?: number) => Promise<PtyExitEvent>;
/** Ends behavior-complete PTY scenarios without exercising graceful TUI shutdown. */
forceKill: () => Promise<void>;
dispose: () => Promise<void>;
};
@@ -146,6 +149,7 @@ export function startPty(
onTimeout: () => new Error(`timed out waiting for PTY exit\n${output}`),
});
let forceKillPromise: Promise<void> | undefined;
let disposePromise: Promise<void> | undefined;
const run: PtyRun = {
@@ -169,7 +173,29 @@ export function startPty(
onTimeout: () => new Error(`timed out waiting for ${JSON.stringify(needle)}\n${output}`),
}),
waitForExit,
forceKill: () => {
forceKillPromise ??= (async () => {
try {
if (!exitEvent) {
// The PTY owns a process group; killing only its shell can leave the TUI child alive.
await new Promise<void>((resolve) => {
signalProcessTree(pty.pid, "SIGKILL", { onComplete: resolve });
});
// Native PTY backends do not consistently emit onExit after a forced tree kill.
await sleep(PTY_EXIT_SETTLE_MS);
exitEvent ??= { exitCode: 137, signal: 9 };
}
} finally {
dataSubscription.dispose();
exitSubscription.dispose();
}
})();
return forceKillPromise;
},
dispose: () => {
if (forceKillPromise) {
return forceKillPromise;
}
disposePromise ??= (async () => {
dataSubscription.dispose();
try {

View File

@@ -0,0 +1,97 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import {
objectFieldEquals,
readFixtureLog,
waitForFixtureLogEntry,
writeTuiPtyFixtureScript,
} from "./tui-pty-harness-fixture-test-support.js";
import { startPty } from "./tui-pty-test-support.js";
const STARTUP_TIMEOUT_MS = 20_000;
const OUTPUT_TIMEOUT_MS = 2_000;
const EXIT_TIMEOUT_MS = 8_000;
const TEST_TIMEOUT_MS = 25_000;
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
describe("TUI reset transition PTY", () => {
it(
"preserves overlapping input while /reset owns the terminal session transition",
async () => {
const tempDir = tempDirs.make("openclaw-tui-reset-pty-");
const scriptPath = await writeTuiPtyFixtureScript(tempDir);
const logPath = path.join(tempDir, "fixture-log.jsonl");
const resetReleasePath = path.join(tempDir, "release-reset-session");
const run = startPty(process.execPath, ["--import", "tsx", scriptPath], {
cwd: process.cwd(),
env: {
OPENCLAW_THEME: "dark",
OPENCLAW_TUI_PTY_LOG_PATH: logPath,
OPENCLAW_TUI_PTY_RESET_RELEASE_PATH: resetReleasePath,
OPENCLAW_TUI_PTY_SUBMIT_BURST_WINDOW_MS: "1000",
OPENCLAW_TUI_PTY_TYPE_CHUNK_SIZE: "1",
OPENCLAW_TUI_PTY_TYPE_DELAY_MS: "2",
TERM_PROGRAM: "Apple_Terminal",
NO_COLOR: undefined,
},
exitTimeoutMs: EXIT_TIMEOUT_MS,
outputTimeoutMs: OUTPUT_TIMEOUT_MS,
});
try {
const waitForLogEntry = async (predicate: Parameters<typeof waitForFixtureLogEntry>[1]) =>
await waitForFixtureLogEntry(logPath, predicate, OUTPUT_TIMEOUT_MS, run.output);
await run.waitForOutput("local ready", STARTUP_TIMEOUT_MS);
await run.write("/reset\r", { delay: false });
await waitForLogEntry(
(entry) => entry.method === "resetSession" && objectFieldEquals(entry, "reason", "reset"),
);
await run.write("overlap during reset\r");
await waitForLogEntry(
(entry) =>
entry.method === "submitBurstCaptured" &&
objectFieldEquals(entry, "value", "overlap during reset"),
);
await run.write("newer suffix");
// Release before the controlled paste coalescer flushes. Admission must use
// the transition snapshot captured when Enter arrived, not live state.
await writeFile(resetReleasePath, "released\n", "utf8");
await run.waitForOutput("session main (Reset session after)");
await run.waitForOutput("overlap during reset\nnewer suffix", 7_000);
await run.write("\r", { delay: false });
await waitForLogEntry(
(entry) =>
entry.method === "sendChat" &&
objectFieldEquals(entry, "message", "overlap during reset\nnewer suffix"),
);
await run.waitForOutput("PTY_RESPONSE: overlap during reset", 7_000);
const sends = (await readFixtureLog(logPath)).filter(
(entry) => entry.method === "sendChat",
);
expect(sends).toEqual([
expect.objectContaining({
payload: expect.objectContaining({ message: "overlap during reset\nnewer suffix" }),
}),
]);
console.info(
"[behavior-evidence] tui-reset-transition",
JSON.stringify({
terminal: "real PTY",
overlappingInputPreserved: true,
resetCompleted: true,
preservedInputDelivered: true,
}),
);
} finally {
await run.forceKill();
await run.dispose();
}
},
TEST_TIMEOUT_MS,
);
});

View File

@@ -41,9 +41,10 @@ describe("tui session actions", () => {
const createHistoryChatLog = () => {
const addSystem = vi.fn();
const addUser = vi.fn();
const clearAll = vi.fn();
const chatLog = {
addSystem,
clearAll: vi.fn(),
clearAll,
clearPendingUsers: vi.fn(),
addUser,
addLiveUser: vi.fn(),
@@ -52,7 +53,7 @@ describe("tui session actions", () => {
updateAssistant: vi.fn(),
startTool: vi.fn(),
} as unknown as ChatLog;
return { chatLog, addSystem, addUser };
return { chatLog, addSystem, addUser, clearAll };
};
const createBaseState = (overrides: Partial<TuiStateAccess> = {}): TuiStateAccess => ({
@@ -1629,6 +1630,77 @@ describe("tui session actions", () => {
expect(addSystem).toHaveBeenCalledWith("session agent:main:new");
});
it("fences pre-reset history and session-info reads when reset commits", async () => {
const history = createDeferred<unknown>();
const sessionInfo = createDeferred<unknown>();
const loadHistory = vi.fn(() => history.promise);
const listSessions = vi.fn(() => sessionInfo.promise);
const { chatLog, addUser, clearAll } = createHistoryChatLog();
const state = createBaseState({
currentSessionId: "session-before-reset",
sessionGeneration: 4,
sessionInfo: { model: "model-before-reset", updatedAt: 10 },
});
const {
applySessionMutationResult,
loadHistory: readHistory,
refreshSessionInfo,
} = createTestSessionActions({
client: { loadHistory, listSessions } as unknown as TuiBackend,
chatLog,
state,
});
const staleHistory = readHistory();
const staleSessionInfo = refreshSessionInfo();
await vi.waitFor(() => {
expect(loadHistory).toHaveBeenCalledOnce();
expect(listSessions).toHaveBeenCalledOnce();
});
expect(
applySessionMutationResult({
ok: true,
key: "agent:main:main",
entry: {
sessionId: "session-after-reset",
model: "model-after-reset",
updatedAt: 20,
},
}),
).toBe(true);
history.resolve({
sessionInfo: {
key: "agent:main:main",
sessionId: "session-before-reset",
model: "stale-history-model",
updatedAt: 10,
},
messages: [{ role: "user", content: "before reset" }],
});
sessionInfo.resolve({
defaults: {},
sessions: [
{
key: "agent:main:main",
sessionId: "session-before-reset",
model: "stale-session-info-model",
updatedAt: 10,
},
],
});
await expect(staleHistory).resolves.toEqual({ loaded: false });
await staleSessionInfo;
expect(state.sessionGeneration).toBe(5);
expect(state.currentSessionId).toBe("session-after-reset");
expect(state.sessionInfo.model).toBe("model-after-reset");
expect(addUser).not.toHaveBeenCalled();
expect(clearAll).toHaveBeenCalledOnce();
});
it("does not clear the selected session for another session's reset result", () => {
const addSystem = vi.fn();
const clearAll = vi.fn();

View File

@@ -431,6 +431,8 @@ export function createSessionActions(context: SessionActionContext) {
if (!result?.entry || !isCurrentSessionSelection(requestSelection)) {
return false;
}
// Invalidate same-key history/session-info readers before adopting the replacement epoch.
historyLoadGeneration += 1;
state.sessionGeneration = (state.sessionGeneration ?? 0) + 1;
reduceTuiSessionProjection(state, {
type: "sessionReset",

View File

@@ -22,7 +22,7 @@ describe("resolveTuiChatSubmitAdmission", () => {
activeChatRunId: null,
pendingSubmit: null,
message: "hello",
expected: "allowed",
expected: { status: "allowed" },
},
{
name: "active run",
@@ -30,7 +30,7 @@ describe("resolveTuiChatSubmitAdmission", () => {
activeChatRunId: "run-active",
pendingSubmit: null,
message: "follow up",
expected: "allowed",
expected: { status: "allowed" },
},
{
name: "disconnected",
@@ -38,7 +38,7 @@ describe("resolveTuiChatSubmitAdmission", () => {
activeChatRunId: null,
pendingSubmit: null,
message: "send after reconnect",
expected: "disconnected",
expected: { status: "blocked", reason: "disconnected" },
},
{
name: "sending",
@@ -46,7 +46,7 @@ describe("resolveTuiChatSubmitAdmission", () => {
activeChatRunId: null,
pendingSubmit: { phase: "sending", runId: "run-send", draftText: "hello" },
message: "another",
expected: "pending",
expected: { status: "blocked", reason: "pending" },
},
{
name: "accepted",
@@ -54,7 +54,7 @@ describe("resolveTuiChatSubmitAdmission", () => {
activeChatRunId: null,
pendingSubmit: { phase: "accepted", runId: "run-pending", draftText: "hello" },
message: "another",
expected: "pending",
expected: { status: "blocked", reason: "pending" },
},
{
name: "stop active run",
@@ -62,7 +62,7 @@ describe("resolveTuiChatSubmitAdmission", () => {
activeChatRunId: "run-active",
pendingSubmit: null,
message: "please stop",
expected: "allowed",
expected: { status: "allowed" },
},
{
name: "stop accepted run",
@@ -70,10 +70,10 @@ describe("resolveTuiChatSubmitAdmission", () => {
activeChatRunId: null,
pendingSubmit: { phase: "accepted", runId: "run-pending", draftText: null },
message: "please stop",
expected: "allowed",
expected: { status: "allowed" },
},
] as const)("returns $expected while $name", ({ expected, ...params }) => {
expect(resolveTuiChatSubmitAdmission(params)).toBe(expected);
] as const)("resolves admission while $name", ({ expected, ...params }) => {
expect(resolveTuiChatSubmitAdmission(params)).toEqual(expected);
});
});

View File

@@ -4,7 +4,19 @@ export type TuiPendingSubmit =
| { phase: "sending"; runId: string; draftText: string }
| { phase: "accepted"; runId: string; draftText: string | null };
export type TuiChatSubmitAdmission = "allowed" | "disconnected" | "pending";
export type TuiChatSubmitAdmission =
| { status: "allowed" }
| { status: "blocked"; reason: "disconnected" }
| { status: "blocked"; reason: "pending" }
| { status: "blocked"; reason: "session-transition"; command: "new" | "reset" };
export type TuiChatSubmitBlock = Exclude<TuiChatSubmitAdmission, { status: "allowed" }>;
/** Session-transition state captured when Enter reached the submit coalescer. */
export type TuiChatSubmitSnapshot = {
sessionTransition: "new" | "reset" | null;
sessionTransitionEpoch: number;
};
type PendingSubmitState = { pendingSubmit: TuiPendingSubmit | null };
@@ -87,15 +99,15 @@ export function resolveTuiChatSubmitAdmission(params: {
message: string;
}): TuiChatSubmitAdmission {
if (!params.isConnected) {
return "disconnected";
return { status: "blocked", reason: "disconnected" };
}
if (
isChatStopCommandText(params.message) &&
(params.activeChatRunId || params.pendingSubmit?.phase === "accepted")
) {
return "allowed";
return { status: "allowed" };
}
return params.pendingSubmit ? "pending" : "allowed";
return params.pendingSubmit ? { status: "blocked", reason: "pending" } : { status: "allowed" };
}
export function disconnectedTuiChatSubmitMessage(local: boolean): string {

View File

@@ -31,7 +31,7 @@ export function createSubmitHarness(params?: {
const handleCommand = vi.fn();
const sendMessage = vi.fn();
const handleBangLine = vi.fn();
const admitMessage = vi.fn(params?.admitMessage ?? (() => "allowed" as const));
const admitMessage = vi.fn(params?.admitMessage ?? (() => ({ status: "allowed" }) as const));
const onBlockedMessageSubmit = vi.fn();
const onSubmitError = vi.fn();
const onSubmit = createEditorSubmitHandler({

View File

@@ -1,6 +1,10 @@
// Handles TUI input submission and command dispatch.
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import type { TuiChatSubmitAdmission } from "./tui-submit-state.js";
import type {
TuiChatSubmitAdmission,
TuiChatSubmitBlock,
TuiChatSubmitSnapshot,
} from "./tui-submit-state.js";
export type TuiSubmitAction = "local shell" | "command" | "message";
@@ -28,11 +32,8 @@ export function createEditorSubmitHandler(params: {
sendMessage: (value: string) => Promise<void> | void;
handleBangLine: (value: string) => Promise<void> | void;
onSubmitError: (action: TuiSubmitAction, error: unknown) => void;
admitMessage?: (value: string) => TuiChatSubmitAdmission;
onBlockedMessageSubmit?: (
value: string,
reason: Exclude<TuiChatSubmitAdmission, "allowed">,
) => void;
admitMessage?: (value: string, snapshot?: TuiChatSubmitSnapshot) => TuiChatSubmitAdmission;
onBlockedMessageSubmit?: (value: string, admission: TuiChatSubmitBlock) => void;
}) {
const clearSubmittedEditor = () => {
// pi-tui clears before onSubmit; a delayed paste flush must not erase a newer draft.
@@ -41,7 +42,14 @@ export function createEditorSubmitHandler(params: {
}
};
return (text: string) => {
const restoreBlockedEditor = (value: string) => {
// pi-tui clears before onSubmit. Preserve text typed while a buffered submit
// waited by replaying the blocked value before the newer editor-owned draft.
const newerDraft = params.editor.getText?.() ?? "";
params.editor.setText(newerDraft ? `${value}\n${newerDraft}` : value);
};
return (text: string, snapshot?: TuiChatSubmitSnapshot) => {
const raw = text;
const value = raw.trim();
const multiline = raw.includes("\n");
@@ -70,9 +78,11 @@ export function createEditorSubmitHandler(params: {
return;
}
const admission = params.admitMessage?.(value) ?? "allowed";
if (admission !== "allowed") {
params.editor.setText(value);
const admission: TuiChatSubmitAdmission = (snapshot
? params.admitMessage?.(value, snapshot)
: params.admitMessage?.(value)) ?? { status: "allowed" };
if (admission.status === "blocked") {
restoreBlockedEditor(value);
params.onBlockedMessageSubmit?.(value, admission);
return;
}
@@ -117,18 +127,20 @@ export function shouldEnableWindowsGitBashPasteFallback(params?: {
}
export function createSubmitBurstCoalescer(params: {
submit: (value: string) => void;
submit: (value: string, snapshot?: TuiChatSubmitSnapshot) => void;
captureSnapshot?: () => TuiChatSubmitSnapshot;
enabled: boolean;
burstWindowMs?: number;
now?: () => number;
setTimer?: typeof setTimeout;
clearTimer?: typeof clearTimeout;
onCapture?: (value: string, snapshot?: TuiChatSubmitSnapshot) => void;
}) {
const windowMs = Math.max(1, params.burstWindowMs ?? 50);
const now = params.now ?? (() => Date.now());
const setTimer = params.setTimer ?? setTimeout;
const clearTimer = params.clearTimer ?? clearTimeout;
let pending: string | null = null;
let pending: { value: string; snapshot?: TuiChatSubmitSnapshot } | null = null;
let pendingAt = 0;
let flushTimer: ReturnType<typeof setTimeout> | null = null;
@@ -140,15 +152,23 @@ export function createSubmitBurstCoalescer(params: {
flushTimer = null;
};
const submit = (value: string, snapshot?: TuiChatSubmitSnapshot) => {
if (snapshot) {
params.submit(value, snapshot);
} else {
params.submit(value);
}
};
const flushPending = () => {
if (pending === null) {
if (!pending) {
return;
}
const value = pending;
const { value, snapshot } = pending;
pending = null;
pendingAt = 0;
clearFlushTimer();
params.submit(value);
submit(value, snapshot);
};
const scheduleFlush = () => {
@@ -160,29 +180,34 @@ export function createSubmitBurstCoalescer(params: {
return (value: string) => {
if (!params.enabled) {
params.submit(value);
submit(value, params.captureSnapshot?.());
return;
}
if (value.includes("\n")) {
flushPending();
params.submit(value);
submit(value, params.captureSnapshot?.());
return;
}
const ts = now();
if (pending === null) {
pending = value;
const snapshot = params.captureSnapshot?.();
params.onCapture?.(value, snapshot);
if (!pending) {
pending = { value, ...(snapshot ? { snapshot } : {}) };
pendingAt = ts;
scheduleFlush();
return;
}
if (ts - pendingAt <= windowMs) {
pending = `${pending}\n${value}`;
pending = {
value: `${pending.value}\n${value}`,
...(pending.snapshot || snapshot ? { snapshot: pending.snapshot ?? snapshot } : {}),
};
pendingAt = ts;
scheduleFlush();
return;
}
flushPending();
pending = value;
pending = { value, ...(snapshot ? { snapshot } : {}) };
pendingAt = ts;
scheduleFlush();
};

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({
admitMessage: () => "pending",
admitMessage: () => ({ status: "blocked", reason: "pending" }),
});
onSubmit(" wait, use c++ instead ");
@@ -64,12 +64,17 @@ describe("createEditorSubmitHandler", () => {
expect(sendMessage).not.toHaveBeenCalled();
expect(handleCommand).not.toHaveBeenCalled();
expect(handleBangLine).not.toHaveBeenCalled();
expect(onBlockedMessageSubmit).toHaveBeenCalledWith("wait, use c++ instead", "pending");
expect(onBlockedMessageSubmit).toHaveBeenCalledWith("wait, use c++ instead", {
status: "blocked",
reason: "pending",
});
});
it("passes the submitted text to the busy gate", () => {
const admitMessage = vi.fn((value: string) =>
value === "please stop" ? ("allowed" as const) : ("pending" as const),
value === "please stop"
? ({ status: "allowed" } as const)
: ({ status: "blocked", reason: "pending" } as const),
);
const { sendMessage, onSubmit } = createSubmitHarness({ admitMessage });
@@ -91,7 +96,7 @@ describe("createEditorSubmitHandler", () => {
sendMessage,
handleBangLine: vi.fn(),
onSubmitError: vi.fn(),
admitMessage: () => "pending",
admitMessage: () => ({ status: "blocked", reason: "pending" }),
onBlockedMessageSubmit,
});
@@ -99,13 +104,16 @@ describe("createEditorSubmitHandler", () => {
expect(editor.getText()).toBe("wait, use c++ instead");
expect(sendMessage).not.toHaveBeenCalled();
expect(onBlockedMessageSubmit).toHaveBeenCalledWith("wait, use c++ instead", "pending");
expect(onBlockedMessageSubmit).toHaveBeenCalledWith("wait, use c++ instead", {
status: "blocked",
reason: "pending",
});
});
it("continues to route slash commands while chat is busy", () => {
const { editor, handleCommand, sendMessage, onBlockedMessageSubmit, onSubmit } =
createSubmitHarness({
admitMessage: () => "pending",
admitMessage: () => ({ status: "blocked", reason: "pending" }),
});
onSubmit("/abort");
@@ -224,6 +232,37 @@ describe("createSubmitBurstCoalescer", () => {
vi.useRealTimers();
});
it("preserves text typed after a buffered submit is blocked", () => {
vi.useFakeTimers();
const tui = { requestRender: vi.fn() } as unknown as TUI;
const editor = new CustomEditor(tui, editorTheme);
const sendMessage = vi.fn();
const submit = createEditorSubmitHandler({
editor,
handleCommand: vi.fn(),
sendMessage,
handleBangLine: vi.fn(),
onSubmitError: vi.fn(),
admitMessage: () => ({ status: "blocked", reason: "pending" }),
});
editor.onSubmit = createSubmitBurstCoalescer({
submit,
enabled: true,
burstWindowMs: 50,
});
editor.setText("blocked message");
editor.handleInput("\r");
for (const character of "plus newer text") {
editor.handleInput(character);
}
vi.advanceTimersByTime(50);
expect(sendMessage).not.toHaveBeenCalled();
expect(editor.getText()).toBe("blocked message\nplus newer text");
vi.useRealTimers();
});
it("passes through immediately when disabled", () => {
const submit = vi.fn();
const onSubmit = createSubmitBurstCoalescer({

View File

@@ -42,7 +42,6 @@ import { CustomEditor } from "./components/custom-editor.js";
import { resolveLocalRunShutdownGraceMs } from "./local-run-shutdown.js";
import { editorTheme, theme } from "./theme/theme.js";
import type { TuiBackend } from "./tui-backend.js";
import { addBlockedChatSubmitNotice } from "./tui-busy-notice.js";
import { createCommandHandlers } from "./tui-command-handlers.js";
import { createEventHandlers } from "./tui-event-handlers.js";
import {
@@ -62,12 +61,7 @@ import { createOverlayHandlers } from "./tui-overlays.js";
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 {
disconnectedTuiChatSubmitMessage,
resolveTuiChatSubmitAdmission,
type TuiChatSubmitAdmission,
type TuiPendingSubmit,
} from "./tui-submit-state.js";
import type { TuiPendingSubmit } from "./tui-submit-state.js";
import {
createEditorSubmitHandler,
createSubmitBurstCoalescer,
@@ -107,6 +101,9 @@ const SESSION_SUBSCRIPTION_RETRY_DELAY_MS = 25;
type RunTuiOptions = TuiOptions & {
backend?: TuiBackend;
submitBurstWindowMs?: number;
ctrlCExitWindowMs?: number;
onSubmitBurstCaptured?: (value: string) => void;
/** Exact pre-probed remote target for an in-process setup handoff. */
boundGateway?: {
url: string;
@@ -1562,35 +1559,43 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
};
exitAwareClient.setRequestExitHandler?.(() => requestExit());
const { handleCommand, sendMessage, openModelSelector, openAgentSelector, openSessionSelector } =
createCommandHandlers({
client,
chatLog,
tui,
opts: { ...opts, local: isLocalMode },
state,
deliverDefault,
openOverlay,
closeOverlay,
refreshSessionInfo,
applySessionInfoFromPatch,
applySessionMutationResult,
loadHistory,
setSession,
refreshAgents,
abortActive,
setActivityStatus,
formatSessionKey,
noteLocalRunId,
noteLocalBtwRunId,
forgetLocalRunId,
forgetLocalBtwRunId,
consumeCompletedRunForPendingSend,
isRunObserved,
flushPendingHistoryRefreshIfIdle,
runAuthFlow,
requestExit,
});
const {
handleCommand,
sendMessage,
captureMessageAdmission,
resolveMessageAdmission,
reportBlockedMessageSubmit,
openModelSelector,
openAgentSelector,
openSessionSelector,
} = createCommandHandlers({
client,
chatLog,
tui,
opts: { ...opts, local: isLocalMode },
state,
deliverDefault,
openOverlay,
closeOverlay,
refreshSessionInfo,
applySessionInfoFromPatch,
applySessionMutationResult,
loadHistory,
setSession,
refreshAgents,
abortActive,
setActivityStatus,
formatSessionKey,
noteLocalRunId,
noteLocalBtwRunId,
forgetLocalRunId,
forgetLocalBtwRunId,
consumeCompletedRunForPendingSend,
isRunObserved,
flushPendingHistoryRefreshIfIdle,
runAuthFlow,
requestExit,
});
const { runLocalShellLine } = createLocalShellRunner({
chatLog,
@@ -1599,25 +1604,6 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
closeOverlay,
});
updateAutocompleteProvider();
const admitChatMessage = (message: string) =>
resolveTuiChatSubmitAdmission({
isConnected: state.isConnected,
activeChatRunId: state.activeChatRunId,
pendingSubmit: state.pendingSubmit,
message,
});
const notifyBlockedChatSubmit = (
_message: string,
reason: Exclude<TuiChatSubmitAdmission, "allowed">,
) => {
if (reason === "pending") {
addBlockedChatSubmitNotice(chatLog);
} else {
chatLog.addSystem(disconnectedTuiChatSubmitMessage(isLocalMode));
setActivityStatus("disconnected");
}
tui.requestRender();
};
const notifySubmitError = (action: TuiSubmitAction, error: unknown) => {
const message = formatTuiErrorMessage(error);
chatLog.addSystem(`${action} submit failed: ${message}`);
@@ -1629,12 +1615,15 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
sendMessage,
handleBangLine: runLocalShellLine,
onSubmitError: notifySubmitError,
admitMessage: admitChatMessage,
onBlockedMessageSubmit: notifyBlockedChatSubmit,
admitMessage: resolveMessageAdmission,
onBlockedMessageSubmit: reportBlockedMessageSubmit,
});
editor.onSubmit = createSubmitBurstCoalescer({
submit: submitHandler,
enabled: shouldEnableWindowsGitBashPasteFallback(),
captureSnapshot: captureMessageAdmission,
enabled: opts.submitBurstWindowMs !== undefined || shouldEnableWindowsGitBashPasteFallback(),
burstWindowMs: opts.submitBurstWindowMs,
onCapture: opts.onSubmitBurstCaptured,
});
editor.onEscape = () => {
@@ -1653,6 +1642,7 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
lastCtrlCAt,
exitRequested,
wasDisconnected,
exitWindowMs: opts.ctrlCExitWindowMs,
});
if (decision.action === "force-exit") {
forceExit();

View File

@@ -6,8 +6,10 @@ import { resolveRepoRootPath, sharedVitestConfig } from "./vitest.shared.config.
const targetableIncludes = [
"src/tui/tui-pty-harness.e2e.test.ts",
"src/tui/tui-pty-local.e2e.test.ts",
"src/tui/tui-reset-transition-pty.e2e.test.ts",
"tui/tui-pty-harness.e2e.test.ts",
"tui/tui-pty-local.e2e.test.ts",
"tui/tui-reset-transition-pty.e2e.test.ts",
];
function toTuiPtyIncludePatterns(patterns: string[] | null) {
@@ -21,6 +23,7 @@ function createTuiPtyVitestConfig(env?: Record<string, string | undefined>) {
const includeLocal = configEnv.OPENCLAW_TUI_PTY_INCLUDE_LOCAL === "1";
const include = [
"tui/tui-pty-harness.e2e.test.ts",
"tui/tui-reset-transition-pty.e2e.test.ts",
...(includeLocal ? ["tui/tui-pty-local.e2e.test.ts"] : []),
];
const includeFromEnv = toTuiPtyIncludePatterns(