From dce2513db21781538eb6db6303490102ecfedc93 Mon Sep 17 00:00:00 2001 From: bitloi <89318445+bitloi@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:14:48 -0300 Subject: [PATCH] fix(agents): preserve CLI wake-up session metadata (#74171) * Fix CLI wake-up resume metadata * Rerun CI * ci: re-trigger parity gate --- src/agents/cli-runner.spawn.test.ts | 27 +++- src/agents/cli-runner/execute.ts | 52 ++++++- .../reply/get-reply-run.exec-hint.test.ts | 95 ++++++++++++ .../reply/get-reply-run.media-only.test.ts | 58 +++++++ src/auto-reply/reply/get-reply-run.ts | 145 ++++++++++++++++-- 5 files changed, 359 insertions(+), 18 deletions(-) diff --git a/src/agents/cli-runner.spawn.test.ts b/src/agents/cli-runner.spawn.test.ts index 59cfd95e909..d8e080cafe1 100644 --- a/src/agents/cli-runner.spawn.test.ts +++ b/src/agents/cli-runner.spawn.test.ts @@ -25,7 +25,11 @@ import { resetClaudeLiveSessionsForTest, runClaudeLiveSessionTurn, } from "./cli-runner/claude-live-session.js"; -import { buildCliEnvAuthLog, executePreparedCliRun } from "./cli-runner/execute.js"; +import { + buildCliEnvAuthLog, + buildCliExecLogLine, + executePreparedCliRun, +} from "./cli-runner/execute.js"; import { buildSystemPrompt } from "./cli-runner/helpers.js"; import { setCliRunnerPrepareTestDeps } from "./cli-runner/prepare.js"; import type { PreparedCliRunContext } from "./cli-runner/types.js"; @@ -127,6 +131,27 @@ function buildPreparedCliRunContext(params: { } describe("runCliAgent spawn path", () => { + it("formats redacted CLI resume diagnostics without exposing raw session ids", () => { + const logLine = buildCliExecLogLine({ + provider: "claude-cli", + model: "claude-opus-4-7", + promptChars: 42, + trigger: "heartbeat", + useResume: true, + cliSessionId: "claude-session-secret", + resolvedSessionId: "claude-session-secret", + reusableSessionId: "claude-session-secret", + hasHistoryPrompt: false, + }); + + expect(logLine).toContain("trigger=heartbeat"); + expect(logLine).toContain("useResume=true"); + expect(logLine).toContain("session=present"); + expect(logLine).toContain("reuse=reusable"); + expect(logLine).toContain("historyPrompt=none"); + expect(logLine).not.toContain("claude-session-secret"); + }); + it("does not inject hardcoded 'Tools are disabled' text into CLI arguments", async () => { supervisorSpawnMock.mockResolvedValueOnce( createManagedRun({ diff --git a/src/agents/cli-runner/execute.ts b/src/agents/cli-runner/execute.ts index 0d6ed5c2cde..38fb9c59fdc 100644 --- a/src/agents/cli-runner/execute.ts +++ b/src/agents/cli-runner/execute.ts @@ -1,3 +1,4 @@ +import crypto from "node:crypto"; import { shouldLogVerbose } from "../../globals.js"; import { emitAgentEvent } from "../../infra/agent-events.js"; import { isTruthyEnvValue } from "../../infra/env.js"; @@ -164,6 +165,44 @@ function buildCliEnvMcpLog(childEnv: Record): string { ].join(" "); } +function fingerprintCliSessionId(sessionId?: string): string { + const trimmed = sessionId?.trim(); + if (!trimmed) { + return "none"; + } + return crypto.createHash("sha256").update(trimmed).digest("hex").slice(0, 12); +} + +export function buildCliExecLogLine(params: { + provider: string; + model: string; + promptChars: number; + trigger?: string; + useResume: boolean; + cliSessionId?: string; + resolvedSessionId?: string; + reusableSessionId?: string; + invalidatedReason?: string; + hasHistoryPrompt: boolean; +}): string { + const reuseState = params.reusableSessionId + ? "reusable" + : params.invalidatedReason + ? `invalidated:${params.invalidatedReason}` + : "none"; + return [ + `cli exec: provider=${params.provider}`, + `model=${params.model}`, + `promptChars=${params.promptChars}`, + `trigger=${params.trigger ?? "unknown"}`, + `useResume=${params.useResume ? "true" : "false"}`, + `session=${params.cliSessionId ? "present" : "none"}`, + `resumeSession=${params.useResume ? fingerprintCliSessionId(params.resolvedSessionId) : "none"}`, + `reuse=${reuseState}`, + `historyPrompt=${params.hasHistoryPrompt ? "present" : "none"}`, + ].join(" "); +} + export function buildCliEnvAuthLog(childEnv: Record): string { const hostKeys = listPresentCliAuthEnvKeys(process.env); const childKeys = listPresentCliAuthEnvKeys(childEnv); @@ -273,7 +312,18 @@ export async function executePreparedCliRun( : undefined; try { cliBackendLog.info( - `cli exec: provider=${params.provider} model=${context.normalizedModel} promptChars=${basePrompt.length}`, + buildCliExecLogLine({ + provider: params.provider, + model: context.normalizedModel, + promptChars: basePrompt.length, + trigger: params.trigger, + useResume, + cliSessionId: cliSessionIdToUse, + resolvedSessionId, + reusableSessionId: context.reusableCliSession.sessionId, + invalidatedReason: context.reusableCliSession.invalidatedReason, + hasHistoryPrompt: Boolean(context.openClawHistoryPrompt), + }), ); const logOutputText = isTruthyEnvValue(process.env[CLI_BACKEND_LOG_OUTPUT_ENV]) || diff --git a/src/auto-reply/reply/get-reply-run.exec-hint.test.ts b/src/auto-reply/reply/get-reply-run.exec-hint.test.ts index 3d49048a9cc..efa35dd6343 100644 --- a/src/auto-reply/reply/get-reply-run.exec-hint.test.ts +++ b/src/auto-reply/reply/get-reply-run.exec-hint.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; +import type { SessionEntry } from "../../config/sessions.js"; +import type { TemplateContext } from "../templating.js"; import { buildExecOverridePromptHint, + resolvePromptSessionContextForSystemEvent, resolvePromptSilentReplyConversationType, } from "./get-reply-run.js"; import { buildGetReplyCtx, buildGetReplyGroupCtx } from "./get-reply.test-fixtures.js"; @@ -106,3 +109,95 @@ describe("resolvePromptSilentReplyConversationType", () => { ).toBeUndefined(); }); }); + +describe("resolvePromptSessionContextForSystemEvent", () => { + it("rebuilds missing system-event chat metadata from the persisted session entry", () => { + const sessionCtx = { + Body: "wake up", + Provider: "cron-event", + Surface: "cron-event", + } as TemplateContext; + const sessionEntry = { + sessionId: "session-1", + updatedAt: 1, + chatType: "channel", + channel: "discord", + groupId: "guild-1", + groupChannel: "#ops", + space: "Ops Guild", + origin: { + provider: "discord", + surface: "discord", + chatType: "channel", + to: "channel-1", + accountId: "acct-1", + threadId: "thread-1", + }, + lastChannel: "discord", + lastTo: "channel-1", + lastAccountId: "acct-1", + lastThreadId: "thread-1", + } satisfies SessionEntry; + + const result = resolvePromptSessionContextForSystemEvent({ + sessionCtx, + sessionEntry, + ctx: { Provider: "cron-event" }, + }); + + expect(result).not.toBe(sessionCtx); + expect(result).toMatchObject({ + Provider: "discord", + Surface: "discord", + ChatType: "channel", + GroupChannel: "#ops", + GroupSpace: "Ops Guild", + OriginatingChannel: "discord", + OriginatingTo: "channel-1", + AccountId: "acct-1", + MessageThreadId: "thread-1", + }); + }); + + it("keeps normal user turns on their live chat metadata", () => { + const sessionCtx = buildGetReplyGroupCtx({ + Provider: "discord", + Surface: "discord", + ChatType: "group", + }) as TemplateContext; + const result = resolvePromptSessionContextForSystemEvent({ + sessionCtx, + sessionEntry: { + sessionId: "session-1", + updatedAt: 1, + chatType: "direct", + channel: "telegram", + }, + ctx: { Provider: "discord" }, + }); + + expect(result).toBe(sessionCtx); + }); + + it("does not overwrite explicit system-event chat metadata", () => { + const sessionCtx = { + Provider: "discord", + Surface: "discord", + ChatType: "direct", + OriginatingChannel: "discord", + } as TemplateContext; + const result = resolvePromptSessionContextForSystemEvent({ + sessionCtx, + sessionEntry: { + sessionId: "session-1", + updatedAt: 1, + chatType: "channel", + channel: "discord", + groupChannel: "#ops", + }, + ctx: { Provider: "heartbeat" }, + }); + + expect(result).toBe(sessionCtx); + }); +}); diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index f1396999953..4f74a577b0b 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -130,6 +130,7 @@ let runReplyAgent: typeof import("./agent-runner.runtime.js").runReplyAgent; let routeReply: typeof import("./route-reply.runtime.js").routeReply; let drainFormattedSystemEvents: typeof import("./session-system-events.js").drainFormattedSystemEvents; let resolveTypingMode: typeof import("./typing-mode.js").resolveTypingMode; +let buildGroupChatContext: typeof import("./groups.js").buildGroupChatContext; let buildInboundUserContextPrefix: typeof import("./inbound-meta.js").buildInboundUserContextPrefix; let getActiveReplyRunCount: typeof import("./reply-run-registry.js").getActiveReplyRunCount; let replyRunTesting: typeof import("./reply-run-registry.js").__testing; @@ -241,6 +242,7 @@ describe("runPreparedReply media-only handling", () => { ({ routeReply } = await import("./route-reply.runtime.js")); ({ drainFormattedSystemEvents } = await import("./session-system-events.js")); ({ resolveTypingMode } = await import("./typing-mode.js")); + ({ buildGroupChatContext } = await import("./groups.js")); ({ buildInboundUserContextPrefix } = await import("./inbound-meta.js")); ({ __testing: replyRunTesting, getActiveReplyRunCount } = await import("./reply-run-registry.js")); @@ -1019,6 +1021,62 @@ describe("runPreparedReply media-only handling", () => { expect(call?.followupRun.transcriptPrompt).toBe("[OpenClaw heartbeat poll]"); }); + it("uses persisted Discord chat metadata for system-event CLI static prompt identity", async () => { + vi.mocked(buildGroupChatContext).mockImplementationOnce(({ sessionCtx }) => + [`group`, sessionCtx.Provider, sessionCtx.ChatType, sessionCtx.GroupChannel].join(":"), + ); + + await runPreparedReply( + baseParams({ + opts: { isHeartbeat: true }, + isNewSession: false, + systemSent: true, + ctx: { + Body: "scheduled wake", + RawBody: "scheduled wake", + CommandBody: "scheduled wake", + Provider: "cron-event", + SessionKey: "agent:main:discord:guild-1:channel-1", + }, + sessionCtx: { + Body: "scheduled wake", + BodyStripped: "scheduled wake", + Provider: "cron-event", + }, + sessionEntry: { + sessionId: "session-1", + updatedAt: 1, + systemSent: true, + chatType: "channel", + channel: "discord", + groupId: "guild-1", + groupChannel: "#ops", + lastChannel: "discord", + lastTo: "channel-1", + origin: { + provider: "discord", + surface: "discord", + chatType: "channel", + to: "channel-1", + }, + } as SessionEntry, + }), + ); + + const call = vi.mocked(runReplyAgent).mock.calls.at(-1)?.[0]; + expect(buildGroupChatContext).toHaveBeenCalledWith( + expect.objectContaining({ + sessionCtx: expect.objectContaining({ + Provider: "discord", + Surface: "discord", + ChatType: "channel", + GroupChannel: "#ops", + }), + }), + ); + expect(call?.followupRun.run.extraSystemPromptStatic).toBe("group:discord:channel:#ops"); + }); + it("uses a non-empty transcript marker while keeping bare reset startup instructions out of visible transcript prompt", async () => { await runPreparedReply( baseParams({ diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 1067265a091..7bbf9d1b5ce 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -45,6 +45,7 @@ import type { GetReplyOptions, ReplyPayload } from "../types.js"; import { applySessionHints } from "./body.js"; import type { buildCommandContext } from "./commands.js"; import type { InlineDirectives } from "./directive-handling.js"; +import { isSystemEventProvider } from "./effective-reply-route.js"; import { shouldUseReplyFastTestRuntime } from "./get-reply-fast-path.js"; import { resolvePreparedReplyQueueState } from "./get-reply-run-queue.js"; import { @@ -94,6 +95,111 @@ export function resolvePromptSilentReplyConversationType(params: { return undefined; } +function normalizePromptRouteChannel(raw?: string | null): string | undefined { + const normalized = normalizeOptionalString(raw); + return normalized && normalized !== "none" ? normalized : undefined; +} + +function resolvePersistedPromptProvider(entry?: SessionEntry): string | undefined { + return ( + normalizePromptRouteChannel(entry?.origin?.provider) ?? + normalizePromptRouteChannel(entry?.channel) ?? + normalizePromptRouteChannel(entry?.lastChannel) ?? + normalizePromptRouteChannel(entry?.deliveryContext?.channel) + ); +} + +function resolvePersistedPromptSurface(entry?: SessionEntry): string | undefined { + return ( + normalizePromptRouteChannel(entry?.origin?.surface) ?? resolvePersistedPromptProvider(entry) + ); +} + +export function resolvePromptSessionContextForSystemEvent(params: { + sessionCtx: TemplateContext; + sessionEntry?: SessionEntry; + ctx?: Pick; + isHeartbeat?: boolean; +}): TemplateContext { + const { sessionCtx, sessionEntry } = params; + const isSystemEvent = + params.isHeartbeat === true || + isSystemEventProvider(params.ctx?.Provider) || + isSystemEventProvider(sessionCtx.Provider); + if (!isSystemEvent || !sessionEntry) { + return sessionCtx; + } + + const persistedChatType = + normalizeChatType(sessionEntry.chatType) ?? normalizeChatType(sessionEntry.origin?.chatType); + const liveChatType = normalizeChatType(sessionCtx.ChatType); + const effectiveChatType = liveChatType ?? persistedChatType; + const persistedProvider = resolvePersistedPromptProvider(sessionEntry); + const persistedSurface = resolvePersistedPromptSurface(sessionEntry); + const liveProvider = normalizeOptionalString(sessionCtx.Provider); + const liveSurface = normalizeOptionalString(sessionCtx.Surface); + const nextProvider = + liveProvider && !isSystemEventProvider(liveProvider) + ? liveProvider + : (persistedProvider ?? liveProvider); + const nextSurface = + liveSurface && !isSystemEventProvider(liveSurface) + ? liveSurface + : (persistedSurface ?? liveSurface); + + const next: TemplateContext = { ...sessionCtx }; + let changed = false; + const setIfMissing = (key: K, value: TemplateContext[K]) => { + if (next[key] != null && next[key] !== "") { + return; + } + if (value == null || value === "") { + return; + } + next[key] = value; + changed = true; + }; + const setIfChanged = (key: K, value: TemplateContext[K]) => { + if (value == null || value === "" || next[key] === value) { + return; + } + next[key] = value; + changed = true; + }; + + setIfChanged("Provider", nextProvider); + setIfChanged("Surface", nextSurface); + setIfMissing("ChatType", persistedChatType); + if (effectiveChatType === "group" || effectiveChatType === "channel") { + setIfMissing("GroupSubject", normalizeOptionalString(sessionEntry.subject)); + setIfMissing("GroupChannel", normalizeOptionalString(sessionEntry.groupChannel)); + setIfMissing("GroupSpace", normalizeOptionalString(sessionEntry.space)); + } + setIfMissing("OriginatingChannel", persistedProvider); + setIfMissing( + "OriginatingTo", + normalizeOptionalString( + sessionEntry.lastTo ?? sessionEntry.deliveryContext?.to ?? sessionEntry.origin?.to, + ), + ); + setIfMissing( + "AccountId", + normalizeOptionalString( + sessionEntry.lastAccountId ?? + sessionEntry.deliveryContext?.accountId ?? + sessionEntry.origin?.accountId, + ), + ); + setIfMissing( + "MessageThreadId", + sessionEntry.lastThreadId ?? + sessionEntry.deliveryContext?.threadId ?? + sessionEntry.origin?.threadId, + ); + + return changed ? next : sessionCtx; +} + export function buildExecOverridePromptHint(params: { execOverrides?: ExecOverrides; elevatedLevel: ElevatedLevel; @@ -278,15 +384,6 @@ export async function runPreparedReply( ctx, sessionKey, }); - const silentReplySettings = resolveSilentReplySettings({ - cfg, - sessionKey: runtimePolicySessionKey, - surface: sessionCtx.Surface ?? sessionCtx.Provider, - conversationType: resolvePromptSilentReplyConversationType({ - ctx: sessionCtx, - inboundSessionKey: ctx.SessionKey, - }), - }); let { sessionEntry, resolvedThinkLevel, @@ -296,6 +393,22 @@ export async function runPreparedReply( execOverrides, abortedLastRun, } = params; + const isHeartbeat = opts?.isHeartbeat === true; + const promptSessionCtx = resolvePromptSessionContextForSystemEvent({ + sessionCtx, + sessionEntry, + ctx, + isHeartbeat, + }); + const silentReplySettings = resolveSilentReplySettings({ + cfg, + sessionKey: runtimePolicySessionKey, + surface: promptSessionCtx.Surface ?? promptSessionCtx.Provider, + conversationType: resolvePromptSilentReplyConversationType({ + ctx: promptSessionCtx, + inboundSessionKey: ctx.SessionKey, + }), + }); const useFastReplyRuntime = shouldUseReplyFastTestRuntime({ cfg, isFastTestEnv: process.env.OPENCLAW_TEST_FAST === "1", @@ -310,9 +423,9 @@ export async function runPreparedReply( let currentSystemSent = systemSent; const isFirstTurnInSession = isNewSession || !currentSystemSent; - const isGroupChat = sessionCtx.ChatType === "group" || sessionCtx.ChatType === "channel"; + const isGroupChat = + promptSessionCtx.ChatType === "group" || promptSessionCtx.ChatType === "channel"; const wasMentioned = ctx.WasMentioned === true; - const isHeartbeat = opts?.isHeartbeat === true; const { typingPolicy, suppressTyping } = resolveRunTypingPolicy({ requestedPolicy: opts?.typingPolicy, suppressTyping: opts?.suppressTyping === true, @@ -332,9 +445,9 @@ export async function runPreparedReply( isGroupChat && (isFirstTurnInSession || sessionEntry?.groupActivationNeedsSystemIntro), ); const directChatContext = - sessionCtx.ChatType === "direct" || sessionCtx.ChatType === "dm" + promptSessionCtx.ChatType === "direct" || promptSessionCtx.ChatType === "dm" ? buildDirectChatContext({ - sessionCtx, + sessionCtx: promptSessionCtx, silentReplyPolicy: silentReplySettings.policy, silentReplyRewrite: silentReplySettings.rewrite, silentToken: SILENT_REPLY_TOKEN, @@ -343,7 +456,7 @@ export async function runPreparedReply( // Always include persistent group chat context (provider + reply guidance). const groupChatContext = isGroupChat ? buildGroupChatContext({ - sessionCtx, + sessionCtx: promptSessionCtx, sourceReplyDeliveryMode: opts?.sourceReplyDeliveryMode, silentReplyPolicy: silentReplySettings.policy, silentReplyRewrite: silentReplySettings.rewrite, @@ -354,7 +467,7 @@ export async function runPreparedReply( const groupIntro = shouldInjectGroupIntro ? buildGroupIntro({ cfg, - sessionCtx, + sessionCtx: promptSessionCtx, sessionEntry, defaultActivation, silentToken: SILENT_REPLY_TOKEN, @@ -370,7 +483,7 @@ export async function runPreparedReply( silentReplyPolicy: silentReplySettings.policy, silentReplyRewrite: silentReplySettings.rewrite, }).allowEmptyAssistantReplyAsSilent; - const groupSystemPrompt = normalizeOptionalString(sessionCtx.GroupSystemPrompt) ?? ""; + const groupSystemPrompt = normalizeOptionalString(promptSessionCtx.GroupSystemPrompt) ?? ""; const inboundMetaPrompt = buildInboundMetaSystemPrompt( isNewSession ? sessionCtx : { ...sessionCtx, ThreadStarterBody: undefined }, { includeFormattingHints: !useFastReplyRuntime },