fix(tui): preserve active conversations during session reset (#113841)

This commit is contained in:
Peter Steinberger
2026-07-25 13:23:57 -07:00
committed by GitHub
parent a18c314377
commit f2d2cc34bb
4 changed files with 135 additions and 4 deletions
+39
View File
@@ -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(
+15 -3
View File
@@ -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;
+29 -1
View File
@@ -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 () => {
+52
View File
@@ -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 }) => {