mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
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:
committed by
GitHub
parent
0af3b865d7
commit
7b4981bfd2
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"] : []),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user