

























@@ -28,11 +28,15 @@ import { initSessionState } from "./session.js";
28282929const sessionForkMocks = vi.hoisted(() => ({
3030forkSessionFromParent: vi.fn(),
31+resolveParentForkTokenCount: vi.fn(),
3132nextSessionId: 0,
3233}));
3334const channelSummaryMocks = vi.hoisted(() => ({
3435buildChannelSummary: vi.fn(async () => [] as string[]),
3536}));
37+const browserMaintenanceMocks = vi.hoisted(() => ({
38+closeTrackedBrowserTabsForSessions: vi.fn(async () => 0),
39+}));
36403741type ForkSessionParamsForTest = {
3842parentEntry: SessionEntry;
@@ -42,6 +46,8 @@ type ForkSessionParamsForTest = {
4246vi.mock("./session-fork.js", () => ({
4347forkSessionFromParent: (...args: [ForkSessionParamsForTest]) =>
4448sessionForkMocks.forkSessionFromParent(...args),
49+resolveParentForkTokenCount: (...args: [{ parentEntry: SessionEntry; storePath: string }]) =>
50+sessionForkMocks.resolveParentForkTokenCount(...args),
4551resolveParentForkMaxTokens: (cfg: { session?: { parentForkMaxTokens?: unknown } }) => {
4652const configured = cfg.session?.parentForkMaxTokens;
4753return typeof configured === "number" && Number.isFinite(configured) && configured >= 0
@@ -50,6 +56,10 @@ vi.mock("./session-fork.js", () => ({
5056},
5157}));
525859+vi.mock("../../plugin-sdk/browser-maintenance.js", () => ({
60+closeTrackedBrowserTabsForSessions: browserMaintenanceMocks.closeTrackedBrowserTabsForSessions,
61+}));
62+5363vi.mock("../../plugins/hook-runner-global.js", () => ({
5464getGlobalHookRunner: () => null,
5565}));
@@ -248,8 +258,15 @@ function registerCurrentConversationBindingAdapterForTest(params: {
248258249259beforeEach(() => {
250260channelSummaryMocks.buildChannelSummary.mockReset().mockResolvedValue([]);
261+browserMaintenanceMocks.closeTrackedBrowserTabsForSessions.mockReset().mockResolvedValue(0);
251262sessionBindingTesting.resetSessionBindingAdaptersForTests();
252263sessionForkMocks.nextSessionId = 0;
264+sessionForkMocks.resolveParentForkTokenCount.mockReset().mockImplementation(({ parentEntry }) => {
265+const tokens = parentEntry.totalTokens;
266+return typeof tokens === "number" && Number.isFinite(tokens) && tokens > 0
267+ ? Math.floor(tokens)
268+ : undefined;
269+});
253270sessionForkMocks.forkSessionFromParent
254271.mockReset()
255272.mockImplementation(async ({ parentEntry, sessionsDir }: ForkSessionParamsForTest) => {
@@ -517,6 +534,66 @@ describe("initSessionState thread forking", () => {
517534expect(result.sessionEntry.sessionFile).not.toBe(parentSessionFile);
518535});
519536537+it("skips fork when resolved parent token estimate exceeds threshold", async () => {
538+const root = await makeCaseDir("openclaw-thread-session-overflow-estimated-");
539+const sessionsDir = path.join(root, "sessions");
540+await fs.mkdir(sessionsDir);
541+542+const parentSessionId = "parent-overflow-estimated";
543+const parentSessionFile = path.join(sessionsDir, "parent.jsonl");
544+await fs.writeFile(
545+parentSessionFile,
546+`${JSON.stringify({
547+ type: "session",
548+ version: 3,
549+ id: parentSessionId,
550+ timestamp: new Date().toISOString(),
551+ cwd: process.cwd(),
552+ })}\n`,
553+"utf-8",
554+);
555+556+const storePath = path.join(root, "sessions.json");
557+const parentSessionKey = "agent:main:slack:channel:c1";
558+await writeSessionStoreFast(storePath, {
559+[parentSessionKey]: {
560+sessionId: parentSessionId,
561+sessionFile: parentSessionFile,
562+updatedAt: Date.now(),
563+totalTokens: 1,
564+totalTokensFresh: false,
565+},
566+});
567+sessionForkMocks.resolveParentForkTokenCount.mockReturnValueOnce(170_000);
568+569+const cfg = {
570+session: { store: storePath },
571+} as OpenClawConfig;
572+573+const threadSessionKey = "agent:main:slack:channel:c1:thread:estimated";
574+const result = await initSessionState({
575+ctx: {
576+Body: "Thread reply",
577+SessionKey: threadSessionKey,
578+ParentSessionKey: parentSessionKey,
579+},
580+ cfg,
581+commandAuthorized: true,
582+});
583+584+expect(sessionForkMocks.resolveParentForkTokenCount).toHaveBeenCalledWith({
585+parentEntry: expect.objectContaining({
586+sessionId: parentSessionId,
587+totalTokensFresh: false,
588+}),
589+ storePath,
590+});
591+expect(result.sessionEntry.forkedFromParent).toBe(true);
592+expect(result.sessionEntry.sessionId).not.toBe(parentSessionId);
593+expect(result.sessionEntry.sessionFile).not.toBe(parentSessionFile);
594+expect(sessionForkMocks.forkSessionFromParent).not.toHaveBeenCalled();
595+});
596+520597it("respects session.parentForkMaxTokens override", async () => {
521598const root = await makeCaseDir("openclaw-thread-session-overflow-override-");
522599const sessionsDir = path.join(root, "sessions");
@@ -1616,6 +1693,95 @@ describe("initSessionState reset policy", () => {
16161693});
16171694});
161816951696+describe("initSessionState browser tab cleanup", () => {
1697+it("closes tracked browser tabs when idle session expires", async () => {
1698+vi.setSystemTime(new Date(2026, 0, 18, 5, 30, 0));
1699+const storePath = await createStorePath("openclaw-tab-cleanup-idle-");
1700+const sessionKey = "agent:main:whatsapp:dm:tab-idle";
1701+const existingSessionId = "tab-idle-session-id";
1702+1703+await writeSessionStoreFast(storePath, {
1704+[sessionKey]: {
1705+sessionId: existingSessionId,
1706+updatedAt: new Date(2026, 0, 18, 4, 45, 0).getTime(),
1707+},
1708+});
1709+1710+const cfg = {
1711+session: {
1712+store: storePath,
1713+reset: { mode: "daily", atHour: 4, idleMinutes: 30 },
1714+},
1715+} as OpenClawConfig;
1716+const result = await initSessionState({
1717+ctx: { Body: "hello", SessionKey: sessionKey },
1718+ cfg,
1719+commandAuthorized: true,
1720+});
1721+1722+expect(result.isNewSession).toBe(true);
1723+expect(browserMaintenanceMocks.closeTrackedBrowserTabsForSessions).toHaveBeenCalledWith(
1724+expect.objectContaining({
1725+sessionKeys: expect.arrayContaining([existingSessionId, sessionKey]),
1726+}),
1727+);
1728+});
1729+1730+it("closes tracked browser tabs on explicit /new reset", async () => {
1731+const storePath = await createStorePath("openclaw-tab-cleanup-reset-");
1732+const sessionKey = "agent:main:telegram:dm:tab-reset";
1733+const existingSessionId = "tab-reset-session-id";
1734+1735+await writeSessionStoreFast(storePath, {
1736+[sessionKey]: {
1737+sessionId: existingSessionId,
1738+updatedAt: Date.now(),
1739+},
1740+});
1741+1742+const cfg = {
1743+session: { store: storePath, idleMinutes: 999 },
1744+} as OpenClawConfig;
1745+const result = await initSessionState({
1746+ctx: {
1747+Body: "/new",
1748+RawBody: "/new",
1749+CommandBody: "/new",
1750+SessionKey: sessionKey,
1751+},
1752+ cfg,
1753+commandAuthorized: true,
1754+});
1755+1756+expect(result.isNewSession).toBe(true);
1757+expect(browserMaintenanceMocks.closeTrackedBrowserTabsForSessions).toHaveBeenCalledWith(
1758+expect.objectContaining({
1759+sessionKeys: expect.arrayContaining([existingSessionId, sessionKey]),
1760+}),
1761+);
1762+});
1763+1764+it("does not close browser tabs for a fresh session without previous state", async () => {
1765+const storePath = await createStorePath("openclaw-tab-cleanup-fresh-");
1766+const sessionKey = "agent:main:telegram:dm:tab-fresh";
1767+1768+const cfg = {
1769+session: { store: storePath, idleMinutes: 999 },
1770+} as OpenClawConfig;
1771+const result = await initSessionState({
1772+ctx: {
1773+Body: "hello",
1774+SessionKey: sessionKey,
1775+},
1776+ cfg,
1777+commandAuthorized: true,
1778+});
1779+1780+expect(result.isNewSession).toBe(true);
1781+expect(browserMaintenanceMocks.closeTrackedBrowserTabsForSessions).not.toHaveBeenCalled();
1782+});
1783+});
1784+16191785describe("initSessionState channel reset overrides", () => {
16201786it("uses channel-specific reset policy when configured", async () => {
16211787const root = await makeCaseDir("openclaw-channel-idle-");
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。