fix(tui): fence session state and delayed actions (#129810)

This commit is contained in:
Peter Steinberger
2026-08-25 21:01:41 -07:00
committed by GitHub
parent 5005661473
commit b8c6996eed
5 changed files with 295 additions and 52 deletions
+187 -5
View File
@@ -770,6 +770,70 @@ describe("tui command handlers", () => {
expect(refreshSessionInfo).toHaveBeenCalled();
});
it.each([
{ name: "a different agent", replace: false, fails: false },
{ name: "a replacement session", replace: true, fails: false },
{ name: "a rejected old goal", replace: false, fails: true },
])("keeps a delayed local goal from leaking into $name", async ({ replace, fails }) => {
const deferred = createDeferred<{ text: string; continuationPrompt?: string }>();
const harness = createHarness({
opts: { local: true },
currentAgentId: "research",
currentSessionKey: "agent:research:private",
currentSessionId: "research-session",
sessionGeneration: 4,
runGoalCommand: vi.fn(() => deferred.promise),
});
const pending = harness.handleCommand("/goal start private objective");
if (replace) {
harness.state.currentSessionId = "replacement-session";
harness.state.sessionGeneration += 1;
} else {
harness.state.currentAgentId = "ops";
harness.state.currentSessionKey = "agent:ops:public";
harness.state.currentSessionId = "ops-session";
}
if (fails) {
deferred.reject(new Error("private research failure"));
} else {
deferred.resolve({
text: "private research goal status",
continuationPrompt: "PRIVATE_RESEARCH_OBJECTIVE",
});
}
await pending;
expect(harness.sendChat).not.toHaveBeenCalled();
expect(harness.addSystem).not.toHaveBeenCalled();
expect(harness.refreshSessionInfo).not.toHaveBeenCalled();
});
it("does not send an old goal continuation after its session changes during refresh", async () => {
const refresh = createDeferred();
const harness = createHarness({
opts: { local: true },
currentAgentId: "research",
currentSessionKey: "agent:research:private",
currentSessionId: "research-session",
runGoalCommand: vi.fn().mockResolvedValue({
text: "private research goal status",
continuationPrompt: "PRIVATE_RESEARCH_OBJECTIVE",
}),
refreshSessionInfo: vi.fn(() => refresh.promise),
});
const pending = harness.handleCommand("/goal start private objective");
await vi.waitFor(() => expect(harness.refreshSessionInfo).toHaveBeenCalledOnce());
harness.state.currentAgentId = "ops";
harness.state.currentSessionKey = "agent:ops:public";
harness.state.currentSessionId = "ops-session";
refresh.resolve();
await pending;
expect(harness.sendChat).not.toHaveBeenCalled();
});
it("wraps command-prefixed local goal objectives before sending", async () => {
const slashPrompt = `Pursue this goal exactly as written from this JSON string: "\\/status"`;
const slashRunGoalCommand = vi
@@ -1587,6 +1651,87 @@ describe("tui command handlers", () => {
expect(loadHistory).not.toHaveBeenCalled();
});
it.each([
{ name: "a different agent", replace: false, fails: false },
{ name: "a replacement session", replace: true, fails: false },
{ name: "a rejected old session", replace: false, fails: true },
])("does not let delayed /new hijack $name", async ({ replace, fails }) => {
const deferred = createDeferred<{ ok: true; key: string }>();
const harness = createHarness({
currentAgentId: "research",
currentSessionKey: "agent:research:private",
currentSessionId: "research-session",
sessionGeneration: 4,
sessionInfo: { inputTokens: 11, outputTokens: 22, totalTokens: 33 },
createSession: vi.fn(() => deferred.promise),
});
const pending = harness.handleCommand("/new");
if (replace) {
harness.state.currentSessionId = "replacement-session";
harness.state.sessionGeneration += 1;
} else {
harness.state.currentAgentId = "ops";
harness.state.currentSessionKey = "agent:ops:public";
harness.state.currentSessionId = "ops-session";
}
if (fails) {
deferred.reject(new Error("private research session failure"));
} else {
deferred.resolve({ ok: true, key: "agent:research:private-child" });
}
await pending;
expect(harness.setSession).not.toHaveBeenCalled();
expect(harness.addSystem).not.toHaveBeenCalled();
expect(harness.state.sessionInfo).toMatchObject({
inputTokens: 11,
outputTokens: 22,
totalTokens: 33,
});
});
it.each([
{ name: "reports an adopted session failure", fails: true, switches: false },
{ name: "does not leak an adopted session notice", fails: false, switches: true },
])("$name after /new changes the selected session", async ({ fails, switches }) => {
const adoption = createDeferred();
const createdKey = "agent:research:private-child";
const harness = createHarness({
currentAgentId: "research",
currentSessionKey: "agent:research:private",
currentSessionId: "research-session",
createSession: vi.fn().mockResolvedValue({ ok: true, key: createdKey }),
setSession: vi.fn((key: string) => {
harness.state.currentSessionKey = key;
harness.state.currentSessionId = null;
return adoption.promise;
}) as SetSessionMock,
});
const pending = harness.handleCommand("/new");
await vi.waitFor(() => expect(harness.setSession).toHaveBeenCalledOnce());
if (switches) {
harness.state.currentAgentId = "ops";
harness.state.currentSessionKey = "agent:ops:public";
harness.state.currentSessionId = "ops-session";
}
if (fails) {
adoption.reject(new Error("replacement history unavailable"));
} else {
adoption.resolve();
}
await pending;
if (fails) {
expect(harness.addSystem).toHaveBeenCalledWith(
"new session failed: replacement history unavailable",
);
} else {
expect(harness.addSystem).not.toHaveBeenCalled();
}
});
it("reports a reset after adopting the backend's replacement session key", async () => {
const resetResult = {
ok: true as const,
@@ -3120,21 +3265,58 @@ describe("tui command handlers", () => {
});
it.each([
{ name: "session result", sessionKey: "agent:main:first", agentId: "main", fails: false },
{ name: "global-agent result", sessionKey: "global", agentId: "main", fails: false },
{ name: "session failure", sessionKey: "agent:main:first", agentId: "main", fails: true },
])("suppresses a stale usage-cost $name", async ({ sessionKey, agentId, fails }) => {
{
name: "session result",
sessionKey: "agent:main:first",
agentId: "main",
fails: false,
replace: false,
},
{
name: "global-agent result",
sessionKey: "global",
agentId: "main",
fails: false,
replace: false,
},
{
name: "session failure",
sessionKey: "agent:main:first",
agentId: "main",
fails: true,
replace: false,
},
{
name: "replacement-session result",
sessionKey: "agent:main:first",
agentId: "main",
fails: false,
replace: true,
},
{
name: "replacement-session failure",
sessionKey: "agent:main:first",
agentId: "main",
fails: true,
replace: true,
},
])("suppresses a stale usage-cost $name", async ({ sessionKey, agentId, fails, replace }) => {
const deferred = createDeferred<{ text: string }>();
const runUsageCostCommand = vi.fn(() => deferred.promise);
const harness = createHarness({
opts: { local: true },
currentSessionKey: sessionKey,
currentAgentId: agentId,
currentSessionId: "original-session",
sessionGeneration: 4,
runUsageCostCommand,
});
const pending = harness.handleCommand("/usage cost");
if (sessionKey === "global") {
if (replace) {
harness.state.currentSessionId = "replacement-session";
harness.state.sessionGeneration += 1;
} else if (sessionKey === "global") {
harness.state.currentAgentId = "work";
} else {
harness.state.currentSessionKey = "agent:main:second";
+29 -20
View File
@@ -560,19 +560,24 @@ export function createCommandHandlers(context: CommandHandlerContext) {
},
goal: async (_args, raw) => {
if (opts.local === true && client.runGoalCommand) {
const { selection, isCurrent } = captureSessionIncarnation();
try {
const result = await client.runGoalCommand({
sessionKey: state.currentSessionKey,
agentId: state.currentAgentId,
...selection,
command: raw,
});
if (!isCurrent()) {
return;
}
chatLog.addSystem(result.text);
await refreshSessionInfo();
if (result.continuationPrompt) {
if (result.continuationPrompt && isCurrent()) {
await sendMessage(result.continuationPrompt);
}
} catch (err) {
chatLog.addSystem(`goal failed: ${formatTuiErrorMessage(err)}`);
if (isCurrent()) {
chatLog.addSystem(`goal failed: ${formatTuiErrorMessage(err)}`);
}
}
} else {
await sendMessage(raw);
@@ -703,14 +708,14 @@ export function createCommandHandlers(context: CommandHandlerContext) {
addUnsupportedLocalCommand("usage cost");
return;
}
const selection = captureSessionSelection();
const { selection, isCurrent } = captureSessionIncarnation();
try {
const result = await client.runUsageCostCommand(selection);
if (isCurrentSessionSelection(selection)) {
if (isCurrent()) {
chatLog.addSystem(result.text);
}
} catch (err) {
if (isCurrentSessionSelection(selection)) {
if (isCurrent()) {
chatLog.addSystem(`usage cost failed: ${formatTuiErrorMessage(err)}`);
}
}
@@ -773,27 +778,31 @@ export function createCommandHandlers(context: CommandHandlerContext) {
if (rejectUnsafeSessionRollover("new")) {
return;
}
let creationIncarnation = captureSessionIncarnation();
const { selection, sessionId } = creationIncarnation;
const finishSessionTransition = beginSessionTransition("new");
try {
const uniqueKey = `tui-${randomUUID()}`;
const result = await client.createSession({
key: uniqueKey,
agentId: state.currentAgentId,
...(state.currentSessionId
? { parentSessionKey: state.currentSessionKey, succeedsParent: true }
: {}),
key: `tui-${randomUUID()}`,
agentId: selection.agentId,
...(sessionId ? { parentSessionKey: selection.sessionKey, succeedsParent: true } : {}),
});
if (!creationIncarnation.isCurrent()) {
return;
}
if (!result.key) {
throw new Error("sessions.create returned no session key");
}
state.sessionInfo.inputTokens = null;
state.sessionInfo.outputTokens = null;
state.sessionInfo.totalTokens = null;
tui.requestRender();
await setSession(result.key);
chatLog.addSystem(`new session: ${result.key}`);
const adoption = setSession(result.key);
creationIncarnation = captureSessionIncarnation();
await adoption;
if (creationIncarnation.isCurrent()) {
chatLog.addSystem(`new session: ${result.key}`);
}
} catch (err) {
chatLog.addSystem(`new session failed: ${formatTuiErrorMessage(err)}`);
if (creationIncarnation.isCurrent()) {
chatLog.addSystem(`new session failed: ${formatTuiErrorMessage(err)}`);
}
} finally {
finishSessionTransition();
}
+73 -12
View File
@@ -200,7 +200,19 @@ describe("tui session actions", () => {
activeChatRunId: "research-run",
pendingSubmit: acceptedSubmit("research-pending", "private draft"),
historyLoaded: true,
sessionInfo: { displayName: "Research secret", updatedAt: 100, verboseLevel: "full" },
sessionInfo: {
displayName: "Research secret",
updatedAt: 100,
modelProvider: "anthropic",
model: "private-research-model",
thinkingLevel: "high",
thinkingLevels: [{ id: "high", label: "high" }],
agentRuntime: { id: "private-runtime", source: "agent" },
responseUsage: "full",
effectiveResponseUsage: "full",
contextTokens: 999_999,
verboseLevel: "full",
},
});
sendPendingUser(state, "research-pending", "private draft");
const chatLog = new ChatLog();
@@ -208,6 +220,7 @@ describe("tui session actions", () => {
const history = createDeferred<{
messages: unknown[];
sessionInfo: { sessionId: string };
defaults: { modelProvider: string; model: string; contextTokens: number };
}>();
const loadHistory = vi.fn(() => history.promise);
const invalidateRunOwnership = vi.fn();
@@ -229,7 +242,7 @@ describe("tui session actions", () => {
expect(state.currentAgentId).toBe("ops");
expect(state.currentSessionKey).toBe("global");
expect(state.currentSessionId).toBeNull();
expect(state.sessionInfo.displayName).toBeUndefined();
expect(state.sessionInfo).toEqual({});
expect(state.activeChatRunId).toBeNull();
expect(state.pendingSubmit).toBeNull();
expect(state.historyLoaded).toBe(false);
@@ -239,9 +252,21 @@ describe("tui session actions", () => {
expect(clearLocalRunIds).toHaveBeenCalledOnce();
expect(loadHistory).toHaveBeenCalledWith({ sessionKey: "global", agentId: "ops", limit: 200 });
history.resolve({ messages: [], sessionInfo: { sessionId: "ops-session" } });
history.resolve({
messages: [],
sessionInfo: { sessionId: "ops-session" },
defaults: { modelProvider: "openai", model: "gpt-5.4", contextTokens: 128_000 },
});
await switching;
expect(state.currentSessionId).toBe("ops-session");
expect(state.sessionInfo).toMatchObject({
modelProvider: "openai",
model: "gpt-5.4",
contextTokens: 128_000,
});
expect(state.sessionInfo.thinkingLevel).toBeUndefined();
expect(state.sessionInfo.agentRuntime).toBeUndefined();
expect(state.sessionInfo.responseUsage).toBeUndefined();
});
it("returns success after applying a normalized fresh agent roster", async () => {
@@ -397,9 +422,9 @@ describe("tui session actions", () => {
activeChatRunId: null,
pendingSubmit: null,
historyLoaded: false,
sessionInfo: { updatedAt: null },
sessionInfo: {},
});
expect(state.sessionInfo.thinkingLevel).toBe("high");
expect(state.sessionInfo.thinkingLevel).toBeUndefined();
expect(state.sessionInfo.verboseLevel).toBeUndefined();
expect(state.sessionProjection?.entries).toEqual([]);
expect(invalidateRunOwnership).toHaveBeenCalledOnce();
@@ -1685,13 +1710,15 @@ describe("tui session actions", () => {
await setSession("agent:main:target");
expect(state.sessionInfo).toMatchObject({
displayName: undefined,
fastMode: undefined,
verboseLevel: undefined,
traceLevel: undefined,
reasoningLevel: undefined,
});
for (const key of [
"displayName",
"fastMode",
"verboseLevel",
"traceLevel",
"reasoningLevel",
] as const) {
expect(state.sessionInfo[key]).toBeUndefined();
}
});
it("merges a same-session mode patch without clearing untouched modes", () => {
@@ -2198,6 +2225,40 @@ describe("tui session actions", () => {
expect(clearAll).toHaveBeenCalledOnce();
});
it("discards in-flight history when an external event replaces the same session", async () => {
const history = createDeferred<unknown>();
const { chatLog, addUser, clearAll } = createHistoryChatLog();
const state = createBaseState({
currentSessionId: "session-before-reset",
sessionGeneration: 4,
sessionInfo: { model: "model-before-reset" },
});
const { loadHistory } = createTestSessionActions({
client: makeTuiBackend({ loadHistory: vi.fn(() => history.promise) }),
chatLog,
state,
});
const staleHistory = loadHistory();
state.sessionGeneration = 5;
state.currentSessionId = "session-after-reset";
state.sessionInfo = { model: "model-after-reset" };
history.resolve({
sessionInfo: {
key: "agent:main:main",
sessionId: "session-before-reset",
model: "private-old-model",
},
messages: [{ role: "user", content: "PRIVATE OLD HISTORY" }],
});
await expect(staleHistory).resolves.toEqual({ loaded: false });
expect(state.currentSessionId).toBe("session-after-reset");
expect(state.sessionInfo.model).toBe("model-after-reset");
expect(addUser).not.toHaveBeenCalled();
expect(clearAll).not.toHaveBeenCalled();
});
it("does not clear the selected session for another session's reset result", () => {
const addSystem = vi.fn();
const clearAll = vi.fn();
+6 -7
View File
@@ -23,7 +23,6 @@ import {
} from "./tui-formatters.js";
import { readTuiSessionUserMessage } from "./tui-session-events.js";
import {
clearTuiSessionModeOverrides,
sessionInfoUiEquals,
type SessionInfoDefaults,
type SessionInfoEntry,
@@ -111,11 +110,8 @@ export function createSessionActions(context: SessionActionContext) {
submit.clearPendingSubmit(state);
setActivityStatus("idle");
state.currentSessionId = null;
state.sessionInfo.displayName = undefined;
clearTuiSessionModeOverrides(state.sessionInfo);
// Session keys can move backwards in updatedAt ordering; drop previous session freshness
// so refresh data for the newly selected session isn't rejected as stale.
state.sessionInfo.updatedAt = null;
state.sessionInfo = {};
lastSessionDefaults = null;
state.historyLoaded = false;
// Live prompt identities belong to the old selection, not its pending successor.
chatLog.clearAll();
@@ -455,9 +451,12 @@ export function createSessionActions(context: SessionActionContext) {
// History rebuilds mutate shared UI state after multiple awaits. Only the
// latest request may render, or a slow reload can replace a newer selection.
const generation = ++historyLoadGeneration;
const sessionGeneration = state.sessionGeneration ?? 0;
const selection = captureSessionSelection();
const isCurrentLoad = () =>
generation === historyLoadGeneration && isCurrentSessionSelection(selection);
generation === historyLoadGeneration &&
(state.sessionGeneration ?? 0) === sessionGeneration &&
isCurrentSessionSelection(selection);
try {
const history = await client.loadHistory({
sessionKey: selection.sessionKey,
-8
View File
@@ -41,11 +41,3 @@ export function sessionInfoUiEquals(left: SessionInfo, right: SessionInfo): bool
JSON.stringify(left.goal ?? null) === JSON.stringify(right.goal ?? null))
);
}
/** Clear selection-owned modes so a switch cannot display its predecessor while loading. */
export function clearTuiSessionModeOverrides(sessionInfo: SessionInfo): void {
sessionInfo.fastMode = undefined;
sessionInfo.verboseLevel = undefined;
sessionInfo.traceLevel = undefined;
sessionInfo.reasoningLevel = undefined;
}