From 9f09001014f8b6dcb11606cd65d4d1bb800f60e7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 12 Apr 2026 16:07:38 +0100 Subject: [PATCH] fix(reply): preserve active session state --- ...eply.triggers.group-intro-prompts.cases.ts | 6 +- ...ets-active-session-native-stop.e2e.test.ts | 12 ++-- ....triggers.trigger-handling.test-harness.ts | 4 ++ src/auto-reply/reply/commands-session.ts | 9 +-- src/auto-reply/reply/get-reply-fast-path.ts | 72 +++++++++++++------ src/auto-reply/reply/get-reply-run.ts | 16 ++++- src/auto-reply/reply/get-reply.ts | 12 ++++ src/auto-reply/reply/groups.ts | 8 +++ 8 files changed, 104 insertions(+), 35 deletions(-) diff --git a/src/auto-reply/reply.triggers.group-intro-prompts.cases.ts b/src/auto-reply/reply.triggers.group-intro-prompts.cases.ts index e077bcda204..860c023db1e 100644 --- a/src/auto-reply/reply.triggers.group-intro-prompts.cases.ts +++ b/src/auto-reply/reply.triggers.group-intro-prompts.cases.ts @@ -15,7 +15,7 @@ export function registerGroupIntroPromptCases(): void { setup?: (cfg: ReturnType) => void; }; const groupParticipationNote = - "Be a good group participant: mostly lurk and follow the conversation; reply only when directly addressed or you can add clear value. Emoji reactions are welcome when available. Write like a human. Avoid Markdown tables. Don't type literal \\n sequences; use real line breaks sparingly."; + "Be a good group participant: mostly lurk and follow the conversation; reply only when directly addressed or you can add clear value. Emoji reactions are welcome when available. Write like a human. Avoid Markdown tables. Minimize empty lines and use normal chat conventions, not document-style spacing. Don't type literal \\n sequences; use real line breaks sparingly."; const cases: GroupIntroCase[] = [ { name: "discord", @@ -44,8 +44,8 @@ export function registerGroupIntroPromptCases(): void { Provider: "whatsapp", }, expected: [ - `You are in the WhatsApp group chat "Ops". Your replies are automatically sent to this group chat. Do not use the message tool to send to this same group — just reply normally.`, - `Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). WhatsApp IDs: SenderId is the participant JID (group participant id). ${groupParticipationNote} Address the specific sender noted in the message context.`, + `You are in the WhatsApp group chat "Ops". Your replies are automatically sent to this group chat. Do not use the message tool to send to this same group - just reply normally.`, + `Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). ${groupParticipationNote} Address the specific sender noted in the message context.`, ], }, { diff --git a/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts b/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts index 0a675506cf6..57dbaec15ef 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts @@ -145,15 +145,13 @@ function mockSuccessfulCompaction() { function makeUnauthorizedWhatsAppCfg(home: string) { const baseCfg = makeCfg(home); - return { - ...baseCfg, - channels: { - ...baseCfg.channels, - whatsapp: { - allowFrom: ["+1000"], - }, + baseCfg.channels = { + ...baseCfg.channels, + whatsapp: { + allowFrom: ["+1000"], }, }; + return baseCfg; } async function expectResetBlockedForNonOwner(params: { home: string }): Promise { diff --git a/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts b/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts index 462803286e4..62f1e63158e 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts @@ -65,6 +65,10 @@ const installPiEmbeddedMock = () => installPiEmbeddedMock(); +vi.doMock("../agents/pi-embedded-runner/runs.js", () => ({ + abortEmbeddedPiRun: (...args: unknown[]) => piEmbeddedMocks.abortEmbeddedPiRun(...args), +})); + const providerUsageMocks = vi.hoisted(() => ({ loadProviderUsageSummary: vi.fn().mockResolvedValue({ updatedAt: 0, diff --git a/src/auto-reply/reply/commands-session.ts b/src/auto-reply/reply/commands-session.ts index ebb413c5436..9941e792f0f 100644 --- a/src/auto-reply/reply/commands-session.ts +++ b/src/auto-reply/reply/commands-session.ts @@ -319,13 +319,14 @@ export const handleUsageCommand: CommandHandler = async (params, allowTextComman const current = resolveResponseUsageMode(currentRaw); const next = requested ?? (current === "off" ? "tokens" : current === "tokens" ? "full" : "off"); - if (params.sessionEntry && params.sessionStore && params.sessionKey) { + if (targetSessionEntry && params.sessionStore && params.sessionKey) { if (next === "off") { - delete params.sessionEntry.responseUsage; + delete targetSessionEntry.responseUsage; } else { - params.sessionEntry.responseUsage = next; + targetSessionEntry.responseUsage = next; } - await persistSessionEntry(params); + params.sessionStore[params.sessionKey] = targetSessionEntry; + await persistSessionEntry({ ...params, sessionEntry: targetSessionEntry }); } return { diff --git a/src/auto-reply/reply/get-reply-fast-path.ts b/src/auto-reply/reply/get-reply-fast-path.ts index 24f8615132c..f64d877813a 100644 --- a/src/auto-reply/reply/get-reply-fast-path.ts +++ b/src/auto-reply/reply/get-reply-fast-path.ts @@ -1,9 +1,11 @@ import crypto from "node:crypto"; -import path from "node:path"; import { normalizeChatType } from "../../channels/chat-type.js"; import { normalizeAnyChannelId } from "../../channels/registry.js"; import { applyMergePatch } from "../../config/merge-patch.js"; -import type { SessionEntry } from "../../config/sessions/types.js"; +import { resolveSessionTranscriptPath, resolveStorePath } from "../../config/sessions/paths.js"; +import { resolveSessionKey } from "../../config/sessions/session-key.js"; +import { loadSessionStore } from "../../config/sessions/store.js"; +import type { SessionEntry, SessionScope } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { normalizeOptionalLowercaseString, @@ -29,21 +31,18 @@ function isSlowReplyTestAllowed(env: NodeJS.ProcessEnv = process.env): boolean { ); } -function resolveFastSessionKey(ctx: MsgContext): string { +function resolveFastSessionKey(params: { + ctx: MsgContext; + sessionScope: SessionScope; + mainKey?: string; +}): string { + const { ctx } = params; const nativeCommandTarget = ctx.CommandSource === "native" ? normalizeOptionalString(ctx.CommandTargetSessionKey) : ""; if (nativeCommandTarget) { return nativeCommandTarget; } - const existing = normalizeOptionalString(ctx.SessionKey); - if (existing) { - return existing; - } - const provider = - normalizeOptionalString(ctx.Provider) ?? normalizeOptionalString(ctx.Surface) ?? "main"; - const destination = - normalizeOptionalString(ctx.To) ?? normalizeOptionalString(ctx.From) ?? "default"; - return `agent:main:${provider}:${destination}`; + return resolveSessionKey(params.sessionScope, ctx, params.mainKey); } function markReplyConfigRuntimeMode( @@ -200,10 +199,18 @@ export function initFastReplySessionState(params: { commandAuthorized: boolean; workspaceDir: string; }): SessionInitResult { - const { ctx, cfg, agentId, commandAuthorized, workspaceDir } = params; + const { ctx, cfg, agentId, commandAuthorized } = params; const sessionScope = cfg.session?.scope ?? "per-sender"; - const sessionKey = resolveFastSessionKey(ctx); - const sessionId = crypto.randomUUID(); + const sessionKey = resolveFastSessionKey({ + ctx, + sessionScope, + mainKey: cfg.session?.mainKey, + }); + const storePath = resolveStorePath(cfg.session?.store, { agentId }); + const sessionStore: Record = loadSessionStore(storePath, { + skipCache: true, + }); + const existingEntry = sessionStore[sessionKey]; const commandSource = ctx.BodyForCommands ?? ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? ""; const triggerBodyNormalized = stripStructuralPrefixes(commandSource).trim(); const normalizedChatType = normalizeChatType(ctx.ChatType); @@ -213,15 +220,40 @@ export function initFastReplySessionState(params: { : triggerBodyNormalized; const resetMatch = strippedForReset.match(/^\/(new|reset)(?:\s|$)/i); const resetTriggered = Boolean(resetMatch); + const previousSessionEntry = resetTriggered && existingEntry ? { ...existingEntry } : undefined; + const sessionId = + !resetTriggered && existingEntry ? existingEntry.sessionId : crypto.randomUUID(); const bodyStripped = resetTriggered ? strippedForReset.slice(resetMatch?.[0].length ?? 0).trimStart() : (ctx.BodyForAgent ?? ctx.Body ?? ""); const now = Date.now(); - const sessionFile = path.join(workspaceDir, ".openclaw", "sessions", `${sessionId}.jsonl`); + const sessionFile = + !resetTriggered && existingEntry?.sessionFile + ? existingEntry.sessionFile + : resolveSessionTranscriptPath(sessionId, agentId); const sessionEntry: SessionEntry = { + ...(!resetTriggered ? existingEntry : undefined), sessionId, sessionFile, updatedAt: now, + thinkingLevel: resetTriggered ? existingEntry?.thinkingLevel : existingEntry?.thinkingLevel, + verboseLevel: resetTriggered ? existingEntry?.verboseLevel : existingEntry?.verboseLevel, + reasoningLevel: resetTriggered ? existingEntry?.reasoningLevel : existingEntry?.reasoningLevel, + ttsAuto: resetTriggered ? existingEntry?.ttsAuto : existingEntry?.ttsAuto, + responseUsage: !resetTriggered ? existingEntry?.responseUsage : undefined, + modelOverride: resetTriggered ? existingEntry?.modelOverride : existingEntry?.modelOverride, + providerOverride: resetTriggered + ? existingEntry?.providerOverride + : existingEntry?.providerOverride, + authProfileOverride: resetTriggered + ? existingEntry?.authProfileOverride + : existingEntry?.authProfileOverride, + authProfileOverrideSource: resetTriggered + ? existingEntry?.authProfileOverrideSource + : existingEntry?.authProfileOverrideSource, + authProfileOverrideCompactionCount: resetTriggered + ? existingEntry?.authProfileOverrideCompactionCount + : existingEntry?.authProfileOverrideCompactionCount, ...(normalizedChatType ? { chatType: normalizedChatType } : {}), ...(normalizeOptionalString(ctx.Provider) ? { channel: normalizeOptionalString(ctx.Provider) } @@ -233,7 +265,7 @@ export function initFastReplySessionState(params: { ? { groupChannel: normalizeOptionalString(ctx.GroupChannel) } : {}), }; - const sessionStore: Record = { [sessionKey]: sessionEntry }; + sessionStore[sessionKey] = sessionEntry; const sessionCtx: TemplateContext = { ...ctx, SessionKey: sessionKey, @@ -244,19 +276,19 @@ export function initFastReplySessionState(params: { return { sessionCtx, sessionEntry, - previousSessionEntry: undefined, sessionStore, sessionKey, sessionId, - isNewSession: resetTriggered || !ctx.SessionKey, + isNewSession: resetTriggered || !existingEntry, resetTriggered, systemSent: false, abortedLastRun: false, - storePath: normalizeOptionalString(cfg.session?.store) ?? "", + storePath, sessionScope, groupResolution: undefined, isGroup, bodyStripped, triggerBodyNormalized, + previousSessionEntry, }; } diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 9cc0fb1a412..9e702ed67f3 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -118,6 +118,18 @@ function loadSessionStoreRuntime() { return sessionStoreRuntimePromise; } +function stripPromptThinkingDirectives(body: string): string { + return body + .split("\n") + .map((line) => + line + .replace(/(^|\s)\/(?:thinking|think|t)(?=$|\s|:)(?:\s*:\s*|\s+)?[A-Za-z-]*/gi, "$1") + .replace(/[ \t]{2,}/g, " ") + .trimEnd(), + ) + .join("\n"); +} + type RunPreparedReplyParams = { ctx: MsgContext; sessionCtx: TemplateContext; @@ -314,7 +326,9 @@ export async function runPreparedReply( cfg, }) : null; - const baseBodyFinal = isBareSessionReset ? buildBareSessionResetPrompt(cfg) : baseBody; + const baseBodyFinal = isBareSessionReset + ? buildBareSessionResetPrompt(cfg) + : stripPromptThinkingDirectives(baseBody); const envelopeOptions = resolveEnvelopeFormatOptions(cfg); const inboundUserContext = buildInboundUserContextPrefix( isNewSession diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 605f27a59b5..295ec03e50f 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -36,6 +36,7 @@ import { finalizeInboundContext } from "./inbound-context.js"; import { emitPreAgentMessageHooks } from "./message-preprocess-hooks.js"; import { createFastTestModelSelectionState } from "./model-selection.js"; import { initSessionState } from "./session.js"; +import { resolveStoredModelOverride } from "./stored-model-override.js"; import { createTypingController } from "./typing.js"; type ResetCommandAction = "new" | "reset"; @@ -320,6 +321,17 @@ export async function getReplyFromConfig( normalizeOptionalString(sessionEntry.modelOverride) || normalizeOptionalString(sessionEntry.providerOverride), ); + const storedModelOverride = resolveStoredModelOverride({ + sessionEntry, + sessionStore, + sessionKey, + parentSessionKey: sessionEntry.parentSessionKey ?? sessionCtx.ParentSessionKey, + defaultProvider, + }); + if (storedModelOverride?.model && !hasResolvedHeartbeatModelOverride) { + provider = storedModelOverride.provider ?? defaultProvider; + model = storedModelOverride.model; + } if (!hasResolvedHeartbeatModelOverride && !hasSessionModelOverride && channelModelOverride) { const resolved = resolveModelRefFromString({ raw: channelModelOverride.model, diff --git a/src/auto-reply/reply/groups.ts b/src/auto-reply/reply/groups.ts index c2e434384b6..e340e41e738 100644 --- a/src/auto-reply/reply/groups.ts +++ b/src/auto-reply/reply/groups.ts @@ -90,6 +90,14 @@ function resolveProviderLabel(rawProvider: string | undefined): string { if (isInternalMessageChannel(providerKey)) { return "WebChat"; } + const labels: Record = { + imessage: "iMessage", + whatsapp: "WhatsApp", + }; + const label = labels[providerKey]; + if (label) { + return label; + } return `${providerKey.at(0)?.toUpperCase() ?? ""}${providerKey.slice(1)}`; }