diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts index bd0952856433..512bd22c1506 100644 --- a/src/tui/tui-command-handlers.test.ts +++ b/src/tui/tui-command-handlers.test.ts @@ -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 & (() => Promise); @@ -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); diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index d35cfcdf7624..1df264f75916 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -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, diff --git a/src/tui/tui-pty-harness-fixture-test-support.ts b/src/tui/tui-pty-harness-fixture-test-support.ts index 1102cc9b0ddb..68072cb207d1 100644 --- a/src/tui/tui-pty-harness-fixture-test-support.ts +++ b/src/tui/tui-pty-harness-fixture-test-support.ts @@ -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} }); } diff --git a/src/tui/tui-pty-local.e2e.test.ts b/src/tui/tui-pty-local.e2e.test.ts index 89d2f7159951..528feb5e8873 100644 --- a/src/tui/tui-pty-local.e2e.test.ts +++ b/src/tui/tui-pty-local.e2e.test.ts @@ -872,6 +872,7 @@ describe("TUI PTY real backends", () => { }, waitForOutput: async () => output, waitForExit: async () => ({ exitCode: 0, signal: 0 }), + forceKill: async () => {}, dispose: async () => {}, } satisfies PtyRun; diff --git a/src/tui/tui-pty-reset-fixture-test-support.ts b/src/tui/tui-pty-reset-fixture-test-support.ts new file mode 100644 index 000000000000..fc41f974afcf --- /dev/null +++ b/src/tui/tui-pty-reset-fixture-test-support.ts @@ -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 }), +`, +}; diff --git a/src/tui/tui-pty-test-support.test.ts b/src/tui/tui-pty-test-support.test.ts index b5fe0c0dc64d..9ff6801231af 100644 --- a/src/tui/tui-pty-test-support.test.ts +++ b/src/tui/tui-pty-test-support.test.ts @@ -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 { 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); + }); }); diff --git a/src/tui/tui-pty-test-support.ts b/src/tui/tui-pty-test-support.ts index 93a258226a89..a7e77dee2832 100644 --- a/src/tui/tui-pty-test-support.ts +++ b/src/tui/tui-pty-test-support.ts @@ -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[0]>[0]; @@ -15,6 +16,8 @@ export type PtyRun = { write: (data: string, opts?: { delay?: boolean }) => Promise; waitForOutput: (needle: string, timeoutMs?: number) => Promise; waitForExit: (timeoutMs?: number) => Promise; + /** Ends behavior-complete PTY scenarios without exercising graceful TUI shutdown. */ + forceKill: () => Promise; dispose: () => Promise; }; @@ -146,6 +149,7 @@ export function startPty( onTimeout: () => new Error(`timed out waiting for PTY exit\n${output}`), }); + let forceKillPromise: Promise | undefined; let disposePromise: Promise | 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((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 { diff --git a/src/tui/tui-reset-transition-pty.e2e.test.ts b/src/tui/tui-reset-transition-pty.e2e.test.ts new file mode 100644 index 000000000000..9df3cf5caf73 --- /dev/null +++ b/src/tui/tui-reset-transition-pty.e2e.test.ts @@ -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[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, + ); +}); diff --git a/src/tui/tui-session-actions.test.ts b/src/tui/tui-session-actions.test.ts index d523a2dbb655..b303a8a959c4 100644 --- a/src/tui/tui-session-actions.test.ts +++ b/src/tui/tui-session-actions.test.ts @@ -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 => ({ @@ -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(); + const sessionInfo = createDeferred(); + 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(); diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index c27e38d7ba14..20b8a6585bfa 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -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", diff --git a/src/tui/tui-submit-state.test.ts b/src/tui/tui-submit-state.test.ts index 036fb166d53f..3217abc18a91 100644 --- a/src/tui/tui-submit-state.test.ts +++ b/src/tui/tui-submit-state.test.ts @@ -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); }); }); diff --git a/src/tui/tui-submit-state.ts b/src/tui/tui-submit-state.ts index d6eed6ea02f7..c5a7f6d8ad37 100644 --- a/src/tui/tui-submit-state.ts +++ b/src/tui/tui-submit-state.ts @@ -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; + +/** 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 { diff --git a/src/tui/tui-submit-test-helpers.ts b/src/tui/tui-submit-test-helpers.ts index 378f3ef3a254..9c156d639b39 100644 --- a/src/tui/tui-submit-test-helpers.ts +++ b/src/tui/tui-submit-test-helpers.ts @@ -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({ diff --git a/src/tui/tui-submit.ts b/src/tui/tui-submit.ts index 0ef9b36f185e..89aabd7891c6 100644 --- a/src/tui/tui-submit.ts +++ b/src/tui/tui-submit.ts @@ -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; handleBangLine: (value: string) => Promise | void; onSubmitError: (action: TuiSubmitAction, error: unknown) => void; - admitMessage?: (value: string) => TuiChatSubmitAdmission; - onBlockedMessageSubmit?: ( - value: string, - reason: Exclude, - ) => 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 | 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(); }; diff --git a/src/tui/tui.submit-handler.test.ts b/src/tui/tui.submit-handler.test.ts index 38fac2f602ef..2bd8c1471258 100644 --- a/src/tui/tui.submit-handler.test.ts +++ b/src/tui/tui.submit-handler.test.ts @@ -54,7 +54,7 @@ describe("createEditorSubmitHandler", () => { it("preserves normal message drafts when chat is busy", () => { const { editor, sendMessage, handleCommand, handleBangLine, onBlockedMessageSubmit, onSubmit } = createSubmitHarness({ - 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({ diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 791611cdc3ae..6311730e3225 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -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 { }; 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 { closeOverlay, }); updateAutocompleteProvider(); - const admitChatMessage = (message: string) => - resolveTuiChatSubmitAdmission({ - isConnected: state.isConnected, - activeChatRunId: state.activeChatRunId, - pendingSubmit: state.pendingSubmit, - message, - }); - const notifyBlockedChatSubmit = ( - _message: string, - reason: Exclude, - ) => { - 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 { 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 { lastCtrlCAt, exitRequested, wasDisconnected, + exitWindowMs: opts.ctrlCExitWindowMs, }); if (decision.action === "force-exit") { forceExit(); diff --git a/test/vitest/vitest.tui-pty.config.ts b/test/vitest/vitest.tui-pty.config.ts index 22a24ffc359b..5df35696073f 100644 --- a/test/vitest/vitest.tui-pty.config.ts +++ b/test/vitest/vitest.tui-pty.config.ts @@ -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) { 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(