diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts index 544d5307612b..26687bd0ed53 100644 --- a/src/tui/tui-command-handlers.test.ts +++ b/src/tui/tui-command-handlers.test.ts @@ -1069,6 +1069,45 @@ describe("tui command handlers", () => { expect(addSystem).toHaveBeenCalledWith("abort the current run before /new"); }); + it.each([ + { + activeChatRunId: "active-run", + pendingSubmit: null, + activityStatus: "running", + }, + { + activeChatRunId: null, + pendingSubmit: { + phase: "accepted" as const, + runId: "pending-run", + draftText: null, + }, + activityStatus: "sending", + }, + { + activeChatRunId: null, + pendingSubmit: { + phase: "sending" as const, + runId: "pending-run", + draftText: "pending", + }, + activityStatus: "sending", + }, + { + activeChatRunId: null, + pendingSubmit: null, + activityStatus: "finishing context", + }, + ])("blocks /reset while the current session lifecycle is unfinished", async (runState) => { + const resetSession = vi.fn(); + const { handleCommand, addSystem } = createHarness({ resetSession, ...runState }); + + await handleCommand("/reset"); + + expect(resetSession).not.toHaveBeenCalled(); + expect(addSystem).toHaveBeenCalledWith("abort the current run before /reset"); + }); + it("serializes input until /new adopts the created session", async () => { let resolveCreate: ((value: { ok: true; key: string }) => void) | undefined; const createSession = vi.fn().mockImplementation( diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index c82b06bc3c8a..1526b4648d5b 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -179,6 +179,17 @@ export function createCommandHandlers(context: CommandHandlerContext) { const hasUnsafeSessionRollover = () => hasTrackedAbortTarget() || state.activityStatus === "finishing context"; + const rejectUnsafeSessionRollover = (command: "new" | "reset") => { + if (!hasUnsafeSessionRollover()) { + return false; + } + // Reset interrupts admitted Gateway work, so both rollover commands must + // reject active, queued, and finishing runs before mutating the session. + chatLog.addSystem(`abort the current run before /${command}`); + tui.requestRender(); + return true; + }; + const currentSessionPatchTarget = () => ({ key: state.currentSessionKey, ...(state.currentSessionKey === "global" ? { agentId: state.currentAgentId } : {}), @@ -736,9 +747,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { break; } case "new": - if (hasUnsafeSessionRollover()) { - chatLog.addSystem("abort the current run before /new"); - tui.requestRender(); + if (rejectUnsafeSessionRollover("new")) { break; } sessionCreationInFlight = true; @@ -769,6 +778,9 @@ export function createCommandHandlers(context: CommandHandlerContext) { } break; case "reset": + if (rejectUnsafeSessionRollover("reset")) { + break; + } try { // Clear token counts immediately to avoid stale display (#1523) state.sessionInfo.inputTokens = null; diff --git a/src/tui/tui-pty-harness.e2e.test.ts b/src/tui/tui-pty-harness.e2e.test.ts index 402d93034305..2f536b83da7c 100644 --- a/src/tui/tui-pty-harness.e2e.test.ts +++ b/src/tui/tui-pty-harness.e2e.test.ts @@ -232,7 +232,11 @@ async function writeTuiPtyFixtureScript(dir: string) { return { runId }; } const responseDelayMs = - opts.message === "slow prompt" || opts.message === "streaming prompt" ? 500 : 20; + opts.message === "slow prompt" || + opts.message === "slow reset proof" || + opts.message === "streaming prompt" + ? 500 + : 20; if (opts.message === "streaming prompt") { setTimeout(() => { this.onEvent?.({ @@ -834,6 +838,30 @@ describe.sequential("TUI PTY harness", () => { TEST_TIMEOUT_MS, ); + it( + "keeps an active session intact when /reset is submitted from the terminal", + async () => { + const priorResetCount = (await readFixtureLog(fixture.logPath)).filter( + (entry) => entry.method === "resetSession", + ).length; + + await fixture.run.write("slow reset proof\r"); + await fixture.waitForLogEntry( + (entry) => + entry.method === "sendChat" && objectFieldEquals(entry, "message", "slow reset proof"), + ); + await fixture.run.write("/reset\r", { delay: false }); + await fixture.run.waitForOutput("abort the current run before /reset"); + + const resetCalls = (await readFixtureLog(fixture.logPath)).filter( + (entry) => entry.method === "resetSession", + ); + expect(resetCalls).toHaveLength(priorResetCount); + await fixture.run.waitForOutput("PTY_RESPONSE: slow reset proof"); + }, + TEST_TIMEOUT_MS, + ); + it( "submits a follow-up prompt while a run is streaming", async () => { diff --git a/src/tui/tui-pty-local.e2e.test.ts b/src/tui/tui-pty-local.e2e.test.ts index 07df4e41ff0c..1f83d69dc281 100644 --- a/src/tui/tui-pty-local.e2e.test.ts +++ b/src/tui/tui-pty-local.e2e.test.ts @@ -67,6 +67,14 @@ const GATEWAY_SCENARIOS = { holdFirstResponse: true, followupReplyText: "FOLLOWUP_RUN_COMPLETE", }, + reset: { + agentId: "tui-pty-reset", + modelId: "tui-pty-reset", + toolsProfile: "minimal", + replyText: "FIRST_RUN_ACTIVE", + holdFirstResponse: true, + followupReplyText: "FOLLOWUP_RUN_COMPLETE", + }, emptyReply: { agentId: "tui-pty-empty-reply", modelId: "tui-pty-empty-reply", @@ -1128,6 +1136,50 @@ describe("TUI PTY real backends", () => { LOCAL_TEST_TIMEOUT_MS, ); + registerGatewayTest( + "preserves a running session when /reset is typed against the real Gateway", + async ({ onTestFinished }) => { + const fixture = await startGatewayModeTui("reset", onTestFinished); + try { + await fixture.run.waitForOutput("gateway connected", LOCAL_STARTUP_TIMEOUT_MS); + await fixture.run.write("keep the active Gateway turn\r"); + await waitFor({ + timeoutMs: LOCAL_OUTPUT_TIMEOUT_MS, + read: () => (fixture.mockModel.requests().length === 1 ? true : null), + onTimeout: () => + new Error(`active Gateway turn did not reach the model\n${fixture.run.output()}`), + }); + + await fixture.run.write("/reset\r", { delay: false }); + await fixture.run.waitForOutput("abort the current run before /reset", 5_000); + expect(fixture.mockModel.requests()).toHaveLength(1); + + fixture.mockModel.releaseFirstResponse(); + await fixture.run.waitForOutput("FIRST_RUN_ACTIVE"); + + await fixture.run.write("continue the preserved Gateway session\r"); + await waitFor({ + timeoutMs: LOCAL_OUTPUT_TIMEOUT_MS, + read: () => (fixture.mockModel.requests().length === 2 ? true : null), + onTimeout: () => + new Error( + `preserved Gateway session did not accept its next turn\n${fixture.gateway.logs()}\n${fixture.run.output()}`, + ), + }); + const preservedRequest = JSON.stringify(fixture.mockModel.requests()[1]?.body); + expect(preservedRequest).toContain("keep the active Gateway turn"); + expect(preservedRequest).toContain("continue the preserved Gateway session"); + await fixture.run.waitForOutput("FOLLOWUP_RUN_COMPLETE"); + + await fixture.run.write("/exit\r", { delay: false }); + expect((await fixture.run.waitForExit()).exitCode).toBe(0); + } finally { + await fixture.cleanup(); + } + }, + LOCAL_TEST_TIMEOUT_MS, + ); + registerGatewayTest( "forwards an active-run prompt through the real Gateway followup queue", async ({ onTestFinished }) => {