diff --git a/src/tui/tui-pty-harness-fixture-test-support.ts b/src/tui/tui-pty-harness-fixture-test-support.ts index 9d4d898ad61c..f4177769f4e1 100644 --- a/src/tui/tui-pty-harness-fixture-test-support.ts +++ b/src/tui/tui-pty-harness-fixture-test-support.ts @@ -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") { diff --git a/src/tui/tui-session-identity-pty.e2e.test.ts b/src/tui/tui-session-identity-pty.e2e.test.ts new file mode 100644 index 000000000000..4925b420a71e --- /dev/null +++ b/src/tui/tui-session-identity-pty.e2e.test.ts @@ -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); diff --git a/src/tui/tui.ts b/src/tui/tui.ts index d5d68077861f..1f405d0f6d1b 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -745,6 +745,17 @@ export async function runTui(opts: RunTuiOptions): Promise { return await runTuiUnlocked(opts); } +class TuiSessionIdentityState { + sessionKey = ""; + sessionId: string | null = null; + readonly generations = new Map(); + readonly sessionIds = new Map(); + constructor(public agentId: string) {} + generationKey() { + return JSON.stringify([this.agentId, this.sessionKey]); + } +} + async function runTuiUnlocked(opts: RunTuiOptions): Promise { 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 { 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(); - let currentSessionKey = ""; let rememberedSessionApplied = false; - let currentSessionId: string | null = null; - const sessionGenerations = new Map(); - const sessionIds = new Map(); let connectionGeneration = 0; const connectionLineage = createTuiConnectionLineage(); let remediationShown = false; @@ -789,61 +796,58 @@ async function runTuiUnlocked(opts: RunTuiOptions): Promise { 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 { }); }; - 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 { } 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 { 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 { : 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 { 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 { }, }; - 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 { 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, diff --git a/test/vitest/vitest.tui-pty.config.ts b/test/vitest/vitest.tui-pty.config.ts index d18cdf19ab46..aa81c60a77d4 100644 --- a/test/vitest/vitest.tui-pty.config.ts +++ b/test/vitest/vitest.tui-pty.config.ts @@ -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) { 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"] : []), ];