diff --git a/CHANGELOG.md b/CHANGELOG.md index a39003f644e..6566329a641 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ Docs: https://docs.openclaw.ai - Browser/Playwright: ignore benign already-handled route races during guarded navigation so browser-page tasks no longer fail when Playwright tears down a route mid-flight. (#68708) Thanks @Steady-ai. - Browser/Linux: detect Chromium-based installs under `/opt/google`, `/opt/brave.com`, `/usr/lib/chromium`, and `/usr/lib/chromium-browser` before asking users to set `browser.executablePath`. (#48563) Thanks @lupuletic. +- Sessions/browser: close tracked browser tabs when idle, daily, `/new`, or `/reset` session rollover archives the previous transcript, preventing tabs from leaking past the old session. Thanks @jakozloski. +- Sessions/forking: fall back to transcript-estimated parent token counts when cached totals are stale or missing, so oversized thread forks start fresh instead of cloning the full parent transcript. Thanks @jalehman. - MCP/CLI: retire bundled MCP runtimes at the end of one-shot `openclaw agent` and `openclaw infer model run` gateway/local executions, so repeated scripted runs do not accumulate stdio MCP child processes. Fixes #71457. - OpenAI/Codex image generation: canonicalize legacy `openai-codex.baseUrl` values such as `https://chatgpt.com/backend-api` to the Codex Responses backend before calling `gpt-image-2`, matching the chat transport. Fixes #71460. - Control UI: make `/usage` use the fresh context snapshot for context percentage, and include cache-write tokens in the Usage overview cache-hit denominator. Fixes #47885. Thanks @imwyvern and @Ante042. diff --git a/src/auto-reply/reply/session-fork.runtime.test.ts b/src/auto-reply/reply/session-fork.runtime.test.ts new file mode 100644 index 00000000000..3f4e4432cc3 --- /dev/null +++ b/src/auto-reply/reply/session-fork.runtime.test.ts @@ -0,0 +1,73 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { SessionEntry } from "../../config/sessions/types.js"; +import { resolveParentForkTokenCountRuntime } from "./session-fork.runtime.js"; + +const roots: string[] = []; + +async function makeRoot(prefix: string): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + roots.push(root); + return root; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); +}); + +describe("resolveParentForkTokenCountRuntime", () => { + it("falls back to transcript-estimated tokens when cached totals are stale", async () => { + const root = await makeRoot("openclaw-parent-fork-token-estimate-"); + const sessionsDir = path.join(root, "sessions"); + await fs.mkdir(sessionsDir); + + const sessionId = "parent-overflow-transcript"; + const sessionFile = path.join(sessionsDir, "parent.jsonl"); + const lines = [ + JSON.stringify({ + type: "session", + version: 3, + id: sessionId, + timestamp: new Date().toISOString(), + cwd: process.cwd(), + }), + ]; + for (let index = 0; index < 40; index += 1) { + const body = `turn-${index} ${"x".repeat(12_000)}`; + lines.push( + JSON.stringify({ + type: "message", + id: `u${index}`, + parentId: index === 0 ? null : `a${index - 1}`, + timestamp: new Date().toISOString(), + message: { role: "user", content: body }, + }), + JSON.stringify({ + type: "message", + id: `a${index}`, + parentId: `u${index}`, + timestamp: new Date().toISOString(), + message: { role: "assistant", content: body }, + }), + ); + } + await fs.writeFile(sessionFile, `${lines.join("\n")}\n`, "utf-8"); + + const entry: SessionEntry = { + sessionId, + sessionFile, + updatedAt: Date.now(), + totalTokens: 1, + totalTokensFresh: false, + }; + + const tokens = resolveParentForkTokenCountRuntime({ + parentEntry: entry, + storePath: path.join(root, "sessions.json"), + }); + + expect(tokens).toBeGreaterThan(100_000); + }); +}); diff --git a/src/auto-reply/reply/session-fork.runtime.ts b/src/auto-reply/reply/session-fork.runtime.ts index 736a1a7792c..96ceafe71a1 100644 --- a/src/auto-reply/reply/session-fork.runtime.ts +++ b/src/auto-reply/reply/session-fork.runtime.ts @@ -1,9 +1,49 @@ import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import type { AgentMessage } from "@mariozechner/pi-agent-core"; import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent"; +import { estimateMessagesTokens } from "../../agents/compaction.js"; import { resolveSessionFilePath } from "../../config/sessions/paths.js"; -import type { SessionEntry } from "../../config/sessions/types.js"; +import { resolveFreshSessionTotalTokens, type SessionEntry } from "../../config/sessions/types.js"; +import { readSessionMessages } from "../../gateway/session-utils.fs.js"; + +function resolvePositiveTokenCount(value: number | undefined): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.floor(value) + : undefined; +} + +export function resolveParentForkTokenCountRuntime(params: { + parentEntry: SessionEntry; + storePath: string; +}): number | undefined { + const freshPersistedTokens = resolveFreshSessionTotalTokens(params.parentEntry); + if (typeof freshPersistedTokens === "number") { + return freshPersistedTokens; + } + + try { + const transcriptMessages = readSessionMessages( + params.parentEntry.sessionId, + params.storePath, + params.parentEntry.sessionFile, + ) as AgentMessage[]; + if (transcriptMessages.length > 0) { + const estimatedTokens = estimateMessagesTokens(transcriptMessages); + const transcriptTokens = resolvePositiveTokenCount( + Number.isFinite(estimatedTokens) ? Math.ceil(estimatedTokens) : undefined, + ); + if (typeof transcriptTokens === "number") { + return transcriptTokens; + } + } + } catch { + // Fall back to cached totals when the parent transcript cannot be read. + } + + return resolvePositiveTokenCount(params.parentEntry.totalTokens); +} export function forkSessionFromParentRuntime(params: { parentEntry: SessionEntry; diff --git a/src/auto-reply/reply/session-fork.ts b/src/auto-reply/reply/session-fork.ts index 1090c046456..cdd3ecaf30a 100644 --- a/src/auto-reply/reply/session-fork.ts +++ b/src/auto-reply/reply/session-fork.ts @@ -30,3 +30,11 @@ export async function forkSessionFromParent(params: { const runtime = await loadSessionForkRuntime(); return runtime.forkSessionFromParentRuntime(params); } + +export async function resolveParentForkTokenCount(params: { + parentEntry: SessionEntry; + storePath: string; +}): Promise { + const runtime = await loadSessionForkRuntime(); + return runtime.resolveParentForkTokenCountRuntime(params); +} diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index bad125b0cc4..661a2438ae2 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -28,11 +28,15 @@ import { initSessionState } from "./session.js"; const sessionForkMocks = vi.hoisted(() => ({ forkSessionFromParent: vi.fn(), + resolveParentForkTokenCount: vi.fn(), nextSessionId: 0, })); const channelSummaryMocks = vi.hoisted(() => ({ buildChannelSummary: vi.fn(async () => [] as string[]), })); +const browserMaintenanceMocks = vi.hoisted(() => ({ + closeTrackedBrowserTabsForSessions: vi.fn(async () => 0), +})); type ForkSessionParamsForTest = { parentEntry: SessionEntry; @@ -42,6 +46,8 @@ type ForkSessionParamsForTest = { vi.mock("./session-fork.js", () => ({ forkSessionFromParent: (...args: [ForkSessionParamsForTest]) => sessionForkMocks.forkSessionFromParent(...args), + resolveParentForkTokenCount: (...args: [{ parentEntry: SessionEntry; storePath: string }]) => + sessionForkMocks.resolveParentForkTokenCount(...args), resolveParentForkMaxTokens: (cfg: { session?: { parentForkMaxTokens?: unknown } }) => { const configured = cfg.session?.parentForkMaxTokens; return typeof configured === "number" && Number.isFinite(configured) && configured >= 0 @@ -50,6 +56,10 @@ vi.mock("./session-fork.js", () => ({ }, })); +vi.mock("../../plugin-sdk/browser-maintenance.js", () => ({ + closeTrackedBrowserTabsForSessions: browserMaintenanceMocks.closeTrackedBrowserTabsForSessions, +})); + vi.mock("../../plugins/hook-runner-global.js", () => ({ getGlobalHookRunner: () => null, })); @@ -248,8 +258,15 @@ function registerCurrentConversationBindingAdapterForTest(params: { beforeEach(() => { channelSummaryMocks.buildChannelSummary.mockReset().mockResolvedValue([]); + browserMaintenanceMocks.closeTrackedBrowserTabsForSessions.mockReset().mockResolvedValue(0); sessionBindingTesting.resetSessionBindingAdaptersForTests(); sessionForkMocks.nextSessionId = 0; + sessionForkMocks.resolveParentForkTokenCount.mockReset().mockImplementation(({ parentEntry }) => { + const tokens = parentEntry.totalTokens; + return typeof tokens === "number" && Number.isFinite(tokens) && tokens > 0 + ? Math.floor(tokens) + : undefined; + }); sessionForkMocks.forkSessionFromParent .mockReset() .mockImplementation(async ({ parentEntry, sessionsDir }: ForkSessionParamsForTest) => { @@ -517,6 +534,66 @@ describe("initSessionState thread forking", () => { expect(result.sessionEntry.sessionFile).not.toBe(parentSessionFile); }); + it("skips fork when resolved parent token estimate exceeds threshold", async () => { + const root = await makeCaseDir("openclaw-thread-session-overflow-estimated-"); + const sessionsDir = path.join(root, "sessions"); + await fs.mkdir(sessionsDir); + + const parentSessionId = "parent-overflow-estimated"; + const parentSessionFile = path.join(sessionsDir, "parent.jsonl"); + await fs.writeFile( + parentSessionFile, + `${JSON.stringify({ + type: "session", + version: 3, + id: parentSessionId, + timestamp: new Date().toISOString(), + cwd: process.cwd(), + })}\n`, + "utf-8", + ); + + const storePath = path.join(root, "sessions.json"); + const parentSessionKey = "agent:main:slack:channel:c1"; + await writeSessionStoreFast(storePath, { + [parentSessionKey]: { + sessionId: parentSessionId, + sessionFile: parentSessionFile, + updatedAt: Date.now(), + totalTokens: 1, + totalTokensFresh: false, + }, + }); + sessionForkMocks.resolveParentForkTokenCount.mockReturnValueOnce(170_000); + + const cfg = { + session: { store: storePath }, + } as OpenClawConfig; + + const threadSessionKey = "agent:main:slack:channel:c1:thread:estimated"; + const result = await initSessionState({ + ctx: { + Body: "Thread reply", + SessionKey: threadSessionKey, + ParentSessionKey: parentSessionKey, + }, + cfg, + commandAuthorized: true, + }); + + expect(sessionForkMocks.resolveParentForkTokenCount).toHaveBeenCalledWith({ + parentEntry: expect.objectContaining({ + sessionId: parentSessionId, + totalTokensFresh: false, + }), + storePath, + }); + expect(result.sessionEntry.forkedFromParent).toBe(true); + expect(result.sessionEntry.sessionId).not.toBe(parentSessionId); + expect(result.sessionEntry.sessionFile).not.toBe(parentSessionFile); + expect(sessionForkMocks.forkSessionFromParent).not.toHaveBeenCalled(); + }); + it("respects session.parentForkMaxTokens override", async () => { const root = await makeCaseDir("openclaw-thread-session-overflow-override-"); const sessionsDir = path.join(root, "sessions"); @@ -1616,6 +1693,95 @@ describe("initSessionState reset policy", () => { }); }); +describe("initSessionState browser tab cleanup", () => { + it("closes tracked browser tabs when idle session expires", async () => { + vi.setSystemTime(new Date(2026, 0, 18, 5, 30, 0)); + const storePath = await createStorePath("openclaw-tab-cleanup-idle-"); + const sessionKey = "agent:main:whatsapp:dm:tab-idle"; + const existingSessionId = "tab-idle-session-id"; + + await writeSessionStoreFast(storePath, { + [sessionKey]: { + sessionId: existingSessionId, + updatedAt: new Date(2026, 0, 18, 4, 45, 0).getTime(), + }, + }); + + const cfg = { + session: { + store: storePath, + reset: { mode: "daily", atHour: 4, idleMinutes: 30 }, + }, + } as OpenClawConfig; + const result = await initSessionState({ + ctx: { Body: "hello", SessionKey: sessionKey }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(browserMaintenanceMocks.closeTrackedBrowserTabsForSessions).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKeys: expect.arrayContaining([existingSessionId, sessionKey]), + }), + ); + }); + + it("closes tracked browser tabs on explicit /new reset", async () => { + const storePath = await createStorePath("openclaw-tab-cleanup-reset-"); + const sessionKey = "agent:main:telegram:dm:tab-reset"; + const existingSessionId = "tab-reset-session-id"; + + await writeSessionStoreFast(storePath, { + [sessionKey]: { + sessionId: existingSessionId, + updatedAt: Date.now(), + }, + }); + + const cfg = { + session: { store: storePath, idleMinutes: 999 }, + } as OpenClawConfig; + const result = await initSessionState({ + ctx: { + Body: "/new", + RawBody: "/new", + CommandBody: "/new", + SessionKey: sessionKey, + }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(browserMaintenanceMocks.closeTrackedBrowserTabsForSessions).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKeys: expect.arrayContaining([existingSessionId, sessionKey]), + }), + ); + }); + + it("does not close browser tabs for a fresh session without previous state", async () => { + const storePath = await createStorePath("openclaw-tab-cleanup-fresh-"); + const sessionKey = "agent:main:telegram:dm:tab-fresh"; + + const cfg = { + session: { store: storePath, idleMinutes: 999 }, + } as OpenClawConfig; + const result = await initSessionState({ + ctx: { + Body: "hello", + SessionKey: sessionKey, + }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(browserMaintenanceMocks.closeTrackedBrowserTabsForSessions).not.toHaveBeenCalled(); + }); +}); + describe("initSessionState channel reset overrides", () => { it("uses channel-specific reset policy when configured", async () => { const root = await makeCaseDir("openclaw-channel-idle-"); diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 3b56e4de97e..d703eb32243 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -35,6 +35,7 @@ import type { TtsAutoMode } from "../../config/types.tts.js"; import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js"; import { deliverSessionMaintenanceWarning } from "../../infra/session-maintenance-warning.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { closeTrackedBrowserTabsForSessions } from "../../plugin-sdk/browser-maintenance.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import type { PluginHookSessionEndReason } from "../../plugins/hook-types.js"; import { isAcpSessionKey, normalizeMainKey } from "../../routing/session-key.js"; @@ -58,7 +59,11 @@ import { resolveLastChannelRaw, resolveLastToRaw, } from "./session-delivery.js"; -import { forkSessionFromParent, resolveParentForkMaxTokens } from "./session-fork.js"; +import { + forkSessionFromParent, + resolveParentForkMaxTokens, + resolveParentForkTokenCount, +} from "./session-fork.js"; import { buildSessionEndHookPayload, buildSessionStartHookPayload } from "./session-hooks.js"; const log = createSubsystemLogger("session-init"); @@ -678,8 +683,19 @@ export async function initSessionState(params: { sessionStore[parentSessionKey] && !alreadyForked ) { - const parentTokens = sessionStore[parentSessionKey].totalTokens ?? 0; - if (parentForkMaxTokens > 0 && parentTokens > parentForkMaxTokens) { + const parentEntry = sessionStore[parentSessionKey]; + const parentTokens = + parentForkMaxTokens > 0 + ? await resolveParentForkTokenCount({ + parentEntry, + storePath, + }) + : undefined; + if ( + parentForkMaxTokens > 0 && + typeof parentTokens === "number" && + parentTokens > parentForkMaxTokens + ) { // Parent context is too large — forking would create a thread session // that immediately overflows the model's context window. Start fresh // instead and mark as forked to prevent re-attempts. See #26905. @@ -691,10 +707,10 @@ export async function initSessionState(params: { } else { log.warn( `forking from parent session: parentKey=${parentSessionKey} → sessionKey=${sessionKey} ` + - `parentTokens=${parentTokens}`, + `parentTokens=${parentTokens ?? "unknown"}`, ); const forked = await forkSessionFromParent({ - parentEntry: sessionStore[parentSessionKey], + parentEntry, agentId, sessionsDir: path.dirname(storePath), }); @@ -806,6 +822,12 @@ export async function initSessionState(params: { sessionFile: previousSessionEntry.sessionFile, reason: previousSessionEndReason ?? "unknown", }); + void closeTrackedBrowserTabsForSessions({ + sessionKeys: [previousSessionEntry.sessionId, sessionKey], + onWarn: (message) => log.warn(message), + }).catch((error) => { + log.warn(`browser tab cleanup failed: ${String(error)}`); + }); } const sessionCtx: TemplateContext = {