mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 09:51:33 +00:00
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:
committed by
GitHub
parent
f47542c592
commit
262f26355a
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -872,6 +872,7 @@ describe("TUI PTY real backends", () => {
|
||||
},
|
||||
waitForOutput: async () => output,
|
||||
waitForExit: async () => ({ exitCode: 0, signal: 0 }),
|
||||
forceKill: async () => {},
|
||||
dispose: async () => {},
|
||||
} satisfies PtyRun;
|
||||
|
||||
|
||||
31
src/tui/tui-pty-reset-fixture-test-support.ts
Normal file
31
src/tui/tui-pty-reset-fixture-test-support.ts
Normal 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 }),
|
||||
`,
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
97
src/tui/tui-reset-transition-pty.e2e.test.ts
Normal file
97
src/tui/tui-reset-transition-pty.e2e.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
106
src/tui/tui.ts
106
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<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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user