From d8a84a29cbe49e0d663400a2c71afc462bce7d80 Mon Sep 17 00:00:00 2001 From: Glucksberg <80581902+Glucksberg@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:00:06 -0400 Subject: [PATCH] fix(codex): require explicit final source delivery Distinguish progress from terminal source delivery across Codex telemetry, payload suppression, terminal failure routing, and stranded-reply recovery. Preserve legacy behavior when explicit markers are absent. --- .../src/app-server/dynamic-tool-build.test.ts | 36 +++++++++ .../src/app-server/dynamic-tool-build.ts | 52 ++++++++++++- .../src/app-server/dynamic-tools.test.ts | 61 ++++++++++++++- .../codex/src/app-server/dynamic-tools.ts | 50 +++++++++---- .../src/app-server/thread-lifecycle.test.ts | 19 +++++ .../codex/src/app-server/thread-lifecycle.ts | 2 +- src/agents/embedded-agent-messaging.types.ts | 4 + .../delivery-evidence.test.ts | 38 ++++++++++ .../delivery-evidence.ts | 39 ++++++++++ src/agents/embedded-agent-runner/run.ts | 1 + .../run/payloads.errors.test.ts | 53 +++++++++++++ .../run/payloads.test.ts | 22 ++++++ .../embedded-agent-runner/run/payloads.ts | 27 ++++++- src/agents/runtime-plan/tools.ts | 10 +++ src/agents/tools/message-tool.test.ts | 10 ++- src/agents/tools/message-tool.ts | 3 + .../agent-runner.misc.runreplyagent.test.ts | 34 +++++++++ src/auto-reply/reply/agent-runner.ts | 24 +++--- src/auto-reply/reply/followup-runner.test.ts | 74 +++++++++++++++++++ src/auto-reply/reply/followup-runner.ts | 22 ++++-- src/plugin-sdk/agent-harness-runtime.ts | 1 + .../discord-group-codex-message-tool.md | 14 ++-- .../telegram-direct-codex-message-tool.md | 14 ++-- .../telegram-heartbeat-codex-tool.md | 14 ++-- 24 files changed, 563 insertions(+), 61 deletions(-) diff --git a/extensions/codex/src/app-server/dynamic-tool-build.test.ts b/extensions/codex/src/app-server/dynamic-tool-build.test.ts index d6dc292cf8cb..097c3c6698ea 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.test.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.test.ts @@ -1671,6 +1671,42 @@ describe("Codex app-server dynamic tool build", () => { expect(shouldForceMessageTool(params)).toBe(false); }); + it("exposes the final delivery control only on Codex message-tool-only schemas", async () => { + const workspaceDir = path.join(tempDir, "workspace"); + const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir); + params.disableTools = false; + params.runtimePlan = createCodexRuntimePlanFixture(); + const messageTool = { + ...createRuntimeDynamicTool("message"), + parameters: { + type: "object", + properties: { message: { type: "string" } }, + additionalProperties: false, + }, + }; + setOpenClawCodingToolsFactoryForTests(() => [messageTool]); + + params.sourceReplyDeliveryMode = "message_tool_only"; + const sourceReplyTools = await buildDynamicToolsForTest(params, workspaceDir); + const sourceReplySchema = sourceReplyTools[0]?.parameters as { + properties?: Record; + additionalProperties?: unknown; + }; + + expect(sourceReplySchema.properties).toMatchObject({ + final: { type: "boolean" }, + }); + expect(sourceReplySchema.additionalProperties).toBe(false); + + params.sourceReplyDeliveryMode = "automatic"; + const automaticTools = await buildDynamicToolsForTest(params, workspaceDir); + const automaticSchema = automaticTools[0]?.parameters as { + properties?: Record; + }; + + expect(automaticSchema.properties).not.toHaveProperty("final"); + }); + it("retains forced message policy for the registered schema override", () => { const workspaceDir = path.join(tempDir, "workspace"); const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir); diff --git a/extensions/codex/src/app-server/dynamic-tool-build.ts b/extensions/codex/src/app-server/dynamic-tool-build.ts index 0e171fde4dae..cc621688cbe2 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.ts @@ -6,6 +6,7 @@ import { buildAgentHookContextChannelFields, buildEmbeddedAttemptToolRunContext, + cloneAgentRuntimeToolWithParameters, embeddedAgentLog, filterProviderNormalizableTools, isHostScopedAgentToolActive, @@ -347,9 +348,13 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) { onToolOutcome: params.onToolOutcome, allocateToolOutcomeOrdinal: params.allocateToolOutcomeOrdinal, }); + const codexScopedTools = addCodexMessageToolOnlyFinalControl( + allTools, + params.sourceReplyDeliveryMode, + ); toolBuildStages.mark("create-openclaw-coding-tools"); const preNormalizationDiagnostics: RuntimeToolSchemaDiagnostic[] = []; - const readableAllToolProjection = filterProviderNormalizableTools(allTools); + const readableAllToolProjection = filterProviderNormalizableTools(codexScopedTools); preNormalizationDiagnostics.push(...readableAllToolProjection.diagnostics); const webSearchPlan = resolveCodexWebSearchPlan({ config: params.config, @@ -930,6 +935,51 @@ function hideNodeExecDynamicToolParameters( }; } +/** + * `final` is a Codex-only control for message-tool-only source delivery. Keep + * it on the projected Codex schema so other agent runtimes never receive an + * API contract they do not implement. + */ +function addCodexMessageToolOnlyFinalControl( + tools: OpenClawDynamicTool[], + sourceReplyDeliveryMode: EmbeddedRunAttemptParams["sourceReplyDeliveryMode"], +): OpenClawDynamicTool[] { + if (sourceReplyDeliveryMode !== "message_tool_only") { + return tools; + } + return tools.map((tool) => { + if (normalizeCodexDynamicToolName(tool.name) !== "message") { + return tool; + } + return cloneAgentRuntimeToolWithParameters( + tool, + addCodexMessageToolOnlyFinalParameter(tool.parameters), + ); + }); +} + +function addCodexMessageToolOnlyFinalParameter(parameters: OpenClawDynamicTool["parameters"]) { + if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) { + return parameters; + } + const schema = parameters as Record; + const rawProperties = schema.properties; + if (!rawProperties || typeof rawProperties !== "object" || Array.isArray(rawProperties)) { + return parameters; + } + return { + ...schema, + properties: { + ...rawProperties, + final: { + type: "boolean", + description: + "Set true only when this message is intended to complete the reply to the current source conversation. OpenClaw stops after confirming delivery.", + }, + }, + }; +} + function resolveCodexNativeExecutionPolicyForDynamicTools( input: DynamicToolBuildParams, ): CodexNativeExecutionPolicy { diff --git a/extensions/codex/src/app-server/dynamic-tools.test.ts b/extensions/codex/src/app-server/dynamic-tools.test.ts index 97ff0078a252..c3d759373553 100644 --- a/extensions/codex/src/app-server/dynamic-tools.test.ts +++ b/extensions/codex/src/app-server/dynamic-tools.test.ts @@ -1318,21 +1318,38 @@ describe("createCodexDynamicToolBridge", () => { ]); }); - it("marks delivered message-tool-only source replies as terminal", async () => { + it("requires final=true before a delivered message-tool-only source reply terminates", async () => { const bridge = createBridgeWithToolResult( "message", textToolResult("Sent.", { messageId: "imessage-6264" }), { sourceReplyDeliveryMode: "message_tool_only" }, ); + const progressResult = await handleMessageToolCall(bridge, { + action: "send", + message: "visible reply", + final: false, + }); + + expect(progressResult).toEqual(expectInputText("Sent.")); + expect(progressResult.terminate).toBeUndefined(); + expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true); + expect(bridge.telemetry.messagingToolSentTargets.at(-1)).toMatchObject({ + sourceReplyFinal: false, + }); + const result = await handleMessageToolCall(bridge, { action: "send", message: "visible reply", + final: true, }); expect(result).toEqual(expectInputText("Sent.")); expect(result.terminate).toBe(true); expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true); + expect(bridge.telemetry.messagingToolSentTargets.at(-1)).toMatchObject({ + sourceReplyFinal: true, + }); expect(Object.keys(result)).not.toContain("terminate"); }); @@ -1366,6 +1383,7 @@ describe("createCodexDynamicToolBridge", () => { const result = await handleMessageToolCall(bridge, { action: "send", message: "visible reply", + final: true, }); expect(result).toEqual(expectInputText("Sent.")); @@ -1417,11 +1435,15 @@ describe("createCodexDynamicToolBridge", () => { messageId: "853", message: "visible reply", buttons: [], + final: true, }); expect(result).toEqual(expectInputText("Sent.")); expect(result.terminate).toBe(true); expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true); + expect(bridge.telemetry.messagingToolSentTargets.at(-1)).toMatchObject({ + sourceReplyFinal: true, + }); expect(Object.keys(result)).not.toContain("terminate"); }); @@ -1460,6 +1482,7 @@ describe("createCodexDynamicToolBridge", () => { target: "+1 (206) 910-6512", messageId: "853", message: "visible reply", + final: true, }); expect(result).toEqual(expectInputText("Sent.")); @@ -1499,6 +1522,7 @@ describe("createCodexDynamicToolBridge", () => { messageId: "857", message: "visible reply", buttons: [], + final: true, }); expect(result).toEqual(expectInputText("Sent.")); @@ -1535,6 +1559,7 @@ describe("createCodexDynamicToolBridge", () => { messageId: "861", message: "visible reply", buttons: [], + final: true, }); expect(result).toEqual(expectInputText(receiptText)); @@ -1615,6 +1640,7 @@ describe("createCodexDynamicToolBridge", () => { messageId: "863", message: "visible reply", buttons: [], + final: true, }); expect(result).toEqual(expectInputText("Sent.")); @@ -1636,6 +1662,7 @@ describe("createCodexDynamicToolBridge", () => { messageId: "865", message: "visible reply", buttons: [], + final: true, }); expect(result).toEqual(expectInputText("Sent.")); @@ -1666,9 +1693,39 @@ describe("createCodexDynamicToolBridge", () => { expect(result).toEqual(expectInputText("Sent.")); expect(result.terminate).toBe(true); expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true); + expect(bridge.telemetry.messagingToolSentTargets.at(-1)).toMatchObject({ + sourceReplyFinal: true, + }); expect(Object.keys(result)).not.toContain("terminate"); }); + it("lets explicit progress override legacy message-tool-owned termination", async () => { + const bridge = createBridgeWithToolResult( + "message", + { + ...textToolResult("Sent.", { ok: true }), + terminate: true, + } as AgentToolResult, + { sourceReplyDeliveryMode: "message_tool_only" }, + ); + + const result = await handleMessageToolCall(bridge, { + action: "reply", + channel: "imessage", + target: "+12069106512", + messageId: "868", + message: "Still working.", + buttons: [], + final: false, + }); + + expect(result).toEqual(expectInputText("Sent.")); + expect(result.terminate).toBeUndefined(); + expect(bridge.telemetry.messagingToolSentTargets.at(-1)).toMatchObject({ + sourceReplyFinal: false, + }); + }); + it("does not treat bare send telemetry as delivered message-tool-only source reply evidence", async () => { const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), { sourceReplyDeliveryMode: "message_tool_only", @@ -1699,6 +1756,7 @@ describe("createCodexDynamicToolBridge", () => { const firstResult = await handleMessageToolCall(bridge, { action: "send", message: "visible reply", + final: true, }); const secondResult = await bridge.handleToolCall({ threadId: "thread-1", @@ -1726,6 +1784,7 @@ describe("createCodexDynamicToolBridge", () => { action: "send", target: "channel:other", message: "cross-channel reply", + final: true, }); expect(result).toEqual(expectInputText("Sent.")); diff --git a/extensions/codex/src/app-server/dynamic-tools.ts b/extensions/codex/src/app-server/dynamic-tools.ts index 784769766869..493cc1f8388a 100644 --- a/extensions/codex/src/app-server/dynamic-tools.ts +++ b/extensions/codex/src/app-server/dynamic-tools.ts @@ -629,15 +629,6 @@ export function createCodexDynamicToolBridge(params: { !rawIsError && messagingTarget ? extractMessagingToolSendResult(messagingTarget, telemetryRawResult) : messagingTarget; - collectToolTelemetry({ - toolName, - args: executedArgs, - result, - mediaTrustResult: telemetryRawResult, - telemetry, - isError: resultIsError, - messagingTarget: confirmedMessagingTarget, - }); const terminalType = resultFailureKind === "blocked" ? "blocked" : resultIsError ? "error" : "completed"; const contentItems = convertToolContents(result.content, toolResultMaxChars); @@ -698,17 +689,41 @@ export function createCodexDynamicToolBridge(params: { toolName === "message" && !resultIsError && (rawResult.terminate === true || result.terminate === true); + const explicitFinalSourceReply = + params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" && + toolName === "message" && + executedArgs.final === true; + const hasExplicitFinalControl = typeof executedArgs.final === "boolean"; + const sourceReplyFinal = + params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" && + toolName === "message" && + (toolConfirmedSourceReply || deliveredSourceReply || receiptConfirmedSourceReply) + ? explicitFinalSourceReply || (toolConfirmedSourceReply && !hasExplicitFinalControl) + : undefined; + collectToolTelemetry({ + toolName, + args: executedArgs, + result, + mediaTrustResult: telemetryRawResult, + telemetry, + isError: resultIsError, + messagingTarget: confirmedMessagingTarget, + sourceReplyFinal, + }); if (deliveredSourceReply || receiptConfirmedSourceReply || toolConfirmedSourceReply) { telemetry.didDeliverSourceReplyViaMessageTool = true; } withDynamicToolTermination( response, - rawResult.terminate === true || - result.terminate === true || + ((rawResult.terminate === true || result.terminate === true) && + !( + params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" && + toolName === "message" && + executedArgs.final === false + )) || isToolResultYield(rawResult) || isToolResultYield(result) || - deliveredSourceReply || - receiptConfirmedSourceReply, + (explicitFinalSourceReply && (deliveredSourceReply || receiptConfirmedSourceReply)), ); const asyncStarted = isAsyncStartedToolResult(rawResult) || isAsyncStartedToolResult(result); @@ -1153,6 +1168,7 @@ function collectToolTelemetry(params: { telemetry: CodexDynamicToolBridge["telemetry"]; isError: boolean; messagingTarget?: MessagingToolSend; + sourceReplyFinal?: boolean; }): void { if (params.isError) { return; @@ -1208,7 +1224,12 @@ function collectToolTelemetry(params: { params.telemetry.didSendViaMessagingTool = true; const sourceReplyPayload = extractInternalSourceReplyPayload(params.result?.details); if (sourceReplyPayload) { - params.telemetry.messagingToolSourceReplyPayloads.push(sourceReplyPayload); + params.telemetry.messagingToolSourceReplyPayloads.push({ + ...sourceReplyPayload, + ...(params.sourceReplyFinal !== undefined + ? { sourceReplyFinal: params.sourceReplyFinal } + : {}), + }); return; } const text = readFirstString(params.args, ["text", "message", "body", "content"]); @@ -1227,6 +1248,7 @@ function collectToolTelemetry(params: { }), ...(text ? { text } : {}), ...(mediaUrls.length > 0 ? { mediaUrls } : {}), + ...(params.sourceReplyFinal !== undefined ? { sourceReplyFinal: params.sourceReplyFinal } : {}), }); } diff --git a/extensions/codex/src/app-server/thread-lifecycle.test.ts b/extensions/codex/src/app-server/thread-lifecycle.test.ts index ec621e26b747..e2afec66e75b 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.test.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.test.ts @@ -532,6 +532,25 @@ describe("Codex app-server native code mode config", () => { expect(instructions).not.toContain("Deferred searchable OpenClaw dynamic tools available"); }); + it("instructs Codex to mark only completed message-tool-only source replies final", () => { + const params = createAttemptParams({ provider: "openai" }); + params.sourceReplyDeliveryMode = "message_tool_only"; + + const instructions = buildDeveloperInstructions(params, { + dynamicTools: [ + { + type: "function", + name: "message", + description: "Send a message", + inputSchema: { type: "object" }, + }, + ], + }); + + expect(instructions).toContain("For progress, set `final=false`."); + expect(instructions).toContain("set `final=true`"); + }); + it("keeps durable dynamic tool fingerprints scoped to loading mode", () => { const inputSchema = { type: "object", diff --git a/extensions/codex/src/app-server/thread-lifecycle.ts b/extensions/codex/src/app-server/thread-lifecycle.ts index 813cf22b5b30..7352c84f0a0c 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.ts @@ -3101,7 +3101,7 @@ function buildVisibleReplyInstruction( ? flattenCodexDynamicToolFunctions(dynamicTools).some((tool) => tool.name.trim() === "message") : params.disableMessageTool !== true; if (params.sourceReplyDeliveryMode === "message_tool_only" && messageToolAvailable) { - return "Visible source replies are not automatically delivered for this run. Use `message(action=send)` for user-visible source-channel output. Do not repeat that visible content in your final answer."; + return "Visible source replies are not automatically delivered for this run. Use `message(action=send)` for user-visible source-channel output. For progress, set `final=false`. When the message is the completed reply to the current source conversation, set `final=true`; OpenClaw stops after confirming delivery. Do not repeat that visible content in your final answer."; } if (messageToolAvailable) { return "For the current source conversation, reply normally in your final assistant message; OpenClaw will deliver it through the active source conversation. Use `message` only for explicit out-of-band sends, media/file sends, or sends to a different target."; diff --git a/src/agents/embedded-agent-messaging.types.ts b/src/agents/embedded-agent-messaging.types.ts index 2d6200cd8a17..4ad1097234a8 100644 --- a/src/agents/embedded-agent-messaging.types.ts +++ b/src/agents/embedded-agent-messaging.types.ts @@ -14,6 +14,8 @@ export type MessagingToolSend = { text?: string; mediaUrls?: string[]; hasRichContent?: true; + /** Present only when Codex classified this current-source delivery intent. */ + sourceReplyFinal?: boolean; }; export type MessagingToolSourceReplyPayload = Pick< @@ -27,4 +29,6 @@ export type MessagingToolSourceReplyPayload = Pick< | "text" > & { idempotencyKey?: string; + /** Present only when Codex classified this current-source delivery intent. */ + sourceReplyFinal?: boolean; }; diff --git a/src/agents/embedded-agent-runner/delivery-evidence.test.ts b/src/agents/embedded-agent-runner/delivery-evidence.test.ts index 5f70b15cd169..b8cad748fa58 100644 --- a/src/agents/embedded-agent-runner/delivery-evidence.test.ts +++ b/src/agents/embedded-agent-runner/delivery-evidence.test.ts @@ -1,9 +1,47 @@ import { describe, expect, it } from "vitest"; import { collectDeliveredMediaUrls, + hasCompletedSourceReplyDeliveryEvidence, hasVisibleOutboundDeliveryEvidence, + resolveExplicitFinalSourceReplyDeliveryEvidence, } from "./delivery-evidence.js"; +describe("explicit final source-reply delivery evidence", () => { + it("distinguishes progress from a delivered final reply", () => { + expect( + resolveExplicitFinalSourceReplyDeliveryEvidence({ + messagingToolSentTargets: [{ sourceReplyFinal: false }], + }), + ).toBe(false); + expect( + resolveExplicitFinalSourceReplyDeliveryEvidence({ + messagingToolSentTargets: [{ sourceReplyFinal: false }, { sourceReplyFinal: true }], + }), + ).toBe(true); + expect( + resolveExplicitFinalSourceReplyDeliveryEvidence({ + messagingToolSourceReplyPayloads: [{ sourceReplyFinal: true }], + }), + ).toBe(true); + }); + + it("returns undefined for legacy telemetry without final markers", () => { + expect( + resolveExplicitFinalSourceReplyDeliveryEvidence({ + messagingToolSourceReplyPayloads: [{ text: "legacy reply" }], + }), + ).toBeUndefined(); + }); + + it("preserves legacy completion evidence when no marker is present", () => { + expect( + hasCompletedSourceReplyDeliveryEvidence({ + didDeliverSourceReplyViaMessageTool: true, + }), + ).toBe(true); + }); +}); + describe("visible messaging-tool delivery evidence", () => { it("keeps the coarse flag when detailed delivery metadata is unavailable", () => { expect(hasVisibleOutboundDeliveryEvidence({ didSendViaMessagingTool: true })).toBe(true); diff --git a/src/agents/embedded-agent-runner/delivery-evidence.ts b/src/agents/embedded-agent-runner/delivery-evidence.ts index 4689f3f8bd21..f5f7a476eae2 100644 --- a/src/agents/embedded-agent-runner/delivery-evidence.ts +++ b/src/agents/embedded-agent-runner/delivery-evidence.ts @@ -47,6 +47,45 @@ type SourceReplyDeliveryEvidence = { messagingToolSourceReplyPayloads?: unknown; }; +type ExplicitFinalSourceReplyEvidence = { + messagingToolSentTargets?: unknown; + messagingToolSourceReplyPayloads?: unknown; +}; + +function collectSourceReplyFinalMarkers(value: unknown): boolean[] { + if (!Array.isArray(value)) { + return []; + } + return value.flatMap((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return []; + } + const marker = (entry as { sourceReplyFinal?: unknown }).sourceReplyFinal; + return typeof marker === "boolean" ? [marker] : []; + }); +} + +/** Resolve explicit progress/final evidence, or undefined for legacy runtimes. */ +export function resolveExplicitFinalSourceReplyDeliveryEvidence( + result: ExplicitFinalSourceReplyEvidence, +): boolean | undefined { + const markers = [ + ...collectSourceReplyFinalMarkers(result.messagingToolSentTargets), + ...collectSourceReplyFinalMarkers(result.messagingToolSourceReplyPayloads), + ]; + return markers.length > 0 ? markers.some(Boolean) : undefined; +} + +/** Preserve legacy completion semantics unless the runtime emitted progress/final markers. */ +export function hasCompletedSourceReplyDeliveryEvidence( + result: SourceReplyDeliveryEvidence & ExplicitFinalSourceReplyEvidence, +): boolean { + return ( + resolveExplicitFinalSourceReplyDeliveryEvidence(result) ?? + hasCommittedSourceReplyDeliveryEvidence(result) + ); +} + function hasNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } diff --git a/src/agents/embedded-agent-runner/run.ts b/src/agents/embedded-agent-runner/run.ts index b828a50c140b..efa1f8159d3f 100644 --- a/src/agents/embedded-agent-runner/run.ts +++ b/src/agents/embedded-agent-runner/run.ts @@ -4344,6 +4344,7 @@ async function runEmbeddedAgentInternal( didSendViaMessagingTool: attempt.didSendViaMessagingTool, didDeliverSourceReplyViaMessageTool: attempt.didDeliverSourceReplyViaMessageTool === true, + messagingToolSentTargets: attempt.messagingToolSentTargets, messagingToolSourceReplyPayloads: attempt.messagingToolSourceReplyPayloads, sourceReplyDeliveryMode: params.sourceReplyDeliveryMode, agentId: params.agentId, diff --git a/src/agents/embedded-agent-runner/run/payloads.errors.test.ts b/src/agents/embedded-agent-runner/run/payloads.errors.test.ts index 3a7c3a2dd089..5a98ca520d00 100644 --- a/src/agents/embedded-agent-runner/run/payloads.errors.test.ts +++ b/src/agents/embedded-agent-runner/run/payloads.errors.test.ts @@ -180,6 +180,59 @@ describe("buildEmbeddedRunPayloads", () => { expectNoPayloadTextContaining(payloads, "SECRET_CANARY_69737"); }); + it("surfaces a terminal error after only a message-tool progress update", () => { + const payloads = buildPayloads({ + lastAssistant: makeAssistant({ + stopReason: "error", + errorMessage: "SECRET_PROGRESS_FAILURE", + content: [], + }), + didSendViaMessagingTool: true, + didDeliverSourceReplyViaMessageTool: true, + messagingToolSentTargets: [ + { + tool: "message", + provider: "discord", + to: "channel:C1", + sourceReplyFinal: false, + }, + ], + sourceReplyDeliveryMode: "message_tool_only", + }); + + expectSinglePayloadSummary(payloads, { + text: "LLM request failed.", + isError: true, + }); + expect(getReplyPayloadMetadata(payloads[0] as object)).toMatchObject({ + deliverDespiteSourceReplySuppression: true, + }); + expectNoPayloadTextContaining(payloads, "SECRET_PROGRESS_FAILURE"); + }); + + it("keeps terminal errors suppressed after an explicit final message-tool reply", () => { + const payloads = buildPayloads({ + lastAssistant: makeAssistant({ + stopReason: "error", + errorMessage: "SECRET_POST_FINAL_FAILURE", + content: [], + }), + didSendViaMessagingTool: true, + didDeliverSourceReplyViaMessageTool: true, + messagingToolSentTargets: [ + { + tool: "message", + provider: "discord", + to: "channel:C1", + sourceReplyFinal: true, + }, + ], + sourceReplyDeliveryMode: "message_tool_only", + }); + + expect(payloads).toEqual([]); + }); + it("suppresses structured provider error messages in user-facing reply payloads", () => { const rawError = '{"type":"error","error":{"type":"invalid_request_error","message":"SECRET_CANARY_69737"}}'; diff --git a/src/agents/embedded-agent-runner/run/payloads.test.ts b/src/agents/embedded-agent-runner/run/payloads.test.ts index 501d6b3cb6ce..433e7b7f9aac 100644 --- a/src/agents/embedded-agent-runner/run/payloads.test.ts +++ b/src/agents/embedded-agent-runner/run/payloads.test.ts @@ -414,6 +414,28 @@ describe("buildEmbeddedRunPayloads tool-error warnings", () => { expect(payloads).toEqual([]); }); + it("keeps progress delivery from publishing the private terminal assistant text", () => { + const payloads = buildPayloads({ + assistantTexts: ["ordinary final should stay private"], + didSendViaMessagingTool: true, + didDeliverSourceReplyViaMessageTool: true, + messagingToolSentTargets: [ + { + tool: "message", + provider: "discord", + to: "channel:C1", + sourceReplyFinal: false, + }, + ], + sourceReplyDeliveryMode: "message_tool_only", + sessionKey: "agent:main", + agentId: "main", + runId: "run-1", + }); + + expect(payloads).toEqual([]); + }); + it("preserves rich-only internal message-tool source replies", () => { const presentation = { blocks: [ diff --git a/src/agents/embedded-agent-runner/run/payloads.ts b/src/agents/embedded-agent-runner/run/payloads.ts index c7f394566e29..11ce01f5e419 100644 --- a/src/agents/embedded-agent-runner/run/payloads.ts +++ b/src/agents/embedded-agent-runner/run/payloads.ts @@ -42,7 +42,10 @@ import { isRawApiErrorPayload, normalizeTextForComparison, } from "../../embedded-agent-helpers.js"; -import type { MessagingToolSourceReplyPayload } from "../../embedded-agent-messaging.types.js"; +import type { + MessagingToolSend, + MessagingToolSourceReplyPayload, +} from "../../embedded-agent-messaging.types.js"; import type { ToolResultFormat } from "../../embedded-agent-subscribe.shared-types.js"; import { extractAssistantThinking, @@ -51,6 +54,7 @@ import { } from "../../embedded-agent-utils.js"; import { isExecLikeToolName, type ToolErrorSummary } from "../../tool-error-summary.js"; import { isLikelyMutatingToolName } from "../../tool-mutation.js"; +import { resolveExplicitFinalSourceReplyDeliveryEvidence } from "../delivery-evidence.js"; type ToolMetaEntry = { toolName: string; meta?: string }; type ToolErrorWarningPolicy = { @@ -581,6 +585,7 @@ export function buildEmbeddedRunPayloads(params: { inlineToolResultsAllowed: boolean; didSendViaMessagingTool?: boolean; didDeliverSourceReplyViaMessageTool?: boolean; + messagingToolSentTargets?: MessagingToolSend[]; messagingToolSourceReplyPayloads?: MessagingToolSourceReplyPayload[]; sourceReplyDeliveryMode?: SourceReplyDeliveryMode; agentId?: string; @@ -653,12 +658,21 @@ export function buildEmbeddedRunPayloads(params: { const deliveredSourceReplyViaMessageTool = params.sourceReplyDeliveryMode === "message_tool_only" && params.didDeliverSourceReplyViaMessageTool === true; + const explicitFinalSourceReply = resolveExplicitFinalSourceReplyDeliveryEvidence({ + messagingToolSentTargets: params.messagingToolSentTargets, + messagingToolSourceReplyPayloads: sourceReplyPayloads, + }); + const completedSourceReplyViaMessageTool = + explicitFinalSourceReply ?? (hasSourceReplyPayload || deliveredSourceReplyViaMessageTool); const useMarkdown = params.toolResultFormat === "markdown"; const suppressAssistantArtifacts = params.didSendDeterministicApprovalPrompt === true || (params.sourceReplyDeliveryMode === "message_tool_only" && hasSourceReplyPayload) || deliveredSourceReplyViaMessageTool; + const suppressFailureArtifacts = + params.didSendDeterministicApprovalPrompt === true || + (params.sourceReplyDeliveryMode === "message_tool_only" && completedSourceReplyViaMessageTool); const nonEmptyAssistantTexts = params.assistantTexts .map((text) => sanitizeAssistantVisibleStreamText(text)) .filter((text) => text.trim().length > 0); @@ -675,7 +689,7 @@ export function buildEmbeddedRunPayloads(params: { : undefined; const errorText = assistantForPayload && lastAssistantNeedsErrorSurface - ? suppressAssistantArtifacts + ? suppressFailureArtifacts ? undefined : lastAssistantErrored || rawErrorMessage ? formatUserFacingAssistantErrorText(assistantForPayload, { @@ -843,7 +857,7 @@ export function buildEmbeddedRunPayloads(params: { : [] ).filter((text) => !shouldSuppressRawErrorText(text)); - let hasUserFacingAssistantReply = hasSourceReplyPayload || deliveredSourceReplyViaMessageTool; + let hasUserFacingAssistantReply = completedSourceReplyViaMessageTool; const hasUserFacingErrorReply = replyItems.some((item) => item.isError === true); let hasUserFacingFailureAcknowledgement = false; for (const text of answerTexts) { @@ -932,6 +946,13 @@ export function buildEmbeddedRunPayloads(params: { if (item.isError !== undefined) { payload.isError = item.isError; } + if ( + item.isError === true && + params.sourceReplyDeliveryMode === "message_tool_only" && + explicitFinalSourceReply === false + ) { + markReplyPayloadForSourceSuppressionDelivery(payload); + } if (item.nonTerminalToolErrorWarning) { setReplyPayloadMetadata(payload, { nonTerminalToolErrorWarning: true, diff --git a/src/agents/runtime-plan/tools.ts b/src/agents/runtime-plan/tools.ts index f4826a571536..117c9e3454c1 100644 --- a/src/agents/runtime-plan/tools.ts +++ b/src/agents/runtime-plan/tools.ts @@ -74,6 +74,16 @@ function copyRuntimeToolMetadata(source: AgentTool, target: AgentTool): void { copyToolTerminalPresentation(source as never, target as never); } +/** Clone a runtime tool with a projected schema while preserving WeakMap-backed ownership data. */ +export function cloneAgentRuntimeToolWithParameters( + source: TTool, + parameters: TTool["parameters"], +): TTool { + const target = { ...source, parameters } as TTool; + copyRuntimeToolMetadata(source, target); + return target; +} + // Duplicate names cannot be matched by map lookup alone, so same-index matches // take precedence and unique-name fallback covers cloned arrays. function preserveRuntimeToolMetadata( diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index 04835ea8dbdb..0380d81cb99d 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -435,6 +435,10 @@ async function executeSendWithResult(params: { } describe("message tool gateway timeout", () => { + it("does not advertise the Codex-only final delivery control", () => { + expect(getToolProperties(createMessageTool())).not.toHaveProperty("final"); + }); + it("advertises timeoutMs as a positive integer", () => { const tool = createMessageTool(); expect(getToolProperties(tool).timeoutMs).toMatchObject({ type: "integer", minimum: 1 }); @@ -859,7 +863,7 @@ describe("message tool secret scoping", () => { ); }); - it("reuses the unresolved autogenerated idempotency key for exact retries", async () => { + it("keeps the Codex final control out of delivery and retry idempotency", async () => { mocks.runMessageAction .mockRejectedValueOnce(new Error("gateway timeout")) .mockResolvedValueOnce({ @@ -884,6 +888,7 @@ describe("message tool secret scoping", () => { message: "same", to: "123", timeoutMs: 1, + final: true, }), ).rejects.toThrow("gateway timeout"); const first = firstRunMessageActionInput(); @@ -893,10 +898,13 @@ describe("message tool secret scoping", () => { timeoutMs: 30_000, to: "123", message: "same", + final: false, }); const second = lastRunMessageActionInput(); expect(first?.params?.idempotencyKey).toBe(second?.params?.idempotencyKey); + expect(first?.params).not.toHaveProperty("final"); + expect(second?.params).not.toHaveProperty("final"); }); it("uses delivery params to avoid collisions across distinct sends", async () => { diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 7c7131f5a3f2..a8d0fedb6622 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -1442,6 +1442,9 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { } // Shallow-copy so we don't mutate the original event args (used for logging/dedup). const params = { ...(args as Record) }; + // `final` is a Codex app-server-only source-delivery control. It must + // not be dispatched to a provider or participate in idempotency. + delete params.final; // Sanitize outbound text fields in three layers: // diff --git a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts index 6230b2b57fa9..cce17c75190e 100644 --- a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts @@ -3628,6 +3628,40 @@ describe("runReplyAgent private message_tool_only final warning (#85714)", () => expect(vi.mocked(enqueueFollowupRun)).not.toHaveBeenCalled(); }); + it("still recovers a private final after only a message-tool progress delivery", async () => { + await runPrivateFinalCase({ + didDeliverSourceReplyViaMessageTool: true, + messagingToolSentTargets: [ + { + tool: "message", + provider: "whatsapp", + to: "+15550001111", + sourceReplyFinal: false, + }, + ], + }); + + expect(warnPrivateFinalSpy).toHaveBeenCalledTimes(1); + expect(vi.mocked(enqueueFollowupRun)).toHaveBeenCalledTimes(1); + }); + + it("does not recover again after an explicit final message-tool delivery", async () => { + await runPrivateFinalCase({ + didDeliverSourceReplyViaMessageTool: true, + messagingToolSentTargets: [ + { + tool: "message", + provider: "whatsapp", + to: "+15550001111", + sourceReplyFinal: true, + }, + ], + }); + + expect(warnPrivateFinalSpy).not.toHaveBeenCalled(); + expect(vi.mocked(enqueueFollowupRun)).not.toHaveBeenCalled(); + }); + it("still retries when the message tool sent only to a non-source target", async () => { await runPrivateFinalCase({ messagingToolSentTargets: [{ tool: "message", provider: "whatsapp", to: "+15559998888" }], diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index b8ac09ffbcb0..0d714969c1ae 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -12,9 +12,11 @@ import { resolveContextTokensForModel } from "../../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { isLikelyContextOverflowError } from "../../agents/embedded-agent-helpers/errors.js"; import { + hasCompletedSourceReplyDeliveryEvidence, hasCommittedSourceReplyDeliveryEvidence, hasVisibleCommittedMessagingToolDeliveryEvidence, hasVisibleOutboundDeliveryEvidence, + resolveExplicitFinalSourceReplyDeliveryEvidence, } from "../../agents/embedded-agent-runner/delivery-evidence.js"; import { hasDeliberateSilentTerminalReply } from "../../agents/embedded-agent-runner/result-fallback-classifier.js"; import { @@ -2008,22 +2010,22 @@ export async function runReplyAgent(params: { }); const committedMessagingToolSourceReplyDelivery = hasCommittedSourceReplyDeliveryEvidence(runResult); - // #85714: the stranded-retry diagnostic gates on committed source-reply - // evidence. `committedMessagingToolSourceReplyDelivery` is that exact signal - // after the delivery-evidence refactor extracted it into a shared helper. - const committedSourceReplyDelivery = committedMessagingToolSourceReplyDelivery; + const completedSourceReplyDelivery = hasCompletedSourceReplyDeliveryEvidence(runResult); + const hasExplicitSourceReplyCompletion = + resolveExplicitFinalSourceReplyDeliveryEvidence(runResult) !== undefined; + const visibleOutboundDelivery = hasVisibleOutboundDeliveryEvidence(runResult); const successfulSideEffectDelivery = successfulSourceReplyDelivery || committedMessagingToolSourceReplyDelivery || - hasVisibleOutboundDeliveryEvidence(runResult) || + visibleOutboundDelivery || runResult.didSendDeterministicApprovalPrompt === true; const successfulTerminalDelivery = hasSuccessfulTerminalSourceReplyDelivery({ blockReplyPipeline, directlySentBlockPayloads, }) || - committedMessagingToolSourceReplyDelivery || - hasVisibleOutboundDeliveryEvidence(runResult) || + completedSourceReplyDelivery || + (!hasExplicitSourceReplyCompletion && visibleOutboundDelivery) || runResult.didSendDeterministicApprovalPrompt === true; // Compaction notices are progress, not a terminal reply. Dispatcher-backed // delivery settles after this run returns, so it cannot prove turn completion here. @@ -2057,7 +2059,7 @@ export async function runReplyAgent(params: { if (!sessionKey || !storePath || followupRun.strandedReplyRetry !== true) { return undefined; } - if (sessionCtx.InboundEventKind === "room_event" || committedSourceReplyDelivery) { + if (sessionCtx.InboundEventKind === "room_event" || completedSourceReplyDelivery) { return undefined; } const sourceReplyPolicy = resolveSourceReplyPolicy({ @@ -2076,7 +2078,7 @@ export async function runReplyAgent(params: { } return buildStrandedReplyDeliveryFailurePayload(); }; - if (opts?.sourceReplyDeliveryMode === "message_tool_only" && committedSourceReplyDelivery) { + if (opts?.sourceReplyDeliveryMode === "message_tool_only" && completedSourceReplyDelivery) { await opts.onObservedReplyDelivery?.(); } const currentMessageId = sessionCtx.MessageSidFull ?? sessionCtx.MessageSid; @@ -2664,7 +2666,7 @@ export async function runReplyAgent(params: { shouldWarnAboutPrivateMessageToolFinal({ sourceReplyDeliveryMode: sourceReplyPolicy.sourceReplyDeliveryMode, sendPolicyDenied: sourceReplyPolicy.sendPolicyDenied, - successfulSourceReplyDelivery: committedSourceReplyDelivery, + successfulSourceReplyDelivery: completedSourceReplyDelivery, finalText: assistantFinalText, }); const retryMissingSourceDelivery = @@ -2673,7 +2675,7 @@ export async function runReplyAgent(params: { !isRoomEvent && sourceReplyPolicy.sourceReplyDeliveryMode === "message_tool_only" && !sourceReplyPolicy.sendPolicyDenied && - !committedSourceReplyDelivery; + !completedSourceReplyDelivery; if (isStrandedReply) { warnPrivateMessageToolFinal({ sessionKey, diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index 1a482a76c4f1..22fd3861f2dd 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -5551,6 +5551,41 @@ describe("createFollowupRunner messaging delivery and dedupe", () => { }); }); + it("routes a terminal failure after only a message-tool progress delivery", async () => { + const queued = baseQueuedRun("discord"); + await runMessagingCase({ + agentResult: { + payloads: [], + meta: { error: { kind: "tool_result_mismatch", message: "private detail" } }, + didDeliverSourceReplyViaMessageTool: true, + messagingToolSentTargets: [ + { + tool: "message", + provider: "discord", + to: "channel:C1", + sourceReplyFinal: false, + }, + ], + }, + queued: { + ...queued, + currentInboundEventKind: "user_request", + originatingChannel: "discord", + originatingTo: "channel:C1", + run: { + ...queued.run, + sourceReplyDeliveryMode: "message_tool_only", + }, + }, + }); + + expect(routeReplyMock).toHaveBeenCalledTimes(1); + expect(requireMockCallArg(routeReplyMock, 0).payload).toMatchObject({ + text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT, + isError: true, + }); + }); + it("routes a terminal failure when an empty result exhausts model fallback", async () => { runWithModelFallbackMock.mockImplementationOnce( async (params: { @@ -6198,6 +6233,45 @@ describe("createFollowupRunner messaging delivery and dedupe", () => { expect(onObservedReplyDelivery).toHaveBeenCalledTimes(1); }); + it("routes retry diagnostics when message-tool evidence contains only progress", async () => { + const queued = baseQueuedRun("discord"); + const onObservedReplyDelivery = vi.fn(async () => {}); + const { onBlockReply } = await runMessagingCase({ + agentResult: { + payloads: [], + didDeliverSourceReplyViaMessageTool: true, + messagingToolSentTexts: ["Still working…"], + messagingToolSentTargets: [ + { + tool: "message", + provider: "discord", + to: "channel:C1", + sourceReplyFinal: false, + }, + ], + }, + queued: { + ...queued, + summaryLine: "stranded-reply-retry", + strandedReplyRetry: true, + originatingChannel: "discord", + originatingTo: "channel:C1", + run: { + ...queued.run, + sourceReplyDeliveryMode: "message_tool_only", + }, + } as FollowupRun, + runnerOverrides: { onObservedReplyDelivery }, + }); + + expect(onBlockReply).not.toHaveBeenCalled(); + expect(routeReplyMock).toHaveBeenCalledTimes(1); + expect(routeReplyMock.mock.calls[0]?.[0]?.payload?.text).toBe( + "I generated a reply but could not deliver it to this chat. Please try again.", + ); + expect(onObservedReplyDelivery).not.toHaveBeenCalled(); + }); + it("routes retry diagnostics when message-tool sends to a non-source target", async () => { const queued = baseQueuedRun("discord"); const { onBlockReply } = await runMessagingCase({ diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index ca1d16ec4f29..f3237ea18476 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -13,9 +13,10 @@ import { getCliSessionBinding } from "../../agents/cli-session.js"; import { resolveContextTokensForModel } from "../../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { + hasCompletedSourceReplyDeliveryEvidence, hasCommittedSourceReplyDeliveryEvidence, - hasVisibleAgentPayload, hasVisibleOutboundDeliveryEvidence, + resolveExplicitFinalSourceReplyDeliveryEvidence, } from "../../agents/embedded-agent-runner/delivery-evidence.js"; import { hasDeliberateSilentTerminalReply, @@ -181,12 +182,10 @@ function isStrandedReplyRetryFollowup(queued: FollowupRun): boolean { function hasSuccessfulFollowupSourceReplyDelivery(params: { didDeliverSourceReplyViaMessageTool?: boolean; + messagingToolSentTargets?: EmbeddedAgentRunResult["messagingToolSentTargets"]; messagingToolSourceReplyPayloads?: EmbeddedAgentRunResult["messagingToolSourceReplyPayloads"]; }): boolean { - return ( - params.didDeliverSourceReplyViaMessageTool === true || - hasVisibleAgentPayload({ payloads: params.messagingToolSourceReplyPayloads }) - ); + return hasCompletedSourceReplyDeliveryEvidence(params); } function normalizeAssistantFinalDeliveryText(text: string): string { @@ -1672,6 +1671,7 @@ export function createFollowupRunner(params: { if ( hasSuccessfulFollowupSourceReplyDelivery({ didDeliverSourceReplyViaMessageTool: runResult.didDeliverSourceReplyViaMessageTool, + messagingToolSentTargets: runResult.messagingToolSentTargets, messagingToolSourceReplyPayloads: runResult.messagingToolSourceReplyPayloads, }) ) { @@ -1729,6 +1729,7 @@ export function createFollowupRunner(params: { sendPolicyDenied: sourceReplyPolicy.sendPolicyDenied, successfulSourceReplyDelivery: hasSuccessfulFollowupSourceReplyDelivery({ didDeliverSourceReplyViaMessageTool: runResult.didDeliverSourceReplyViaMessageTool, + messagingToolSentTargets: runResult.messagingToolSentTargets, messagingToolSourceReplyPayloads: runResult.messagingToolSourceReplyPayloads, }), finalText: assistantFinalText, @@ -1802,6 +1803,13 @@ export function createFollowupRunner(params: { hasVisibleOutboundDeliveryEvidence(runResult) || hasCommittedSourceReplyDeliveryEvidence(runResult) || runResult.didSendDeterministicApprovalPrompt === true; + const completedSourceReplyDelivery = hasCompletedSourceReplyDeliveryEvidence(runResult); + const hasExplicitSourceReplyCompletion = + resolveExplicitFinalSourceReplyDeliveryEvidence(runResult) !== undefined; + const hasCompletedTerminalDelivery = + completedSourceReplyDelivery || + (!hasExplicitSourceReplyCompletion && hasVisibleOutboundDeliveryEvidence(runResult)) || + runResult.didSendDeterministicApprovalPrompt === true; const hasDeliveryDestination = Boolean( (isRoutableChannel(queued.originatingChannel) && queued.originatingTo) || opts?.onBlockReply, @@ -1817,9 +1825,7 @@ export function createFollowupRunner(params: { Surface: queued.originatingChannel, }; const fallbackPayload = terminalRunFailed - ? isInteractive && - run.sourceReplyDeliveryMode !== "message_tool_only" && - !hasCommittedDelivery + ? isInteractive && !hasCompletedTerminalDelivery ? buildTerminalAgentRunFailureReplyPayload({ isHeartbeat: opts?.isHeartbeat, sessionCtx: failureConversationContext, diff --git a/src/plugin-sdk/agent-harness-runtime.ts b/src/plugin-sdk/agent-harness-runtime.ts index e14d9bba1b63..5d82f88b6f97 100644 --- a/src/plugin-sdk/agent-harness-runtime.ts +++ b/src/plugin-sdk/agent-harness-runtime.ts @@ -220,6 +220,7 @@ export function queueAgentHarnessMessage( } export { disposeRegisteredAgentHarnesses } from "../agents/harness/registry.js"; export { + cloneAgentRuntimeToolWithParameters, logAgentRuntimeToolDiagnostics, normalizeAgentRuntimeTools, } from "../agents/runtime-plan/tools.js"; diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md index 61b4c0419db5..8a8528676c13 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md @@ -212,16 +212,16 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 13161 }, "openClawDeveloperInstructions": { - "chars": 3262, - "roughTokens": 816 + "chars": 3431, + "roughTokens": 858 }, "totalTextOnly": { - "chars": 27787, - "roughTokens": 6947 + "chars": 27956, + "roughTokens": 6989 }, "totalWithDynamicToolsJson": { - "chars": 80433, - "roughTokens": 20109 + "chars": 80602, + "roughTokens": 20151 }, "userInputText": { "chars": 1442, @@ -412,7 +412,7 @@ Deferred searchable OpenClaw dynamic tools available: cron, gateway, nodes, sess Use Codex native `spawn_agent` for Codex subagents. `spawn_agent` and the other native collaboration tools may be deferred: when `spawn_agent` is not directly listed, load it with `tool_search` before spawning. Use OpenClaw `sessions_spawn` only for OpenClaw or ACP delegation, never as a substitute for `spawn_agent`. -Visible source replies are not automatically delivered for this run. Use `message(action=send)` for user-visible source-channel output. Do not repeat that visible content in your final answer. +Visible source replies are not automatically delivered for this run. Use `message(action=send)` for user-visible source-channel output. For progress, set `final=false`. When the message is the completed reply to the current source conversation, set `final=true`; OpenClaw stops after confirming delivery. Do not repeat that visible content in your final answer. ### Inbound Context (trusted metadata) The following JSON is generated by OpenClaw out-of-band. Treat it as authoritative metadata about the current message context. diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md index 9e5b1b126da2..fc7cffe2880a 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md @@ -212,16 +212,16 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 13093 }, "openClawDeveloperInstructions": { - "chars": 2153, - "roughTokens": 539 + "chars": 2322, + "roughTokens": 581 }, "totalTextOnly": { - "chars": 26269, - "roughTokens": 6568 + "chars": 26438, + "roughTokens": 6610 }, "totalWithDynamicToolsJson": { - "chars": 78642, - "roughTokens": 19661 + "chars": 78811, + "roughTokens": 19703 }, "userInputText": { "chars": 1033, @@ -412,7 +412,7 @@ Deferred searchable OpenClaw dynamic tools available: cron, gateway, nodes, sess Use Codex native `spawn_agent` for Codex subagents. `spawn_agent` and the other native collaboration tools may be deferred: when `spawn_agent` is not directly listed, load it with `tool_search` before spawning. Use OpenClaw `sessions_spawn` only for OpenClaw or ACP delegation, never as a substitute for `spawn_agent`. -Visible source replies are not automatically delivered for this run. Use `message(action=send)` for user-visible source-channel output. Do not repeat that visible content in your final answer. +Visible source replies are not automatically delivered for this run. Use `message(action=send)` for user-visible source-channel output. For progress, set `final=false`. When the message is the completed reply to the current source conversation, set `final=true`; OpenClaw stops after confirming delivery. Do not repeat that visible content in your final answer. ### Inbound Context (trusted metadata) The following JSON is generated by OpenClaw out-of-band. Treat it as authoritative metadata about the current message context. diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md index cc7ae5bb5aa7..c5ee3760a36d 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md @@ -213,16 +213,16 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 13416 }, "openClawDeveloperInstructions": { - "chars": 2172, - "roughTokens": 543 + "chars": 2341, + "roughTokens": 586 }, "totalTextOnly": { - "chars": 26808, - "roughTokens": 6702 + "chars": 26977, + "roughTokens": 6745 }, "totalWithDynamicToolsJson": { - "chars": 80471, - "roughTokens": 20118 + "chars": 80640, + "roughTokens": 20160 }, "userInputText": { "chars": 1271, @@ -413,7 +413,7 @@ Deferred searchable OpenClaw dynamic tools available: cron, gateway, heartbeat_r Use Codex native `spawn_agent` for Codex subagents. `spawn_agent` and the other native collaboration tools may be deferred: when `spawn_agent` is not directly listed, load it with `tool_search` before spawning. Use OpenClaw `sessions_spawn` only for OpenClaw or ACP delegation, never as a substitute for `spawn_agent`. -Visible source replies are not automatically delivered for this run. Use `message(action=send)` for user-visible source-channel output. Do not repeat that visible content in your final answer. +Visible source replies are not automatically delivered for this run. Use `message(action=send)` for user-visible source-channel output. For progress, set `final=false`. When the message is the completed reply to the current source conversation, set `final=true`; OpenClaw stops after confirming delivery. Do not repeat that visible content in your final answer. ### Inbound Context (trusted metadata) The following JSON is generated by OpenClaw out-of-band. Treat it as authoritative metadata about the current message context.