fix(tui): clear stale overlays after session restore (#125086)

Centralize TUI session identity in state so remembered-session restores use the same invalidation path as interactive switches. Fixes #125013.
This commit is contained in:
Peter Steinberger
2026-08-16 23:14:48 -07:00
committed by GitHub
parent 0af3b865d7
commit 7b4981bfd2
4 changed files with 143 additions and 67 deletions
+24 -15
View File
@@ -107,10 +107,27 @@ export async function writeTuiPtyFixtureScript(dir: string) {
const pickerSessionTitle = process.env.OPENCLAW_TUI_PTY_PICKER_SESSION_TITLE;
const pickerSessionPreview = process.env.OPENCLAW_TUI_PTY_PICKER_SESSION_PREVIEW;
const pickerSessionDisplayName = process.env.OPENCLAW_TUI_PTY_PICKER_SESSION_DISPLAY_NAME ?? "Picker target";
const initialPluginApprovalSessionKey = process.env.OPENCLAW_TUI_PTY_INITIAL_APPROVAL_SESSION_KEY;
const xaiLimitError = '403 {"code":"The caller does not have permission to execute the specified operation","error":"Your team team-redacted has either used all available credits or reached its monthly spending limit. To continue making API requests, please purchase more credits or raise your spending limit."}';
let currentModel = footerModel ?? "fixture-provider/fixture-model";
let currentThinkingLevel = footerThinkingLevel;
let fastMode = process.env.OPENCLAW_TUI_PTY_FAST_MODE === "true";
function pluginApproval(sessionKey: string) {
return {
id: "plugin:skill-pty",
request: {
title: "Apply workspace skill proposal",
description: "Apply a pending workspace skill proposal into live workspace skills.",
pluginId: "workspace-skills",
severity: "warning" as const,
toolName: "skill_workshop",
allowedDecisions: ["allow-once", "deny"],
sessionKey,
},
createdAtMs: Date.now(),
expiresAtMs: Date.now() + 120_000,
};
}
let pendingPluginApproval: {
id: string;
request: {
@@ -122,7 +139,9 @@ export async function writeTuiPtyFixtureScript(dir: string) {
};
createdAtMs: number;
expiresAtMs: number;
} | null = null;
} | null = initialPluginApprovalSessionKey
? pluginApproval(initialPluginApprovalSessionKey)
: null;
let pendingPluginApprovalRun: { runId: string; sessionKey: string } | null = null;
let pendingTaskSuggestion: {
id: string;
@@ -185,6 +204,9 @@ export async function writeTuiPtyFixtureScript(dir: string) {
}
start() {
if (pendingPluginApproval) {
this.onEvent?.({ event: "plugin.approval.requested", payload: pendingPluginApproval });
}
queueMicrotask(() => this.onConnected?.());
}
@@ -320,20 +342,7 @@ export async function writeTuiPtyFixtureScript(dir: string) {
}
if (opts.message === "history gap proof") { return beginGapHistoryRecovery(this, runId, opts.sessionKey); }
if (opts.message === "skill approval proof" || opts.message === "skill approval gap proof") {
pendingPluginApproval = {
id: "plugin:skill-pty",
request: {
title: "Apply workspace skill proposal",
description: "Apply a pending workspace skill proposal into live workspace skills.",
pluginId: "workspace-skills",
severity: "warning",
toolName: "skill_workshop",
allowedDecisions: ["allow-once", "deny"],
sessionKey: opts.sessionKey,
},
createdAtMs: Date.now(),
expiresAtMs: Date.now() + 120_000,
};
pendingPluginApproval = pluginApproval(opts.sessionKey);
pendingPluginApprovalRun = { runId, sessionKey: opts.sessionKey };
queueMicrotask(() => {
if (opts.message === "skill approval gap proof") {
@@ -0,0 +1,60 @@
import { afterEach, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { buildTuiLastSessionScopeKey, writeTuiLastSessionKey } from "./tui-last-session.js";
import {
disposeActiveTuiFixtures,
objectFieldEquals,
startTuiFixture,
waitForSynchronizedFrameRows,
} from "./tui-pty-harness-fixture-test-support.js";
const STARTUP_TIMEOUT_MS = 60_000;
const REMEMBERED_SESSION_KEY = "agent:main:picker-target";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
afterEach(async () => {
await disposeActiveTuiFixtures();
});
it("hides a stale approval when startup restores the remembered session", async () => {
const stateDir = tempDirs.make("openclaw-tui-identity-");
await writeTuiLastSessionKey({
scopeKey: buildTuiLastSessionScopeKey({
connectionUrl: "pty-fixture://local",
agentId: "main",
sessionScope: "per-sender",
}),
sessionKey: REMEMBERED_SESSION_KEY,
stateDir,
});
const fixture = await startTuiFixture({
env: {
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_TUI_PTY_INITIAL_APPROVAL_SESSION_KEY: "agent:main:main",
OPENCLAW_TUI_PTY_PICKER_FIXTURE: "1",
},
});
try {
await fixture.waitForLogEntry(
(entry) =>
entry.method === "loadHistory" &&
objectFieldEquals(entry, "sessionKey", REMEMBERED_SESSION_KEY),
STARTUP_TIMEOUT_MS,
);
await fixture.waitForLogEntry(
(entry) =>
entry.method === "listPluginApprovals" && objectFieldEquals(entry, "pending", true),
STARTUP_TIMEOUT_MS,
);
const rows = await waitForSynchronizedFrameRows(
fixture.run,
(frame) => frame.some((row) => row.includes("session picker-target")),
STARTUP_TIMEOUT_MS,
);
expect(rows.join("\n")).not.toContain("workspace skill approval");
} finally {
await fixture.cleanup();
}
}, 65_000);
+56 -52
View File
@@ -745,6 +745,17 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
return await runTuiUnlocked(opts);
}
class TuiSessionIdentityState {
sessionKey = "";
sessionId: string | null = null;
readonly generations = new Map<string, number>();
readonly sessionIds = new Map<string, string>();
constructor(public agentId: string) {}
generationKey() {
return JSON.stringify([this.agentId, this.sessionKey]);
}
}
async function runTuiUnlocked(opts: RunTuiOptions): Promise<TuiResult> {
const isLocalMode = opts.local === true || opts.backend !== undefined;
const config = opts.config ?? getRuntimeConfig({ skipPluginValidation: !isLocalMode });
@@ -755,19 +766,15 @@ async function runTuiUnlocked(opts: RunTuiOptions): Promise<TuiResult> {
const sessionScope = (config.session?.scope ?? "per-sender") as SessionScope;
const sessionMainKey = normalizeMainKey(config.session?.mainKey);
const configuredDefaultAgentId = tryResolveDefaultAgentId(config);
let currentAgentId = resolveInitialTuiAgentId({
const initialAgentId = resolveInitialTuiAgentId({
cfg: config,
fallbackAgentId: configuredDefaultAgentId,
initialSessionInput,
agentId: opts.agentId,
});
const agentDefaultId = configuredDefaultAgentId ?? currentAgentId;
const agentDefaultId = configuredDefaultAgentId ?? initialAgentId;
const agentNames = new Map<string, string>();
let currentSessionKey = "";
let rememberedSessionApplied = false;
let currentSessionId: string | null = null;
const sessionGenerations = new Map<string, number>();
const sessionIds = new Map<string, string>();
let connectionGeneration = 0;
const connectionLineage = createTuiConnectionLineage();
let remediationShown = false;
@@ -789,61 +796,58 @@ async function runTuiUnlocked(opts: RunTuiOptions): Promise<TuiResult> {
let statusStartedAt: number | null = null;
let lastActivityStatus = "idle";
let invalidateSessionRunOwnership: () => void = () => undefined;
let notifySessionChanged: () => void = () => undefined;
let retireHistoryAbsentRun: (_runId: string) => void = () => undefined;
const currentSessionGenerationKey = (): string =>
JSON.stringify([currentAgentId, currentSessionKey]);
const readCurrentSessionGeneration = () =>
sessionGenerations.get(currentSessionGenerationKey()) ?? 0;
const writeCurrentSessionGeneration = (value: number) => {
sessionGenerations.set(currentSessionGenerationKey(), value);
};
const state: TuiStateAccess = {
const state: TuiStateAccess & {
sessionGeneration: number;
sessionIdentity: TuiSessionIdentityState;
} = {
sessionIdentity: new TuiSessionIdentityState(initialAgentId),
agentDefaultId,
sessionMainKey,
sessionScope,
agents: [],
get currentAgentId() {
return currentAgentId;
return this.sessionIdentity.agentId;
},
set currentAgentId(value) {
if (currentAgentId === value) {
set currentAgentId(value: string) {
if (this.sessionIdentity.agentId === value) {
return;
}
currentAgentId = value;
this.sessionIdentity.agentId = value;
invalidateSessionRunOwnership();
pluginApprovals?.sessionChanged();
taskSuggestions?.sessionChanged();
notifySessionChanged();
},
get currentSessionKey() {
return currentSessionKey;
return this.sessionIdentity.sessionKey;
},
set currentSessionKey(value) {
currentSessionKey = value;
pluginApprovals?.sessionChanged();
taskSuggestions?.sessionChanged();
set currentSessionKey(value: string) {
this.sessionIdentity.sessionKey = value;
notifySessionChanged();
},
get currentSessionId() {
return currentSessionId;
return this.sessionIdentity.sessionId;
},
set currentSessionId(value) {
set currentSessionId(value: string | null) {
if (value) {
const generationKey = currentSessionGenerationKey();
const previousSessionId = sessionIds.get(generationKey);
const generationKey = this.sessionIdentity.generationKey();
const previousSessionId = this.sessionIdentity.sessionIds.get(generationKey);
// The first ID binds an unresolved selection; reset/replacement owners bump explicitly.
if (previousSessionId && previousSessionId !== value) {
writeCurrentSessionGeneration(readCurrentSessionGeneration() + 1);
this.sessionGeneration += 1;
}
sessionIds.set(generationKey, value);
this.sessionIdentity.sessionIds.set(generationKey, value);
}
currentSessionId = value;
this.sessionIdentity.sessionId = value;
},
get sessionGeneration() {
return readCurrentSessionGeneration();
const generationKey = this.sessionIdentity.generationKey();
return this.sessionIdentity.generations.get(generationKey) ?? 0;
},
set sessionGeneration(value) {
writeCurrentSessionGeneration(Math.max(readCurrentSessionGeneration(), value));
set sessionGeneration(value: number) {
const generationKey = this.sessionIdentity.generationKey();
this.sessionIdentity.generations.set(generationKey, Math.max(this.sessionGeneration, value));
},
activeChatRunId: null,
pendingSubmit: null,
@@ -1033,9 +1037,10 @@ async function runTuiUnlocked(opts: RunTuiOptions): Promise<TuiResult> {
});
};
currentSessionKey = resolveSessionSelection(initialSessionInput).key;
// Initial selection predates controller construction, so it intentionally does not notify.
state.sessionIdentity.sessionKey = resolveSessionSelection(initialSessionInput).key;
const buildLastSessionScopeKeyFor = (sessionKey = currentSessionKey) => {
const buildLastSessionScopeKeyFor = (sessionKey = state.currentSessionKey) => {
const parsed = parseAgentSessionKey(sessionKey);
return buildTuiLastSessionScopeKey({
connectionUrl: client.connection.url,
@@ -1069,7 +1074,7 @@ async function runTuiUnlocked(opts: RunTuiOptions): Promise<TuiResult> {
}
const rememberedSelection = remembered ? resolveSessionSelection(remembered) : null;
const rememberedKey = rememberedSelection?.key ?? null;
if (!rememberedKey || rememberedKey === currentSessionKey) {
if (!rememberedKey || rememberedKey === state.currentSessionKey) {
rememberedSessionApplied = true;
return;
}
@@ -1102,16 +1107,16 @@ async function runTuiUnlocked(opts: RunTuiOptions): Promise<TuiResult> {
currentAgentId: state.currentAgentId,
sessions: sessions.sessions,
});
if (!restored || restored === currentSessionKey) {
if (!restored || restored === state.currentSessionKey) {
return;
}
currentSessionKey = restored;
state.currentSessionKey = restored;
updateHeader();
updateFooter();
};
const updateHeader = () => {
const sessionLabel = formatSessionKey(currentSessionKey);
const sessionLabel = formatSessionKey(state.currentSessionKey);
const agentLabel = formatAgentLabel(state.currentAgentId);
const title = opts.title ?? "openclaw tui";
const text = `${title} - ${client.connection.url} - agent ${agentLabel} - session ${sessionLabel}`;
@@ -1369,7 +1374,7 @@ async function runTuiUnlocked(opts: RunTuiOptions): Promise<TuiResult> {
: undefined;
const updateFooter = () => {
const sessionKeyLabel = formatSessionKey(currentSessionKey);
const sessionKeyLabel = formatSessionKey(state.currentSessionKey);
const sessionLabel = state.sessionInfo.displayName
? `${sessionKeyLabel} (${state.sessionInfo.displayName})`
: sessionKeyLabel;
@@ -1393,7 +1398,7 @@ async function runTuiUnlocked(opts: RunTuiOptions): Promise<TuiResult> {
client,
chatLog,
getAgentId: () => state.currentAgentId,
getSessionKey: () => currentSessionKey,
getSessionKey: () => state.currentSessionKey,
openOverlay,
closeOverlay,
requestRender: () => tui.requestRender(),
@@ -1407,12 +1412,7 @@ async function runTuiUnlocked(opts: RunTuiOptions): Promise<TuiResult> {
},
};
const initialSessionAgentId = (() => {
if (!initialSessionInput) {
return null;
}
return currentAgentId;
})();
const initialSessionAgentId = initialSessionInput ? state.currentAgentId : null;
const sessionActions = createSessionActions({
client,
chatLog,
@@ -1461,13 +1461,17 @@ async function runTuiUnlocked(opts: RunTuiOptions): Promise<TuiResult> {
const taskSuggestions = createTuiTaskSuggestionController({
client,
chatLog,
getAgentId: () => currentAgentId,
getSessionKey: () => currentSessionKey,
getAgentId: () => state.currentAgentId,
getSessionKey: () => state.currentSessionKey,
openOverlay,
closeOverlay,
requestRender: () => tui.requestRender(),
onAccepted: setSession,
});
notifySessionChanged = () => {
pluginApprovals.sessionChanged();
taskSuggestions.sessionChanged();
};
const {
handleChatEvent,
+3
View File
@@ -6,10 +6,12 @@ import { resolveRepoRootPath, sharedVitestConfig } from "./vitest.shared.config.
const targetableIncludes = [
"src/tui/tui-pty-harness-assertion-test-support.test.ts",
"src/tui/tui-pty-harness.e2e.test.ts",
"src/tui/tui-session-identity-pty.e2e.test.ts",
"src/tui/tui-pty-local.e2e.test.ts",
"src/tui/tui-reset-transition-pty.e2e.test.ts",
"tui/tui-pty-harness-assertion-test-support.test.ts",
"tui/tui-pty-harness.e2e.test.ts",
"tui/tui-session-identity-pty.e2e.test.ts",
"tui/tui-pty-local.e2e.test.ts",
"tui/tui-reset-transition-pty.e2e.test.ts",
];
@@ -25,6 +27,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-session-identity-pty.e2e.test.ts",
"tui/tui-reset-transition-pty.e2e.test.ts",
...(includeLocal ? ["tui/tui-pty-local.e2e.test.ts"] : []),
];