mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:50:43 +00:00
fix(reply): preserve active session state
This commit is contained in:
@@ -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.`,
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user