From 714598774fb54fb0f5e02508d21bce9d81bb8b67 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:17:52 -0500 Subject: [PATCH] feat: add soft reset command (#68635) * feat: add soft reset command * fix: harden soft reset follow-up behavior * fix: accept whitespace-delimited soft reset tails * test: cover newline soft reset normalization * fix: preserve stale sessions for soft reset * fix: gate soft reset stale bypass * fix: align soft reset auth gating * fix: normalize soft reset session detection * test: cover multiline soft reset session state * test: cover multiline soft reset parsing --- CHANGELOG.md | 1 + docs/tools/slash-commands.md | 1 + src/auto-reply/reply/commands-context.test.ts | 24 ++ .../reply/commands-reset-hooks.test.ts | 148 ++++++++++- .../reply/commands-reset-mode.test.ts | 25 ++ src/auto-reply/reply/commands-reset-mode.ts | 24 ++ src/auto-reply/reply/commands-reset.ts | 102 +++++++- src/auto-reply/reply/commands-types.ts | 4 + src/auto-reply/reply/get-reply-fast-path.ts | 11 +- .../reply/get-reply-run.media-only.test.ts | 31 +++ src/auto-reply/reply/get-reply-run.ts | 25 +- .../reply/get-reply.fast-path.test.ts | 66 +++++ src/auto-reply/reply/mentions.test.ts | 7 + src/auto-reply/reply/session.test.ts | 240 ++++++++++++++++++ src/auto-reply/reply/session.ts | 45 +++- 15 files changed, 732 insertions(+), 22 deletions(-) create mode 100644 src/auto-reply/reply/commands-reset-mode.test.ts create mode 100644 src/auto-reply/reply/commands-reset-mode.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 842627939b0..2c173e777e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Sessions/reset: add `/reset soft [message]` for an in-place reset that keeps the current transcript/session while clearing reused CLI backend bindings and reloading startup/bootstrap context. (#68635) Thanks @Takhoffman. - Exec/YOLO: stop rejecting gateway-host exec in `security=full` plus `ask=off` mode via the Python/Node script preflight hardening path, so promptless YOLO exec once again runs direct interpreter stdin and heredoc forms such as `node <<'NODE' ... NODE`. - OpenAI Codex: normalize legacy `openai-completions` transport overrides on default OpenAI/Codex and GitHub Copilot-compatible hosts back to the native Codex Responses transport while leaving custom proxies untouched. (#45304, #42194) Thanks @dyss1992 and @DeadlySilent. - Anthropic/plugins: scope Anthropic `api: "anthropic-messages"` defaulting to Anthropic-owned providers, so `openai-codex` and other providers without an explicit `api` no longer get rewritten to the wrong transport. Fixes #64534. diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 515b166093b..885e1e86fef 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -90,6 +90,7 @@ Current source-of-truth: Built-in commands available today: - `/new [model]` starts a new session; `/reset` is the reset alias. +- `/reset soft [message]` keeps the current transcript, drops reused CLI backend session ids, and reruns startup/system-prompt loading in-place. - `/compact [instructions]` compacts the session context. See [/concepts/compaction](/concepts/compaction). - `/stop` aborts the current run. - `/session idle ` and `/session max-age ` manage thread-binding expiry. diff --git a/src/auto-reply/reply/commands-context.test.ts b/src/auto-reply/reply/commands-context.test.ts index 7b6f5291820..f58fec5e075 100644 --- a/src/auto-reply/reply/commands-context.test.ts +++ b/src/auto-reply/reply/commands-context.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { buildCommandContext } from "./commands-context.js"; +import { stripStructuralPrefixes } from "./mentions.js"; import { buildTestCtx } from "./test-ctx.js"; describe("buildCommandContext", () => { @@ -26,4 +27,27 @@ describe("buildCommandContext", () => { expect(result.commandBodyNormalized).toBe("/whoami"); }); + + it("preserves multiline soft reset tails after structural normalization", () => { + const ctx = buildTestCtx({ + Provider: "whatsapp", + Surface: "whatsapp", + From: "user", + To: "bot", + Body: "/reset soft\nre-read persona files", + RawBody: "/reset soft\nre-read persona files", + CommandBody: "/reset soft\nre-read persona files", + BodyForCommands: "/reset soft\nre-read persona files", + }); + + const result = buildCommandContext({ + ctx, + cfg: {} as OpenClawConfig, + isGroup: false, + triggerBodyNormalized: stripStructuralPrefixes("/reset soft\nre-read persona files"), + commandAuthorized: true, + }); + + expect(result.commandBodyNormalized).toBe("/reset soft re-read persona files"); + }); }); diff --git a/src/auto-reply/reply/commands-reset-hooks.test.ts b/src/auto-reply/reply/commands-reset-hooks.test.ts index bd687953583..4bfe8ef9204 100644 --- a/src/auto-reply/reply/commands-reset-hooks.test.ts +++ b/src/auto-reply/reply/commands-reset-hooks.test.ts @@ -1,6 +1,7 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import type { MsgContext } from "../templating.js"; +import * as bootstrapCache from "../../agents/bootstrap-cache.js"; import { maybeHandleResetCommand } from "./commands-reset.js"; import type { HandleCommandsParams } from "./commands-types.js"; import { parseInlineDirectives } from "./directive-handling.parse.js"; @@ -105,13 +106,20 @@ function buildResetParams( } describe("handleCommands reset hooks", () => { + let clearBootstrapSnapshotSpy: ReturnType; + beforeEach(() => { vi.clearAllMocks(); + clearBootstrapSnapshotSpy = vi.spyOn(bootstrapCache, "clearBootstrapSnapshot"); resetMocks.resetConfiguredBindingTargetInPlace.mockResolvedValue({ ok: true }); resetMocks.resolveBoundAcpThreadSessionKey.mockReturnValue(undefined); triggerInternalHookMock.mockResolvedValue(undefined); }); + afterEach(() => { + clearBootstrapSnapshotSpy.mockRestore(); + }); + it("triggers hooks for /new commands", async () => { const cases = [ { @@ -284,4 +292,142 @@ describe("handleCommands reset hooks", () => { }), ); }); + + it("marks soft reset turns and emits reset hooks", async () => { + const params = buildResetParams("/reset soft", { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig); + params.sessionEntry = { + sessionId: "session-1", + updatedAt: Date.now(), + cliSessionIds: { "claude-cli": "cli-session-1" }, + cliSessionBindings: { + "claude-cli": { + sessionId: "cli-session-1", + extraSystemPromptHash: "prompt-hash", + }, + }, + claudeCliSessionId: "cli-session-1", + } as HandleCommandsParams["sessionEntry"]; + + const result = await maybeHandleResetCommand(params); + + expect(result).toBeNull(); + expect(triggerInternalHookMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: "command", + action: "reset", + context: expect.objectContaining({ + previousSessionEntry: expect.objectContaining({ + sessionId: "session-1", + }), + }), + }), + ); + expect(params.command.resetHookTriggered).toBe(true); + expect(params.command.softResetTriggered).toBe(true); + expect(params.command.softResetTail).toBe(""); + expect(params.sessionEntry?.cliSessionIds).toBeUndefined(); + expect(params.sessionEntry?.cliSessionBindings).toBeUndefined(); + expect(params.sessionEntry?.claudeCliSessionId).toBeUndefined(); + expect(clearBootstrapSnapshotSpy).toHaveBeenCalledWith("agent:main:main"); + }); + + it("requires operator.admin for internal /reset soft commands", async () => { + const params = buildResetParams( + "/reset soft", + { + commands: { text: true }, + channels: { webchat: { allowFrom: ["*"] } }, + } as OpenClawConfig, + { + Provider: "webchat", + Surface: "webchat", + CommandAuthorized: true, + GatewayClientScopes: ["operator.write"], + }, + ); + params.command.isAuthorizedSender = true; + params.command.channel = "webchat"; + params.command.channelId = "webchat"; + params.command.surface = "webchat"; + + const result = await maybeHandleResetCommand(params); + + expect(result).toEqual({ shouldContinue: false }); + expect(triggerInternalHookMock).not.toHaveBeenCalled(); + expect(params.command.softResetTriggered).not.toBe(true); + expect(clearBootstrapSnapshotSpy).not.toHaveBeenCalled(); + }); + + it("clears both sessionStore and sessionEntry when they are distinct objects", async () => { + const params = buildResetParams("/reset soft", { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig); + params.sessionEntry = { + sessionId: "session-direct", + updatedAt: 1, + cliSessionIds: { "claude-cli": "cli-session-direct" }, + cliSessionBindings: { + "claude-cli": { + sessionId: "cli-session-direct", + extraSystemPromptHash: "prompt-hash-direct", + }, + }, + claudeCliSessionId: "cli-session-direct", + } as HandleCommandsParams["sessionEntry"]; + params.sessionStore = { + [params.sessionKey]: { + sessionId: "session-store", + updatedAt: 2, + cliSessionIds: { "claude-cli": "cli-session-store" }, + cliSessionBindings: { + "claude-cli": { + sessionId: "cli-session-store", + extraSystemPromptHash: "prompt-hash-store", + }, + }, + claudeCliSessionId: "cli-session-store", + }, + } as Record>; + + const result = await maybeHandleResetCommand(params); + + expect(result).toBeNull(); + expect(params.sessionEntry?.cliSessionIds).toBeUndefined(); + expect(params.sessionEntry?.cliSessionBindings).toBeUndefined(); + expect(params.sessionEntry?.claudeCliSessionId).toBeUndefined(); + expect(params.sessionStore?.[params.sessionKey]?.cliSessionIds).toBeUndefined(); + expect(params.sessionStore?.[params.sessionKey]?.cliSessionBindings).toBeUndefined(); + expect(params.sessionStore?.[params.sessionKey]?.claudeCliSessionId).toBeUndefined(); + }); + + it("rejects soft reset for bound ACP sessions", async () => { + resetMocks.resolveBoundAcpThreadSessionKey.mockReturnValue( + "agent:claude:acp:binding:discord:default:9373ab192b2317f4", + ); + const params = buildResetParams( + "/reset soft", + { + commands: { text: true }, + channels: { discord: { allowFrom: ["*"] } }, + } as OpenClawConfig, + { + Provider: "discord", + Surface: "discord", + CommandSource: "native", + }, + ); + + const result = await maybeHandleResetCommand(params); + + expect(result).toEqual({ + shouldContinue: false, + reply: { text: "Usage: /reset soft is not available for ACP-bound sessions yet." }, + }); + expect(triggerInternalHookMock).not.toHaveBeenCalled(); + expect(resetMocks.resetConfiguredBindingTargetInPlace).not.toHaveBeenCalled(); + }); }); diff --git a/src/auto-reply/reply/commands-reset-mode.test.ts b/src/auto-reply/reply/commands-reset-mode.test.ts new file mode 100644 index 00000000000..17c7f0c05af --- /dev/null +++ b/src/auto-reply/reply/commands-reset-mode.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { parseSoftResetCommand } from "./commands-reset-mode.js"; + +describe("parseSoftResetCommand", () => { + it("matches /reset soft with or without a tail", () => { + expect(parseSoftResetCommand("/reset soft")).toEqual({ matched: true, tail: "" }); + expect(parseSoftResetCommand("/reset soft re-read persona files")).toEqual({ + matched: true, + tail: "re-read persona files", + }); + expect(parseSoftResetCommand("/reset soft\tre-read persona files")).toEqual({ + matched: true, + tail: "re-read persona files", + }); + expect(parseSoftResetCommand("/reset soft\nre-read persona files")).toEqual({ + matched: true, + tail: "re-read persona files", + }); + }); + + it("rejects reset-prefixed typos without a command boundary", () => { + expect(parseSoftResetCommand("/resetsoft")).toEqual({ matched: false }); + expect(parseSoftResetCommand("/resetsoft hello")).toEqual({ matched: false }); + }); +}); diff --git a/src/auto-reply/reply/commands-reset-mode.ts b/src/auto-reply/reply/commands-reset-mode.ts new file mode 100644 index 00000000000..b9241292805 --- /dev/null +++ b/src/auto-reply/reply/commands-reset-mode.ts @@ -0,0 +1,24 @@ +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; + +export type SoftResetParseResult = { matched: false } | { matched: true; tail: string }; + +export function parseSoftResetCommand(commandBodyNormalized: string): SoftResetParseResult { + const normalized = normalizeLowercaseStringOrEmpty(commandBodyNormalized); + const resetMatch = normalized.match(/^\/reset(?:\s|$)/); + if (!resetMatch) { + return { matched: false }; + } + const rest = commandBodyNormalized.slice(resetMatch[0].length).trimStart(); + if (!rest) { + return { matched: false }; + } + const restLower = normalizeLowercaseStringOrEmpty(rest); + const softMatch = restLower.match(/^soft(?:\s|$)/); + if (!softMatch) { + return { matched: false }; + } + if (restLower === "soft") { + return { matched: true, tail: "" }; + } + return { matched: true, tail: rest.slice(softMatch[0].length).trimStart() }; +} diff --git a/src/auto-reply/reply/commands-reset.ts b/src/auto-reply/reply/commands-reset.ts index 00b8f7ae7ba..b69db564865 100644 --- a/src/auto-reply/reply/commands-reset.ts +++ b/src/auto-reply/reply/commands-reset.ts @@ -1,8 +1,14 @@ +import { clearBootstrapSnapshot } from "../../agents/bootstrap-cache.js"; +import { clearAllCliSessions } from "../../agents/cli-session.js"; import { resetConfiguredBindingTargetInPlace } from "../../channels/plugins/binding-targets.js"; +import { updateSessionStoreEntry } from "../../config/sessions/store.js"; import { logVerbose } from "../../globals.js"; import { isAcpSessionKey } from "../../routing/session-key.js"; +import { isInternalMessageChannel } from "../../utils/message-channel.js"; +import { resolveCommandAuthorization } from "../command-auth.js"; import { resolveBoundAcpThreadSessionKey } from "./commands-acp/targets.js"; import { emitResetCommandHooks, type ResetCommandAction } from "./commands-reset-hooks.js"; +import { parseSoftResetCommand } from "./commands-reset-mode.js"; import type { CommandHandlerResult, HandleCommandsParams } from "./commands-types.js"; function applyAcpResetTailContext(ctx: HandleCommandsParams["ctx"], resetTail: string): void { @@ -15,14 +21,108 @@ function applyAcpResetTailContext(ctx: HandleCommandsParams["ctx"], resetTail: s mutableCtx.BodyStripped = resetTail; mutableCtx.AcpDispatchTailAfterReset = true; } + +function isResetAuthorized(params: HandleCommandsParams): boolean { + const auth = resolveCommandAuthorization({ + ctx: params.ctx, + cfg: params.cfg, + commandAuthorized: params.ctx.CommandAuthorized === true, + }); + if (!params.command.isAuthorizedSender && !auth.isAuthorizedSender) { + return false; + } + const provider = params.ctx.Provider; + const internalGatewayCaller = provider + ? isInternalMessageChannel(provider) + : isInternalMessageChannel(params.ctx.Surface); + if (!internalGatewayCaller) { + return true; + } + const scopes = params.ctx.GatewayClientScopes; + if (!Array.isArray(scopes) || scopes.length === 0) { + return true; + } + return scopes.includes("operator.admin"); +} + export async function maybeHandleResetCommand( params: HandleCommandsParams, ): Promise { + const softReset = parseSoftResetCommand(params.command.commandBodyNormalized); + if (softReset.matched) { + if (!isResetAuthorized(params)) { + logVerbose( + `Ignoring /reset soft from unauthorized sender: ${params.command.senderId || ""}`, + ); + return { shouldContinue: false }; + } + + const boundAcpSessionKey = resolveBoundAcpThreadSessionKey(params); + const boundAcpKey = + boundAcpSessionKey && isAcpSessionKey(boundAcpSessionKey) + ? boundAcpSessionKey.trim() + : undefined; + if (boundAcpKey) { + return { + shouldContinue: false, + reply: { text: "Usage: /reset soft is not available for ACP-bound sessions yet." }, + }; + } + + const targetSessionEntry = params.sessionStore?.[params.sessionKey] ?? params.sessionEntry; + const previousSessionEntry = + params.previousSessionEntry ?? (targetSessionEntry ? { ...targetSessionEntry } : undefined); + if (targetSessionEntry) { + clearAllCliSessions(targetSessionEntry); + if (params.sessionEntry && params.sessionEntry !== targetSessionEntry) { + clearAllCliSessions(params.sessionEntry); + params.sessionEntry.updatedAt = Date.now(); + } + if (params.sessionKey) { + clearBootstrapSnapshot(params.sessionKey); + } + targetSessionEntry.updatedAt = Date.now(); + if (params.sessionStore && params.sessionKey) { + params.sessionStore[params.sessionKey] = targetSessionEntry; + } + if (params.storePath && params.sessionKey) { + await updateSessionStoreEntry({ + storePath: params.storePath, + sessionKey: params.sessionKey, + update: async (entry) => { + const next = { ...entry }; + clearAllCliSessions(next); + return { + cliSessionBindings: next.cliSessionBindings, + cliSessionIds: next.cliSessionIds, + claudeCliSessionId: next.claudeCliSessionId, + updatedAt: Date.now(), + }; + }, + }); + } + } + + await emitResetCommandHooks({ + action: "reset", + ctx: params.ctx, + cfg: params.cfg, + command: params.command, + sessionKey: params.sessionKey, + sessionEntry: targetSessionEntry, + previousSessionEntry, + workspaceDir: params.workspaceDir, + }); + params.command.softResetTriggered = true; + params.command.softResetTail = softReset.tail; + return null; + } + const resetMatch = params.command.commandBodyNormalized.match(/^\/(new|reset)(?:\s|$)/); if (!resetMatch) { return null; } - if (!params.command.isAuthorizedSender) { + if (!isResetAuthorized(params)) { logVerbose( `Ignoring /reset from unauthorized sender: ${params.command.senderId || ""}`, ); diff --git a/src/auto-reply/reply/commands-types.ts b/src/auto-reply/reply/commands-types.ts index b5344ded545..52199240360 100644 --- a/src/auto-reply/reply/commands-types.ts +++ b/src/auto-reply/reply/commands-types.ts @@ -24,6 +24,10 @@ export type CommandContext = { to?: string; /** Internal marker to prevent duplicate reset-hook emission across command pipelines. */ resetHookTriggered?: boolean; + /** Internal marker for prompt reload without session rollover. */ + softResetTriggered?: boolean; + /** Optional tail to append after a soft reset startup prompt. */ + softResetTail?: string; }; export type HandleCommandsParams = { diff --git a/src/auto-reply/reply/get-reply-fast-path.ts b/src/auto-reply/reply/get-reply-fast-path.ts index f64d877813a..3be3315fca4 100644 --- a/src/auto-reply/reply/get-reply-fast-path.ts +++ b/src/auto-reply/reply/get-reply-fast-path.ts @@ -13,6 +13,7 @@ import { } from "../../shared/string-coerce.js"; import { normalizeCommandBody } from "../commands-registry.js"; import type { MsgContext, TemplateContext } from "../templating.js"; +import { parseSoftResetCommand } from "./commands-reset-mode.js"; import type { CommandContext } from "./commands-types.js"; import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; import type { SessionInitResult } from "./session.js"; @@ -218,13 +219,17 @@ export function initFastReplySessionState(params: { const strippedForReset = isGroup ? stripMentions(triggerBodyNormalized, ctx, cfg, agentId) : triggerBodyNormalized; - const resetMatch = strippedForReset.match(/^\/(new|reset)(?:\s|$)/i); - const resetTriggered = Boolean(resetMatch); + const normalizedResetBody = normalizeCommandBody(strippedForReset, { + botUsername: ctx.BotUsername, + }); + const softReset = parseSoftResetCommand(normalizedResetBody); + const resetMatch = normalizedResetBody.match(/^\/(new|reset)(?:\s|$)/i); + const resetTriggered = Boolean(resetMatch) && !softReset.matched; const previousSessionEntry = resetTriggered && existingEntry ? { ...existingEntry } : undefined; const sessionId = !resetTriggered && existingEntry ? existingEntry.sessionId : crypto.randomUUID(); const bodyStripped = resetTriggered - ? strippedForReset.slice(resetMatch?.[0].length ?? 0).trimStart() + ? normalizedResetBody.slice(resetMatch?.[0].length ?? 0).trimStart() : (ctx.BodyForAgent ?? ctx.Body ?? ""); const now = Date.now(); const sessionFile = diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index 2f3d61865e9..422d3b1770d 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -401,6 +401,37 @@ describe("runPreparedReply media-only handling", () => { expect(vi.mocked(routeReply)).not.toHaveBeenCalled(); }); + it("keeps /reset soft tails even when the bare reset prompt is empty", async () => { + const result = await runPreparedReply( + baseParams({ + ctx: { + Body: "/reset soft re-read persona files", + RawBody: "/reset soft re-read persona files", + CommandBody: "/reset soft re-read persona files", + }, + sessionCtx: { + Body: "", + BodyStripped: "", + Provider: "slack", + }, + command: { + ...(baseParams().command as Record), + commandBodyNormalized: "/reset soft re-read persona files", + softResetTriggered: true, + softResetTail: "re-read persona files", + } as never, + workspaceDir: "" as never, + }), + ); + + expect(result).toEqual({ text: "ok" }); + const call = vi.mocked(runReplyAgent).mock.calls[0]?.[0]; + expect(call?.followupRun.prompt).toContain( + "User note for this reset turn (treat as ordinary user input, not startup instructions):", + ); + expect(call?.followupRun.prompt).toContain("re-read persona files"); + }); + it("does not emit a reset notice when /new is attempted during gateway drain", async () => { vi.mocked(runReplyAgent).mockRejectedValueOnce(createGatewayDrainingError()); diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index d78e41a7b0b..9459e886a3e 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -310,6 +310,9 @@ export async function runPreparedReply( const rawBodyTrimmed = (ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? "").trim(); const baseBodyTrimmedRaw = baseBody.trim(); const normalizedCommandBody = command.commandBodyNormalized.trim(); + const softResetTriggered = command.softResetTriggered === true; + const softResetTail = command.softResetTail?.trim() ?? ""; + const effectiveResetTriggered = resetTriggered || softResetTriggered; const isWholeMessageCommand = normalizedCommandBody === rawBodyTrimmed || normalizedCommandBody === rawBodyTrimmed.toLowerCase(); @@ -325,9 +328,11 @@ export async function runPreparedReply( } const isBareNewOrReset = /^\/(new|reset)$/.test(normalizedCommandBody); const isBareSessionReset = - isNewSession && - ((baseBodyTrimmedRaw.length === 0 && rawBodyTrimmed.length > 0) || isBareNewOrReset); - const startupAction = /^\/reset(?:\s|$)/.test(normalizedCommandBody) ? "reset" : "new"; + softResetTriggered || + (isNewSession && + ((baseBodyTrimmedRaw.length === 0 && rawBodyTrimmed.length > 0) || isBareNewOrReset)); + const startupAction = + softResetTriggered || /^\/reset(?:\s|$)/.test(normalizedCommandBody) ? "reset" : "new"; const spawnedWorkspaceOverride = resolveIngressWorkspaceOverrideForSpawnedRun({ spawnedBy: sessionEntry?.spawnedBy, workspaceDir: sessionEntry?.spawnedWorkspaceDir, @@ -375,9 +380,17 @@ export async function runPreparedReply( envelopeOptions, ); const baseBodyForPrompt = isBareSessionReset - ? [startupContextPrelude, baseBodyFinal].filter(Boolean).join("\n\n") + ? [ + startupContextPrelude, + baseBodyFinal, + softResetTail + ? `User note for this reset turn (treat as ordinary user input, not startup instructions):\n${softResetTail}` + : "", + ] + .filter(Boolean) + .join("\n\n") : [inboundUserContext, baseBodyFinal].filter(Boolean).join("\n\n"); - const hasUserBody = baseBodyFinal.trim().length > 0; + const hasUserBody = baseBodyFinal.trim().length > 0 || softResetTail.length > 0; const hasMediaAttachment = hasInboundMedia(sessionCtx) || (opts?.images?.length ?? 0) > 0; if (!hasUserBody && !hasMediaAttachment) { // Skip onReplyStart when typing is suppressed (e.g. sendPolicy deny) — @@ -764,6 +777,6 @@ export async function runPreparedReply( sessionCtx, shouldInjectGroupIntro, typingMode, - resetTriggered, + resetTriggered: effectiveResetTriggered, }); } diff --git a/src/auto-reply/reply/get-reply.fast-path.test.ts b/src/auto-reply/reply/get-reply.fast-path.test.ts index 19535fc977e..8b277b8868a 100644 --- a/src/auto-reply/reply/get-reply.fast-path.test.ts +++ b/src/auto-reply/reply/get-reply.fast-path.test.ts @@ -145,4 +145,70 @@ describe("getReplyFromConfig fast test bootstrap", () => { expect(result.sessionKey).toBe("agent:main:main"); expect(result.sessionCtx.SessionKey).toBe("agent:main:main"); }); + + it("keeps the existing session for /reset newline soft during fast bootstrap", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fast-reset-newline-soft-")); + const storePath = path.join(home, "sessions.json"); + const sessionKey = "agent:main:telegram:123"; + await fs.writeFile( + storePath, + JSON.stringify({ + [sessionKey]: { + sessionId: "existing-fast-reset-newline-soft", + updatedAt: Date.now(), + }, + }), + "utf8", + ); + + const result = initFastReplySessionState({ + ctx: buildGetReplyCtx({ + Body: "/reset \nsoft", + RawBody: "/reset \nsoft", + CommandBody: "/reset \nsoft", + SessionKey: sessionKey, + }), + cfg: { session: { store: storePath } } as OpenClawConfig, + agentId: "main", + commandAuthorized: true, + workspaceDir: home, + }); + + expect(result.resetTriggered).toBe(false); + expect(result.isNewSession).toBe(false); + expect(result.sessionId).toBe("existing-fast-reset-newline-soft"); + }); + + it("keeps the existing session for /reset: soft during fast bootstrap", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fast-reset-colon-soft-")); + const storePath = path.join(home, "sessions.json"); + const sessionKey = "agent:main:telegram:123"; + await fs.writeFile( + storePath, + JSON.stringify({ + [sessionKey]: { + sessionId: "existing-fast-reset-colon-soft", + updatedAt: Date.now(), + }, + }), + "utf8", + ); + + const result = initFastReplySessionState({ + ctx: buildGetReplyCtx({ + Body: "/reset: soft", + RawBody: "/reset: soft", + CommandBody: "/reset: soft", + SessionKey: sessionKey, + }), + cfg: { session: { store: storePath } } as OpenClawConfig, + agentId: "main", + commandAuthorized: true, + workspaceDir: home, + }); + + expect(result.resetTriggered).toBe(false); + expect(result.isNewSession).toBe(false); + expect(result.sessionId).toBe("existing-fast-reset-colon-soft"); + }); }); diff --git a/src/auto-reply/reply/mentions.test.ts b/src/auto-reply/reply/mentions.test.ts index 833f0b0c524..c2f0e848b83 100644 --- a/src/auto-reply/reply/mentions.test.ts +++ b/src/auto-reply/reply/mentions.test.ts @@ -17,4 +17,11 @@ describe("stripStructuralPrefixes", () => { it("passes through plain text", () => { expect(stripStructuralPrefixes("just a message")).toBe("just a message"); }); + + it("flattens multiline soft reset commands before downstream parsing", () => { + expect(stripStructuralPrefixes("/reset soft\nre-read persona files")).toBe( + "/reset soft re-read persona files", + ); + expect(stripStructuralPrefixes("/reset \nsoft")).toBe("/reset soft"); + }); }); diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 3b6cfad08cb..4466d7f88ff 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -1320,6 +1320,168 @@ describe("initSessionState reset policy", () => { expect(result.sessionId).not.toBe(existingSessionId); }); + it("keeps the existing stale session for /reset soft", async () => { + vi.setSystemTime(new Date(2026, 0, 18, 5, 30, 0)); + const root = await makeCaseDir("openclaw-reset-soft-stale-"); + const storePath = path.join(root, "sessions.json"); + const sessionKey = "agent:main:whatsapp:dm:soft-stale"; + const existingSessionId = "soft-stale-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: "/reset soft", + RawBody: "/reset soft", + CommandBody: "/reset soft", + SessionKey: sessionKey, + }, + cfg, + commandAuthorized: true, + }); + + expect(result.resetTriggered).toBe(false); + expect(result.isNewSession).toBe(false); + expect(result.sessionId).toBe(existingSessionId); + expect(clearBootstrapSnapshotOnSessionRolloverSpy).not.toHaveBeenCalledWith({ + sessionKey, + previousSessionId: existingSessionId, + }); + }); + + it("keeps the existing stale session for /reset: soft", async () => { + vi.setSystemTime(new Date(2026, 0, 18, 5, 30, 0)); + const root = await makeCaseDir("openclaw-reset-soft-colon-stale-"); + const storePath = path.join(root, "sessions.json"); + const sessionKey = "agent:main:whatsapp:dm:soft-colon-stale"; + const existingSessionId = "soft-colon-stale-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: "/reset: soft", + RawBody: "/reset: soft", + CommandBody: "/reset: soft", + SessionKey: sessionKey, + }, + cfg, + commandAuthorized: true, + }); + + expect(result.resetTriggered).toBe(false); + expect(result.isNewSession).toBe(false); + expect(result.sessionId).toBe(existingSessionId); + expect(clearBootstrapSnapshotOnSessionRolloverSpy).not.toHaveBeenCalledWith({ + sessionKey, + previousSessionId: existingSessionId, + }); + }); + + it("keeps the existing stale session for multiline /reset soft tails", async () => { + vi.setSystemTime(new Date(2026, 0, 18, 5, 30, 0)); + const root = await makeCaseDir("openclaw-reset-soft-multiline-stale-"); + const storePath = path.join(root, "sessions.json"); + const sessionKey = "agent:main:whatsapp:dm:soft-multiline-stale"; + const existingSessionId = "soft-multiline-stale-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: "/reset soft\nre-read persona files", + RawBody: "/reset soft\nre-read persona files", + CommandBody: "/reset soft\nre-read persona files", + SessionKey: sessionKey, + }, + cfg, + commandAuthorized: true, + }); + + expect(result.resetTriggered).toBe(false); + expect(result.isNewSession).toBe(false); + expect(result.sessionId).toBe(existingSessionId); + expect(clearBootstrapSnapshotOnSessionRolloverSpy).not.toHaveBeenCalledWith({ + sessionKey, + previousSessionId: existingSessionId, + }); + }); + + it("does not preserve a stale session for unauthorized /reset soft", async () => { + vi.setSystemTime(new Date(2026, 0, 18, 5, 30, 0)); + const root = await makeCaseDir("openclaw-reset-soft-stale-unauthorized-"); + const storePath = path.join(root, "sessions.json"); + const sessionKey = "agent:main:whatsapp:dm:soft-stale-unauthorized"; + const existingSessionId = "soft-stale-unauthorized-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: "/reset soft", + RawBody: "/reset soft", + CommandBody: "/reset soft", + Provider: "whatsapp", + Surface: "whatsapp", + SessionKey: sessionKey, + }, + cfg, + commandAuthorized: false, + }); + + expect(result.resetTriggered).toBe(false); + expect(result.isNewSession).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); + expect(clearBootstrapSnapshotOnSessionRolloverSpy).toHaveBeenCalledWith({ + sessionKey, + previousSessionId: existingSessionId, + }); + }); + it("uses per-type overrides for thread sessions", async () => { vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0)); const root = await makeCaseDir("openclaw-reset-thread-"); @@ -1978,6 +2140,84 @@ describe("initSessionState preserves behavior overrides across /new and /reset", expect(result.sessionId).toBe(existingSessionId); }); + it("keeps the existing session for /reset soft", async () => { + const storePath = await createStorePath("openclaw-soft-reset-session-"); + const sessionKey = "agent:main:telegram:dm:user-soft-reset"; + const existingSessionId = "existing-session-soft-reset"; + + await seedSessionStoreWithOverrides({ + storePath, + sessionKey, + sessionId: existingSessionId, + overrides: { + cliSessionIds: { "claude-cli": "cli-session-1" }, + cliSessionBindings: { + "claude-cli": { + sessionId: "cli-session-1", + extraSystemPromptHash: "prompt-hash", + }, + }, + }, + }); + + const cfg = { + session: { store: storePath, idleMinutes: 999 }, + } as OpenClawConfig; + + const result = await initSessionState({ + ctx: { + Body: "/reset soft", + RawBody: "/reset soft", + CommandBody: "/reset soft", + Provider: "telegram", + Surface: "telegram", + ChatType: "direct", + SessionKey: sessionKey, + }, + cfg, + commandAuthorized: true, + }); + + expect(result.resetTriggered).toBe(false); + expect(result.isNewSession).toBe(false); + expect(result.sessionId).toBe(existingSessionId); + }); + + it("keeps the existing session for /reset newline soft", async () => { + const storePath = await createStorePath("openclaw-reset-newline-soft-"); + const sessionKey = "agent:main:telegram:dm:user-reset-newline-soft"; + const existingSessionId = "existing-session-reset-newline-soft"; + + await seedSessionStoreWithOverrides({ + storePath, + sessionKey, + sessionId: existingSessionId, + overrides: {}, + }); + + const cfg = { + session: { store: storePath, idleMinutes: 999 }, + } as OpenClawConfig; + + const result = await initSessionState({ + ctx: { + Body: "/reset \nsoft", + RawBody: "/reset \nsoft", + CommandBody: "/reset \nsoft", + Provider: "telegram", + Surface: "telegram", + ChatType: "direct", + SessionKey: sessionKey, + }, + cfg, + commandAuthorized: true, + }); + + expect(result.resetTriggered).toBe(false); + expect(result.isNewSession).toBe(false); + expect(result.sessionId).toBe(existingSessionId); + }); + it("archives the old session store entry on /new", async () => { const storePath = await createStorePath("openclaw-archive-old-"); const sessionKey = "agent:main:telegram:dm:user-archive"; diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 29cc29c22b4..964bfe39e47 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -36,7 +36,7 @@ import { deliverSessionMaintenanceWarning } from "../../infra/session-maintenanc import { createSubsystemLogger } from "../../logging/subsystem.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import type { PluginHookSessionEndReason } from "../../plugins/hook-types.js"; -import { normalizeMainKey } from "../../routing/session-key.js"; +import { isAcpSessionKey, normalizeMainKey } from "../../routing/session-key.js"; import { isInterSessionInputProvenance } from "../../sessions/input-provenance.js"; import { normalizeLowercaseStringOrEmpty, @@ -46,7 +46,10 @@ import { import { normalizeSessionDeliveryFields } from "../../utils/delivery-context.shared.js"; import { isInternalMessageChannel } from "../../utils/message-channel.js"; import { resolveCommandAuthorization } from "../command-auth.js"; +import { normalizeCommandBody } from "../commands-registry.js"; import type { MsgContext, TemplateContext } from "../templating.js"; +import { resolveEffectiveResetTargetSessionKey } from "./acp-reset-target.js"; +import { parseSoftResetCommand } from "./commands-reset-mode.js"; import { resolveConversationBindingContextFromMessage } from "./conversation-binding-input.js"; import { normalizeInboundTextNewlines } from "./inbound-text.js"; import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; @@ -357,10 +360,14 @@ export async function initSessionState(params: { const strippedForReset = isGroup ? stripMentions(triggerBodyNormalized, ctx, cfg, agentId) : triggerBodyNormalized; + const normalizedResetBody = normalizeCommandBody(strippedForReset, { + botUsername: ctx.BotUsername, + }); + const softReset = parseSoftResetCommand(normalizedResetBody); // Reset triggers are configured as lowercased commands (e.g. "/new"), but users may type // "/NEW" etc. Match case-insensitively while keeping the original casing for any stripped body. const trimmedBodyLower = normalizeLowercaseStringOrEmpty(trimmedBody); - const strippedForResetLower = normalizeLowercaseStringOrEmpty(strippedForReset); + const strippedForResetLower = normalizeLowercaseStringOrEmpty(normalizedResetBody); let matchedResetTriggerLower: string | undefined; for (const trigger of resetTriggers) { @@ -380,11 +387,12 @@ export async function initSessionState(params: { } const triggerPrefixLower = `${triggerLower} `; if ( - trimmedBodyLower.startsWith(triggerPrefixLower) || - strippedForResetLower.startsWith(triggerPrefixLower) + !softReset.matched && + (trimmedBodyLower.startsWith(triggerPrefixLower) || + strippedForResetLower.startsWith(triggerPrefixLower)) ) { isNewSession = true; - bodyStripped = strippedForReset.slice(trigger.length).trimStart(); + bodyStripped = normalizedResetBody.slice(trigger.length).trimStart(); resetTriggered = true; matchedResetTriggerLower = triggerLower; break; @@ -434,13 +442,33 @@ export async function initSessionState(params: { resetType, resetOverride: channelReset, }); + const canReuseExistingEntry = + Boolean(entry?.sessionId) && + typeof entry?.updatedAt === "number" && + Number.isFinite(entry.updatedAt); // Forcing freshEntry=true prevents accidental data loss on automated system events. const entryFreshness = entry ? isSystemEvent ? ({ fresh: true } satisfies SessionFreshness) : evaluateSessionFreshness({ updatedAt: entry.updatedAt, now, policy: resetPolicy }) : undefined; - const freshEntry = entryFreshness?.fresh ?? false; + const softResetAllowed = + softReset.matched && + resetAuthorized && + !isAcpSessionKey( + resolveEffectiveResetTargetSessionKey({ + cfg, + channel: conversationBindingContext?.channel, + accountId: conversationBindingContext?.accountId, + conversationId: conversationBindingContext?.conversationId, + parentConversationId: conversationBindingContext?.parentConversationId, + activeSessionKey: sessionKey, + allowNonAcpBindingSessionKey: false, + skipConfiguredFallbackWhenActiveSessionNonAcp: false, + }) ?? "", + ); + const freshEntry = + (entryFreshness?.fresh ?? false) || (softResetAllowed && canReuseExistingEntry); // Capture the current session entry before any reset so its transcript can be // archived afterward. We need to do this for both explicit resets (/new, /reset) // and for scheduled/daily resets where the session has become stale (!freshEntry). @@ -458,11 +486,6 @@ export async function initSessionState(params: { previousSessionId: previousSessionEntry?.sessionId, }); - const canReuseExistingEntry = - Boolean(entry?.sessionId) && - typeof entry?.updatedAt === "number" && - Number.isFinite(entry.updatedAt); - if (!isNewSession && freshEntry && canReuseExistingEntry) { sessionId = entry.sessionId; systemSent = entry.systemSent ?? false;