From ba97cd010a0340da51076593a6a6cf8fd41758cd Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:02:54 -0500 Subject: [PATCH] fix(auto-reply): align prepared delivery prompts --- src/auto-reply/reply/get-reply-run-context.ts | 61 +++++++++++------ src/auto-reply/reply/get-reply-run-execute.ts | 3 + .../reply/get-reply-run.media-only.test.ts | 35 +++++++++- ...arness-source-delivery.integration.test.ts | 67 ++++++++++++++++++- .../reply/source-reply-delivery-runtime.ts | 18 ++++- 5 files changed, 160 insertions(+), 24 deletions(-) diff --git a/src/auto-reply/reply/get-reply-run-context.ts b/src/auto-reply/reply/get-reply-run-context.ts index 61e9ed5e64d5..e6f738cb4cf1 100644 --- a/src/auto-reply/reply/get-reply-run-context.ts +++ b/src/auto-reply/reply/get-reply-run-context.ts @@ -161,21 +161,30 @@ export async function prepareReplyRunContext(params: RunPreparedReplyParams) { const shouldInjectGroupIntro = Boolean( isGroupChat && (isFirstTurnInSession || sessionEntry?.groupActivationNeedsSystemIntro), ); - const directChatContext = isDirectChat - ? buildDirectChatContext({ + const buildSourceConversationContext = (mode: typeof sourceReplyDeliveryMode) => { + if (isDirectChat) { + return buildDirectChatContext({ + sourceReplyDeliveryMode: mode, sessionCtx: promptSessionCtx, - sourceReplyDeliveryMode: sessionPromptSourceReplyDeliveryMode, - }) - : ""; - // Always include persistent group chat context (provider + reply guidance). - const groupChatContext = isGroupChat - ? buildGroupChatContext({ - sessionCtx: promptSessionCtx, - sourceReplyDeliveryMode: sessionPromptSourceReplyDeliveryMode, - silentReplyPolicy: silentReplySettings.policy, - silentToken: SILENT_REPLY_TOKEN, - }) - : ""; + }); + } + return isGroupChat + ? buildGroupChatContext({ + sessionCtx: promptSessionCtx, + sourceReplyDeliveryMode: mode, + silentReplyPolicy: silentReplySettings.policy, + silentToken: SILENT_REPLY_TOKEN, + }) + : ""; + }; + const sourceConversationContextByMode = { + automatic: buildSourceConversationContext("automatic"), + message_tool_only: buildSourceConversationContext("message_tool_only"), + }; + // CLI sessions keep their creation-time conversation prompt. Embedded attempts + // can instead select the variant owned by their final prepared harness. + const sessionStableConversationContext = + sourceConversationContextByMode[sessionPromptSourceReplyDeliveryMode ?? "automatic"]; // Claude CLI fixes the system prompt at session creation; group intro must stay session-stable. const groupIntro = isGroupChat ? buildGroupIntro({ sessionEntry, defaultActivation }) : ""; const isDirectedTurn = @@ -199,21 +208,34 @@ export async function prepareReplyRunContext(params: RunPreparedReplyParams) { }); const extraSystemPromptParts = [ inboundMetaPrompt, - directChatContext, - groupChatContext, + sessionStableConversationContext, groupIntro, groupSystemPrompt, execOverridePromptHint, ].filter(Boolean); const extraSystemPromptStatic = [ - directChatContext, - groupChatContext, + sessionStableConversationContext, groupIntro, groupSystemPrompt, execOverridePromptHint, ] .filter(Boolean) .join("\n\n"); + const buildExtraSystemPrompt = (mode: keyof typeof sourceConversationContextByMode) => + [ + inboundMetaPrompt, + sourceConversationContextByMode[mode], + groupIntro, + groupSystemPrompt, + execOverridePromptHint, + ] + .filter(Boolean) + .join("\n\n"); + // Prepared harness selects one bounded variant; both are never model-visible together. + const extraSystemPromptBySourceReplyDeliveryMode = { + automatic: buildExtraSystemPrompt("automatic"), + message_tool_only: buildExtraSystemPrompt("message_tool_only"), + }; const cliSessionBindingFacts = { extraSystemPromptStatic, ...(sessionPromptSourceReplyDeliveryMode @@ -221,7 +243,7 @@ export async function prepareReplyRunContext(params: RunPreparedReplyParams) { : {}), }; const silentReplyPromptMode: SilentReplyPromptMode = - directChatContext || groupChatContext || sourceReplyDeliveryMode === "message_tool_only" + sessionStableConversationContext || sourceReplyDeliveryMode === "message_tool_only" ? "none" : "generic"; const baseBody = sessionCtx.agentText ?? ""; @@ -399,6 +421,7 @@ export async function prepareReplyRunContext(params: RunPreparedReplyParams) { fullAccessState, isFirstTurnInSession, extraSystemPromptParts, + extraSystemPromptBySourceReplyDeliveryMode, extraSystemPromptStatic, cliSessionBindingFacts, baseBodyTrimmedRaw, diff --git a/src/auto-reply/reply/get-reply-run-execute.ts b/src/auto-reply/reply/get-reply-run-execute.ts index 5ba5307be1c3..b06b329212e9 100644 --- a/src/auto-reply/reply/get-reply-run-execute.ts +++ b/src/auto-reply/reply/get-reply-run-execute.ts @@ -37,6 +37,7 @@ import { resolveReplyToMode } from "./reply-threading.js"; import { resolveRoutedDeliveryThreadId } from "./routed-delivery-thread.js"; import { setSourceReplyDeliveryModeOrigin, + setSourceReplyDeliveryPromptVariants, type SourceReplyDeliveryRuntimeOptions, } from "./source-reply-delivery-runtime.js"; import { @@ -86,6 +87,7 @@ export async function executePreparedReplyRun(state: PreparedReplyRunAdmission) useFastReplyRuntime, fullAccessState, extraSystemPromptParts, + extraSystemPromptBySourceReplyDeliveryMode, extraSystemPromptStatic, cliSessionBindingFacts, baseBodyTrimmedRaw, @@ -455,6 +457,7 @@ export async function executePreparedReplyRun(state: PreparedReplyRunAdmission) followupRun.run, sourceReplyDeliveryRuntimeOptions?.sourceReplyDeliveryModeOrigin, ); + setSourceReplyDeliveryPromptVariants(followupRun.run, extraSystemPromptBySourceReplyDeliveryMode); const replyThreadingOverride = isBareSessionReset && sessionCtx.ReplyThreading?.implicitCurrentMessage !== "deny" ? { ...sessionCtx.ReplyThreading, implicitCurrentMessage: "deny" as const } 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 fd3261cd11d1..51d1b40f5e40 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 @@ -36,6 +36,7 @@ import { testing as replyRunTesting } from "./reply-run-registry.test-support.js import { routeReply } from "./route-reply.runtime.js"; import { drainFormattedSystemEvents } from "./session-system-events.js"; import { + publishPreparedHarnessSourceReplyDeliveryMode, readSourceReplyDeliveryModeOrigin, type SourceReplyDeliveryRuntimeOptions, } from "./source-reply-delivery-runtime.js"; @@ -637,10 +638,11 @@ describe("runPreparedReply media-only handling", () => { }, }); - expect(buildDirectChatContext).toHaveBeenCalledTimes(1); + expect(buildDirectChatContext).toHaveBeenCalledTimes(2); const directContextParams = requireMockCallArg( vi.mocked(buildDirectChatContext), "direct chat context", + 1, ) as { sessionCtx?: { Provider?: string; ChatType?: string }; sourceReplyDeliveryMode?: string; @@ -668,6 +670,34 @@ describe("runPreparedReply media-only handling", () => { ); }); + it("binds prepared embedded prompt variants without changing CLI session guidance", async () => { + vi.mocked(buildDirectChatContext).mockImplementation( + ({ sourceReplyDeliveryMode }) => `direct:${sourceReplyDeliveryMode ?? "automatic"}`, + ); + await runPrepared({ + opts: { + sourceReplyDeliveryMode: "message_tool_only", + sourceReplyDeliveryModeOrigin: "runtime_default", + } as NonNullable[0]["opts"]> & + SourceReplyDeliveryRuntimeOptions, + ctx: { ...createInboundTurn("hello", "discord", "direct") }, + sessionCtx: { ...createSessionTurn("hello", "discord", "direct") }, + }); + + const run = requireLastRunReplyAgentCall().followupRun.run; + expect(run.extraSystemPrompt).toBe("direct:message_tool_only"); + expect(run.extraSystemPromptStatic).toBe("direct:message_tool_only"); + publishPreparedHarnessSourceReplyDeliveryMode(run, "automatic"); + expect(run.extraSystemPrompt).toBe("direct:automatic"); + expect(run.extraSystemPromptStatic).toBe("direct:message_tool_only"); + publishPreparedHarnessSourceReplyDeliveryMode(run, "message_tool_only"); + expect(run.extraSystemPrompt).toBe("direct:message_tool_only"); + expect(run.cliSessionBindingFacts).toEqual({ + extraSystemPromptStatic: "direct:message_tool_only", + sourceReplyDeliveryMode: "message_tool_only", + }); + }); + it("keeps addressed message-tool delivery hints out of persisted transcript rows", async () => { vi.mocked(buildInboundUserContextPrefix).mockReturnValueOnce( "Current message:\nchat_id=-100123\ninbound_event_kind: user_request", @@ -2849,6 +2879,7 @@ describe("runPreparedReply media-only handling", () => { const directContextParams = requireMockCallArg( vi.mocked(buildDirectChatContext), "direct chat context", + 1, ) as { sourceReplyDeliveryMode?: string }; const call = requireLastRunReplyAgentCall(); expect(directContextParams?.sourceReplyDeliveryMode).toBe("message_tool_only"); @@ -2953,7 +2984,7 @@ describe("runPreparedReply media-only handling", () => { }); const call = requireLastRunReplyAgentCall(); - expect(buildGroupChatContext).toHaveBeenCalledTimes(1); + expect(buildGroupChatContext).toHaveBeenCalledTimes(2); const groupContextParams = requireMockCallArg( vi.mocked(buildGroupChatContext), "group chat context", diff --git a/src/auto-reply/reply/prepared-harness-source-delivery.integration.test.ts b/src/auto-reply/reply/prepared-harness-source-delivery.integration.test.ts index 5252b1fbee28..1f30771df541 100644 --- a/src/auto-reply/reply/prepared-harness-source-delivery.integration.test.ts +++ b/src/auto-reply/reply/prepared-harness-source-delivery.integration.test.ts @@ -7,6 +7,7 @@ import { mockedRunEmbeddedAttempt, useOpenAIPlatformAuthFixture, } from "../../agents/embedded-agent-runner/run.overflow-compaction.harness.js"; +import { buildEmbeddedSystemPrompt } from "../../agents/embedded-agent-runner/system-prompt.js"; import { registerAgentHarness } from "../../agents/harness/registry.js"; import { settleReplyDispatcher } from "../dispatch-dispatcher.js"; import type { MsgContext } from "../templating.js"; @@ -27,8 +28,12 @@ import { setNoAbort, } from "./dispatch-from-config.test-harness.js"; import type { InternalGetReplyOptions } from "./get-reply.types.js"; +import { buildDirectChatContext } from "./groups.js"; import { createReplyDispatcher } from "./reply-dispatcher.js"; -import { setSourceReplyDeliveryModeOrigin } from "./source-reply-delivery-runtime.js"; +import { + setSourceReplyDeliveryModeOrigin, + setSourceReplyDeliveryPromptVariants, +} from "./source-reply-delivery-runtime.js"; import { buildTestCtx } from "./test-ctx.js"; const runnerState = setupAgentRunnerExecutionTestState(); @@ -102,8 +107,34 @@ describe("prepared harness source delivery", () => { modelOverride: "gpt-5.4", }); const emittedStreamingCallbacks: string[] = []; + let modelVisiblePrompt = ""; + const recordModelVisiblePrompt = (attemptParams: { + extraSystemPrompt?: string; + sourceReplyDeliveryMode?: "automatic" | "message_tool_only"; + }) => { + modelVisiblePrompt = buildEmbeddedSystemPrompt({ + workspaceDir: "/tmp/workspace", + reasoningTagHint: false, + extraSystemPrompt: attemptParams.extraSystemPrompt, + sourceReplyDeliveryMode: attemptParams.sourceReplyDeliveryMode, + runtimeInfo: { + host: "host", + os: "linux", + arch: "arm64", + node: "24", + model: "model", + provider: "custom", + channel: "discord", + chatType: "direct", + }, + tools: [], + userTimezone: "UTC", + userDate: "2026-08-11", + }); + }; mockedBuildEmbeddedRunPayloads.mockReturnValue([{ text: "Short fallback final" }]); mockedRunEmbeddedAttempt.mockImplementation(async (attemptParams) => { + recordModelVisiblePrompt(attemptParams); emittedStreamingCallbacks.push("partial"); await attemptParams.onPartialReply?.({ text: "Short fallback final" }); emittedStreamingCallbacks.push("block"); @@ -164,6 +195,7 @@ describe("prepared harness source delivery", () => { ? { supported: true, priority: 200 } : { supported: false, reason: "prepared OpenAI route only" }, runAttempt: vi.fn(async (attemptParams) => { + recordModelVisiblePrompt(attemptParams); emittedStreamingCallbacks.push("partial"); await attemptParams.onPartialReply?.({ text: "Short fallback final" }); emittedStreamingCallbacks.push("block"); @@ -201,6 +233,24 @@ describe("prepared harness source delivery", () => { followupRun.run.sessionKey = undefined; followupRun.run.sessionFile = followupRun.run.sessionId; followupRun.run.sourceReplyDeliveryMode = runtimeOpts.sourceReplyDeliveryMode; + const extraSystemPromptBySourceReplyDeliveryMode = { + automatic: buildDirectChatContext({ + sessionCtx: { Provider: "discord", ChatType: "direct" }, + sourceReplyDeliveryMode: "automatic", + }), + message_tool_only: buildDirectChatContext({ + sessionCtx: { Provider: "discord", ChatType: "direct" }, + sourceReplyDeliveryMode: "message_tool_only", + }), + }; + setSourceReplyDeliveryPromptVariants( + followupRun.run, + extraSystemPromptBySourceReplyDeliveryMode, + ); + followupRun.run.extraSystemPrompt = + extraSystemPromptBySourceReplyDeliveryMode[ + runtimeOpts.sourceReplyDeliveryMode ?? "automatic" + ]; setSourceReplyDeliveryModeOrigin(followupRun.run, runtimeOpts.sourceReplyDeliveryModeOrigin); // Dispatch already captured its session snapshot; the embedded fixture uses // a SQLite compatibility key and has no durable row for writer admission. @@ -286,5 +336,20 @@ describe("prepared harness source delivery", () => { }); expect(dispatcher.getFailedCounts()).toEqual({ tool: 0, block: 0, final: 0 }); expect(modeTransitions).toEqual(testCase.expectedTransitions); + if (testCase.preparedVisibleReplies === "automatic") { + expect(modelVisiblePrompt).toContain("Current-session final text normally routes to source"); + expect(modelVisiblePrompt).toContain( + "Your replies are automatically sent to this conversation", + ); + expect(modelVisiblePrompt).not.toContain("Normal final replies are private"); + } else { + expect(modelVisiblePrompt).toContain( + "Current source visible reply MUST use `message(action=send)`", + ); + expect(modelVisiblePrompt).toContain("Normal final replies are private"); + expect(modelVisiblePrompt).not.toContain( + "Your replies are automatically sent to this conversation", + ); + } }); }); diff --git a/src/auto-reply/reply/source-reply-delivery-runtime.ts b/src/auto-reply/reply/source-reply-delivery-runtime.ts index ce4b0d6b08c0..0f3f62c9ac7a 100644 --- a/src/auto-reply/reply/source-reply-delivery-runtime.ts +++ b/src/auto-reply/reply/source-reply-delivery-runtime.ts @@ -8,8 +8,8 @@ export type SourceReplyDeliveryRuntimeOptions = { }; // The shared enumerable binding follows queue/run spreads without widening their public types. -// Its listener moves prepared ownership before live callbacks; copying only the mode would leave -// pre-settlement source delivery on the preliminary policy. +// Its listener and bounded prompt pair move prepared ownership before prompt/live callbacks; +// plugin handoff strips this symbol so neither mutable authority nor alternate prompt leaks. const sourceReplyDeliveryModeOriginKey: unique symbol = Symbol.for( "openclaw.source-reply-delivery-runtime", ); @@ -17,6 +17,7 @@ type SourceReplyDeliveryRuntimeBinding = { origin?: SourceReplyDeliveryModeOrigin; preparedHarnessMode?: SourceReplyDeliveryMode; preparedHarnessModeListener?: (mode: SourceReplyDeliveryMode) => void; + extraSystemPromptByMode?: Record; }; type SourceReplyDeliveryModeOwner = { [sourceReplyDeliveryModeOriginKey]?: SourceReplyDeliveryRuntimeBinding; @@ -43,6 +44,15 @@ export function readSourceReplyDeliveryModeOrigin( return readSourceReplyDeliveryRuntimeBinding(owner)?.origin; } +export function setSourceReplyDeliveryPromptVariants( + owner: object, + variants: Record, +): void { + const binding = readSourceReplyDeliveryRuntimeBinding(owner) ?? {}; + binding.extraSystemPromptByMode = variants; + (owner as SourceReplyDeliveryModeOwner)[sourceReplyDeliveryModeOriginKey] = binding; +} + export function copySourceReplyDeliveryRuntimeBinding(source: object, target: object): void { const binding = readSourceReplyDeliveryRuntimeBinding(source); if (binding) { @@ -57,6 +67,10 @@ export function publishPreparedHarnessSourceReplyDeliveryMode( const binding = readSourceReplyDeliveryRuntimeBinding(owner); if (binding?.origin === "runtime_default") { binding.preparedHarnessMode = mode; + const extraSystemPrompt = binding.extraSystemPromptByMode?.[mode]; + if (extraSystemPrompt !== undefined) { + (owner as { extraSystemPrompt?: string }).extraSystemPrompt = extraSystemPrompt; + } binding.preparedHarnessModeListener?.(mode); } }