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
This commit is contained in:
Tak Hoffman
2026-04-21 10:17:52 -05:00
committed by GitHub
parent 68fd946e6d
commit 714598774f
15 changed files with 732 additions and 22 deletions
+1
View File
@@ -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.
+1
View File
@@ -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 <duration|off>` and `/session max-age <duration|off>` manage thread-binding expiry.
@@ -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");
});
});
@@ -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<typeof vi.spyOn>;
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<string, NonNullable<HandleCommandsParams["sessionEntry"]>>;
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();
});
});
@@ -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 });
});
});
@@ -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() };
}
+101 -1
View File
@@ -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<CommandHandlerResult | null> {
const softReset = parseSoftResetCommand(params.command.commandBodyNormalized);
if (softReset.matched) {
if (!isResetAuthorized(params)) {
logVerbose(
`Ignoring /reset soft from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
);
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 || "<unknown>"}`,
);
+4
View File
@@ -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 = {
+8 -3
View File
@@ -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 =
@@ -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<string, unknown>),
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());
+19 -6
View File
@@ -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,
});
}
@@ -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");
});
});
+7
View File
@@ -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");
});
});
+240
View File
@@ -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";
+34 -11
View File
@@ -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;