fix(reply): preserve active session state

This commit is contained in:
Peter Steinberger
2026-04-12 16:07:38 +01:00
parent f17fd735ef
commit 9f09001014
8 changed files with 104 additions and 35 deletions

View File

@@ -15,7 +15,7 @@ export function registerGroupIntroPromptCases(): void {
setup?: (cfg: ReturnType<typeof makeCfg>) => 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.`,
],
},
{

View File

@@ -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<void> {

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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<string, SessionEntry> = 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<string, SessionEntry> = { [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,
};
}

View File

@@ -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

View File

@@ -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,

View File

@@ -90,6 +90,14 @@ function resolveProviderLabel(rawProvider: string | undefined): string {
if (isInternalMessageChannel(providerKey)) {
return "WebChat";
}
const labels: Record<string, string> = {
imessage: "iMessage",
whatsapp: "WhatsApp",
};
const label = labels[providerKey];
if (label) {
return label;
}
return `${providerKey.at(0)?.toUpperCase() ?? ""}${providerKey.slice(1)}`;
}