fix: isolate TUI /new sessions per client

Landed from contributor PR #39238 by @widingmarcus-cyber.

Co-authored-by: Marcus Widing <widing.marcus@gmail.com>
This commit is contained in:
Peter Steinberger
2026-03-08 02:31:03 +00:00
parent 76a028a50a
commit 46008178d1
3 changed files with 54 additions and 7 deletions

View File

@@ -7,12 +7,14 @@ type SetActivityStatusMock = ReturnType<typeof vi.fn> & ((text: string) => void)
function createHarness(params?: {
sendChat?: ReturnType<typeof vi.fn>;
resetSession?: ReturnType<typeof vi.fn>;
setSession?: ReturnType<typeof vi.fn>;
loadHistory?: LoadHistoryMock;
setActivityStatus?: SetActivityStatusMock;
isConnected?: boolean;
}) {
const sendChat = params?.sendChat ?? vi.fn().mockResolvedValue({ runId: "r1" });
const resetSession = params?.resetSession ?? vi.fn().mockResolvedValue({ ok: true });
const setSession = params?.setSession ?? vi.fn().mockResolvedValue(undefined);
const addUser = vi.fn();
const addSystem = vi.fn();
const requestRender = vi.fn();
@@ -36,7 +38,7 @@ function createHarness(params?: {
closeOverlay: vi.fn(),
refreshSessionInfo: vi.fn(),
loadHistory,
setSession: vi.fn(),
setSession,
refreshAgents: vi.fn(),
abortActive: vi.fn(),
setActivityStatus,
@@ -51,6 +53,7 @@ function createHarness(params?: {
handleCommand,
sendChat,
resetSession,
setSession,
addUser,
addSystem,
requestRender,
@@ -104,16 +107,26 @@ describe("tui command handlers", () => {
expect(requestRender).toHaveBeenCalled();
});
it("passes reset reason when handling /new and /reset", async () => {
it("creates unique session for /new and resets shared session for /reset", async () => {
const loadHistory = vi.fn().mockResolvedValue(undefined);
const { handleCommand, resetSession } = createHarness({ loadHistory });
const setSessionMock = vi.fn().mockResolvedValue(undefined);
const { handleCommand, resetSession } = createHarness({
loadHistory,
setSession: setSessionMock,
});
await handleCommand("/new");
await handleCommand("/reset");
expect(resetSession).toHaveBeenNthCalledWith(1, "agent:main:main", "new");
expect(resetSession).toHaveBeenNthCalledWith(2, "agent:main:main", "reset");
expect(loadHistory).toHaveBeenCalledTimes(2);
// /new creates a unique session key (isolates TUI client) (#39217)
expect(setSessionMock).toHaveBeenCalledTimes(1);
expect(setSessionMock).toHaveBeenCalledWith(
expect.stringMatching(/^tui-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/),
);
// /reset still resets the shared session
expect(resetSession).toHaveBeenCalledTimes(1);
expect(resetSession).toHaveBeenCalledWith("agent:main:main", "reset");
expect(loadHistory).toHaveBeenCalledTimes(1); // /reset calls loadHistory directly; /new does so indirectly via setSession
});
it("reports send failures and marks activity status as error", async () => {
@@ -129,6 +142,21 @@ describe("tui command handlers", () => {
expect(setActivityStatus).toHaveBeenLastCalledWith("error");
});
it("sanitizes control sequences in /new and /reset failures", async () => {
const setSession = vi.fn().mockRejectedValue(new Error("\u001b[31mboom\u001b[0m"));
const resetSession = vi.fn().mockRejectedValue(new Error("\u001b[31mboom\u001b[0m"));
const { handleCommand, addSystem } = createHarness({
setSession,
resetSession,
});
await handleCommand("/new");
await handleCommand("/reset");
expect(addSystem).toHaveBeenNthCalledWith(1, "new session failed: Error: boom");
expect(addSystem).toHaveBeenNthCalledWith(2, "reset failed: Error: boom");
});
it("reports disconnected status and skips gateway send when offline", async () => {
const { handleCommand, sendChat, addUser, addSystem, setActivityStatus } = createHarness({
isConnected: false,

View File

@@ -16,6 +16,7 @@ import {
createSettingsList,
} from "./components/selectors.js";
import type { GatewayChatClient } from "./gateway-chat.js";
import { sanitizeRenderableText } from "./tui-formatters.js";
import { formatStatusSummary } from "./tui-status-summary.js";
import type {
AgentSummary,
@@ -423,6 +424,23 @@ export function createCommandHandlers(context: CommandHandlerContext) {
}
break;
case "new":
try {
// Clear token counts immediately to avoid stale display (#1523)
state.sessionInfo.inputTokens = null;
state.sessionInfo.outputTokens = null;
state.sessionInfo.totalTokens = null;
tui.requestRender();
// Generate unique session key to isolate this TUI client (#39217)
// This ensures /new creates a fresh session that doesn't broadcast
// to other connected TUI clients sharing the original session key.
const uniqueKey = `tui-${randomUUID()}`;
await setSession(uniqueKey);
chatLog.addSystem(`new session: ${uniqueKey}`);
} catch (err) {
chatLog.addSystem(`new session failed: ${sanitizeRenderableText(String(err))}`);
}
break;
case "reset":
try {
// Clear token counts immediately to avoid stale display (#1523)
@@ -435,7 +453,7 @@ export function createCommandHandlers(context: CommandHandlerContext) {
chatLog.addSystem(`session ${state.currentSessionKey} reset`);
await loadHistory();
} catch (err) {
chatLog.addSystem(`reset failed: ${String(err)}`);
chatLog.addSystem(`reset failed: ${sanitizeRenderableText(String(err))}`);
}
break;
case "abort":