mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
@@ -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>"}`,
|
||||
);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user