



























@@ -190,6 +190,14 @@ vi.mock("../../agents/subagent-registry.js", () => ({
190190markSubagentRunTerminated: () => 0,
191191}));
192192193+// #85714: keep the real private-final decision but spy the WARN emitter so we
194+// can assert it fires only through the substantive text suppression branch.
195+const warnPrivateFinalSpy = vi.hoisted(() => vi.fn());
196+vi.mock("./private-message-tool-final.js", async (importOriginal) => {
197+const actual = await importOriginal<typeof import("./private-message-tool-final.js")>();
198+return { ...actual, warnPrivateMessageToolFinal: warnPrivateFinalSpy };
199+});
200+193201import { runReplyAgent } from "./agent-runner.js";
194202195203type RunWithModelFallbackParams = {
@@ -244,6 +252,7 @@ beforeEach(() => {
244252embeddedRunTesting.resetActiveEmbeddedRuns();
245253replyRunRegistryTesting.resetReplyRunRegistry();
246254runEmbeddedAgentMock.mockClear();
255+warnPrivateFinalSpy.mockClear();
247256runCliAgentMock.mockClear();
248257runWithModelFallbackMock.mockClear();
249258runtimeErrorMock.mockClear();
@@ -2984,3 +2993,129 @@ describe("runReplyAgent mid-turn rate-limit fallback", () => {
29842993expect(payload?.text).toBeUndefined();
29852994});
29862995});
2996+2997+describe("runReplyAgent private message_tool_only final warning (#85714)", () => {
2998+async function runPrivateFinalCase(params: {
2999+messagingToolSentTargets?: unknown[];
3000+finalAssistantText?: string;
3001+payloadText?: string;
3002+successfulCronAdds?: number;
3003+}) {
3004+const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-stranded-"));
3005+const storePath = path.join(tmp, "sessions.json");
3006+const sessionKey = "stranded";
3007+const sessionEntry = { sessionId: "session", updatedAt: Date.now(), totalTokens: 1_000 };
3008+await fs.writeFile(storePath, JSON.stringify({ [sessionKey]: sessionEntry }, null, 2), "utf-8");
3009+3010+const finalAssistantText =
3011+params.finalAssistantText ??
3012+"Here is the answer the user asked for. It includes enough detail to read like a user-facing response rather than a short private note. This should have been sent with the message tool if the channel expected a visible reply.";
3013+runEmbeddedAgentMock.mockResolvedValue({
3014+// payloadText can differ from the assistant text to simulate metadata-only
3015+// payloads (verbose notices, usage line) that must NOT trigger the warn —
3016+// detection keys off the assistant final text, not the payload bundle.
3017+payloads: [{ text: params.payloadText ?? finalAssistantText }],
3018+meta: { agentMeta: {}, finalAssistantVisibleText: finalAssistantText },
3019+ ...(params.messagingToolSentTargets
3020+ ? { messagingToolSentTargets: params.messagingToolSentTargets }
3021+ : {}),
3022+ ...(params.successfulCronAdds === undefined
3023+ ? {}
3024+ : { successfulCronAdds: params.successfulCronAdds }),
3025+});
3026+3027+const sessionCtx = {
3028+Provider: "whatsapp",
3029+OriginatingTo: "+15550001111",
3030+AccountId: "primary",
3031+MessageSid: "msg",
3032+ChatType: "direct",
3033+} as unknown as TemplateContext;
3034+const followupRun = {
3035+prompt: "hello",
3036+summaryLine: "hello",
3037+enqueuedAt: Date.now(),
3038+run: {
3039+agentId: "main",
3040+agentDir: "/tmp/agent",
3041+sessionId: "session",
3042+ sessionKey,
3043+messageProvider: "whatsapp",
3044+sessionFile: "/tmp/session.jsonl",
3045+workspaceDir: tmp,
3046+// Direct chat + visibleReplies=message_tool resolves to message_tool_only,
3047+// so the final text is kept private (no automatic delivery).
3048+config: { messages: { visibleReplies: "message_tool" } },
3049+skillsSnapshot: {},
3050+provider: "anthropic",
3051+model: "claude",
3052+thinkLevel: "low",
3053+reasoningLevel: "on",
3054+verboseLevel: "off",
3055+elevatedLevel: "off",
3056+bashElevated: { enabled: false, allowed: false, defaultLevel: "off" },
3057+timeoutMs: 1_000,
3058+blockReplyBreak: "message_end",
3059+},
3060+} as unknown as FollowupRun;
3061+3062+await runReplyAgent({
3063+commandBody: "hello",
3064+ followupRun,
3065+queueKey: sessionKey,
3066+resolvedQueue: { mode: "interrupt" } as unknown as QueueSettings,
3067+shouldSteer: false,
3068+shouldFollowup: false,
3069+isActive: false,
3070+isStreaming: false,
3071+typing: createMockTypingController(),
3072+ sessionCtx,
3073+ sessionEntry,
3074+sessionStore: { [sessionKey]: sessionEntry },
3075+ sessionKey,
3076+ storePath,
3077+defaultModel: "anthropic/claude-opus-4-6",
3078+agentCfgContextTokens: 200_000,
3079+resolvedVerboseLevel: "off",
3080+isNewSession: false,
3081+blockStreamingEnabled: false,
3082+resolvedBlockStreamingBreak: "message_end",
3083+shouldInjectGroupIntro: false,
3084+typingMode: "instant",
3085+});
3086+}
3087+3088+it("warns when a substantive private final reply never used the message tool", async () => {
3089+await runPrivateFinalCase({});
3090+expect(warnPrivateFinalSpy).toHaveBeenCalledTimes(1);
3091+expect(warnPrivateFinalSpy.mock.calls[0]?.[0]).toMatchObject({ sessionKey: "stranded" });
3092+});
3093+3094+it("does not warn for a short private final reply", async () => {
3095+await runPrivateFinalCase({ finalAssistantText: "Nothing to send here." });
3096+expect(warnPrivateFinalSpy).not.toHaveBeenCalled();
3097+});
3098+3099+it("does not warn when the message tool delivered this turn", async () => {
3100+await runPrivateFinalCase({
3101+messagingToolSentTargets: [{ tool: "message", provider: "whatsapp", to: "+15550001111" }],
3102+});
3103+expect(warnPrivateFinalSpy).not.toHaveBeenCalled();
3104+});
3105+3106+it("still warns when only an unrelated cron side effect succeeded", async () => {
3107+await runPrivateFinalCase({ successfulCronAdds: 1 });
3108+expect(warnPrivateFinalSpy).toHaveBeenCalledTimes(1);
3109+});
3110+3111+it("does not warn on an intentional NO_REPLY turn even when metadata payloads remain", async () => {
3112+// Assistant went silent (NO_REPLY), but a verbose/usage metadata payload
3113+// survives in finalPayloads. The warn must key off the assistant text, not
3114+// the payload bundle, so no private-final warning should fire.
3115+await runPrivateFinalCase({
3116+finalAssistantText: "no_reply",
3117+payloadText: "Auto-compaction complete (count 1).",
3118+});
3119+expect(warnPrivateFinalSpy).not.toHaveBeenCalled();
3120+});
3121+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。