fix(agents): preserve CLI wake-up session metadata (#74171)

* Fix CLI wake-up resume metadata

* Rerun CI

* ci: re-trigger parity gate
This commit is contained in:
bitloi
2026-04-29 08:14:48 -03:00
committed by GitHub
parent 1d494af03a
commit dce2513db2
5 changed files with 359 additions and 18 deletions

View File

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

View File

@@ -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, string>): 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, string>): 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]) ||

View File

@@ -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);
});
});

View File

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

View File

@@ -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<MsgContext, "Provider">;
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 = <K extends keyof TemplateContext>(key: K, value: TemplateContext[K]) => {
if (next[key] != null && next[key] !== "") {
return;
}
if (value == null || value === "") {
return;
}
next[key] = value;
changed = true;
};
const setIfChanged = <K extends keyof TemplateContext>(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 },