From 9d5d33201aad8a75d0f4adaf30e97dd08939ca99 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 14 Sep 2026 13:15:08 +0800 Subject: [PATCH] refactor(reply): share fast-mode session persistence (#147847) --- .../reply/commands-session-usage.test.ts | 92 +++++++++++++++++++ src/auto-reply/reply/commands-session.ts | 31 +++---- 2 files changed, 103 insertions(+), 20 deletions(-) diff --git a/src/auto-reply/reply/commands-session-usage.test.ts b/src/auto-reply/reply/commands-session-usage.test.ts index d905a3ec0df4..cf3adeff69ff 100644 --- a/src/auto-reply/reply/commands-session-usage.test.ts +++ b/src/auto-reply/reply/commands-session-usage.test.ts @@ -1,11 +1,14 @@ // Tests session usage command output and token accounting summaries. +import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; +import { loadSessionEntry, replaceSessionEntry } from "../../config/sessions/session-accessor.js"; import type { CostUsageSummary, CostUsageTotals, SessionCostSummary, } from "../../infra/session-cost-usage.js"; +import { withTempDir } from "../../test-utils/temp-dir.js"; import { handleFastCommand, handleUsageCommand } from "./commands-session.js"; import type { HandleCommandsParams } from "./commands-types.js"; @@ -436,6 +439,95 @@ describe("handleFastCommand", () => { expect(sessionEntry?.fastMode).toBe(true); }); + it.each([ + { mode: "on", value: true, text: "⚙️ Fast mode enabled." }, + { mode: "off", value: false, text: "⚙️ Fast mode disabled." }, + { mode: "auto", value: "auto", text: "⚙️ Fast mode set to auto." }, + ] as const)("persists /fast $mode on the canonical target", async ({ mode, value, text }) => { + await withTempDir("openclaw-fast-command-", async (dir) => { + const params = buildUsageParams(); + params.command.commandBodyNormalized = `/fast ${mode}`; + params.storePath = path.join(dir, "sessions.json"); + params.sessionEntry = { sessionId: "wrapper-session", updatedAt: 1, fastMode: false }; + const target: NonNullable = { + sessionId: "target-session", + updatedAt: 1, + fastMode: mode !== "on", + }; + params.initialSessionEntry = { ...target }; + params.sessionStore = { [params.sessionKey]: target }; + await replaceSessionEntry( + { storePath: params.storePath, sessionKey: params.sessionKey }, + { ...target }, + ); + + const result = await handleFastCommand(params, true); + + expect(result).toEqual({ shouldContinue: false, reply: { text } }); + expect( + loadSessionEntry({ storePath: params.storePath, sessionKey: params.sessionKey })?.fastMode, + ).toBe(value); + expect(params.sessionStore[params.sessionKey]?.fastMode).toBe(value); + expect(params.sessionEntry.fastMode).toBe(false); + expect(resolveFastModeStateMock).not.toHaveBeenCalled(); + }); + }); + + it.each([ + ["on", "⚙️ Fast mode enabled."], + ["off", "⚙️ Fast mode disabled."], + ["auto", "⚙️ Fast mode set to auto."], + ["default", "⚙️ Fast mode reset to default."], + ])("keeps the /fast %s reply without a target entry", async (mode, text) => { + const params = buildUsageParams(); + params.command.commandBodyNormalized = `/fast ${mode}`; + params.sessionEntry = undefined; + + expect(await handleFastCommand(params, true)).toEqual({ + shouldContinue: false, + reply: { text }, + }); + expect(params.sessionStore).toBeUndefined(); + expect(resolveFastModeStateMock).not.toHaveBeenCalled(); + }); + + it("rejects an invalid /fast mode without mutating the target", async () => { + const params = buildUsageParams(); + params.command.commandBodyNormalized = "/fast invalid"; + const target = { sessionId: "target-session", updatedAt: 1, fastMode: true }; + params.sessionEntry = target; + params.sessionStore = { [params.sessionKey]: target }; + + expect(await handleFastCommand(params, true)).toEqual({ + shouldContinue: false, + reply: { text: "⚙️ Usage: /fast status|auto|on|off|default" }, + }); + expect(target).toEqual({ sessionId: "target-session", updatedAt: 1, fastMode: true }); + expect(resolveFastModeStateMock).not.toHaveBeenCalled(); + }); + + it.each(["default", "off"])("reports a /fast %s persistence conflict", async (mode) => { + await withTempDir("openclaw-fast-conflict-", async (dir) => { + const params = buildUsageParams(); + params.command.commandBodyNormalized = `/fast ${mode}`; + params.storePath = path.join(dir, "sessions.json"); + const target = { sessionId: "missing-session", updatedAt: 1, fastMode: true }; + params.sessionEntry = target; + params.initialSessionEntry = { ...target }; + params.sessionStore = { [params.sessionKey]: target }; + + expect(await handleFastCommand(params, true)).toEqual({ + shouldContinue: false, + reply: { + text: "⚠️ Session changed before this setting could be saved. Retry the command.", + }, + }); + expect( + loadSessionEntry({ storePath: params.storePath, sessionKey: params.sessionKey }), + ).toBeUndefined(); + }); + }); + it("clears fast mode for /fast default", async () => { const params = buildUsageParams(); params.command.commandBodyNormalized = "/fast default"; diff --git a/src/auto-reply/reply/commands-session.ts b/src/auto-reply/reply/commands-session.ts index 256517eb634e..317799befc72 100644 --- a/src/auto-reply/reply/commands-session.ts +++ b/src/auto-reply/reply/commands-session.ts @@ -325,27 +325,16 @@ export const handleFastCommand: CommandHandler = defineAuthorizedTextCommand( const targetSessionEntry = params.sessionStore?.[params.sessionKey] ?? params.sessionEntry; const resetsToDefault = isSessionDefaultDirectiveValue(rawMode); const nextMode = resetsToDefault ? undefined : normalizeFastMode(rawMode); - if (nextMode === undefined) { - if (resetsToDefault) { - if (targetSessionEntry && params.sessionStore && params.sessionKey) { - delete targetSessionEntry.fastMode; - if ( - !(await persistCommandSession({ - ...params, - sessionEntry: targetSessionEntry, - touchedFields: ["fastMode"], - })) - ) { - return sessionEntryPersistenceConflictReply(); - } - } - return sessionCommandReply("⚙️ Fast mode reset to default."); - } + if (nextMode === undefined && !resetsToDefault) { return sessionCommandReply("⚙️ Usage: /fast status|auto|on|off|default"); } if (targetSessionEntry && params.sessionStore && params.sessionKey) { - targetSessionEntry.fastMode = nextMode; + if (resetsToDefault) { + delete targetSessionEntry.fastMode; + } else { + targetSessionEntry.fastMode = nextMode; + } if ( !(await persistCommandSession({ ...params, @@ -358,9 +347,11 @@ export const handleFastCommand: CommandHandler = defineAuthorizedTextCommand( } return sessionCommandReply( - nextMode === "auto" - ? "⚙️ Fast mode set to auto." - : `⚙️ Fast mode ${nextMode ? "enabled" : "disabled"}.`, + resetsToDefault + ? "⚙️ Fast mode reset to default." + : nextMode === "auto" + ? "⚙️ Fast mode set to auto." + : `⚙️ Fast mode ${nextMode ? "enabled" : "disabled"}.`, ); }, );