fix(auto-reply): align prepared delivery prompts

This commit is contained in:
joshavant
2026-08-11 00:02:54 -05:00
committed by Josh Avant
parent 174800da0e
commit ba97cd010a
5 changed files with 160 additions and 24 deletions
+42 -19
View File
@@ -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,
@@ -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 }
@@ -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<Parameters<typeof runPreparedReply>[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",
@@ -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",
);
}
});
});
@@ -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<SourceReplyDeliveryMode, string>;
};
type SourceReplyDeliveryModeOwner = {
[sourceReplyDeliveryModeOriginKey]?: SourceReplyDeliveryRuntimeBinding;
@@ -43,6 +44,15 @@ export function readSourceReplyDeliveryModeOrigin(
return readSourceReplyDeliveryRuntimeBinding(owner)?.origin;
}
export function setSourceReplyDeliveryPromptVariants(
owner: object,
variants: Record<SourceReplyDeliveryMode, string>,
): 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);
}
}