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 097c3c6698ea..26cb290302e5 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.test.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.test.ts @@ -1676,15 +1676,18 @@ describe("Codex app-server dynamic tool build", () => { 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, + // Mirror production createOpenClawCodingTools: attempt-fresh tool instances + // per build, never a shared object reused across delivery modes. + setOpenClawCodingToolsFactoryForTests(() => [ + { + ...createRuntimeDynamicTool("message"), + parameters: { + type: "object", + properties: { message: { type: "string" } }, + additionalProperties: false, + }, }, - }; - setOpenClawCodingToolsFactoryForTests(() => [messageTool]); + ]); params.sourceReplyDeliveryMode = "message_tool_only"; const sourceReplyTools = await buildDynamicToolsForTest(params, workspaceDir); diff --git a/extensions/codex/src/app-server/dynamic-tool-build.ts b/extensions/codex/src/app-server/dynamic-tool-build.ts index cc621688cbe2..7aa880a5f389 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.ts @@ -6,7 +6,6 @@ import { buildAgentHookContextChannelFields, buildEmbeddedAttemptToolRunContext, - cloneAgentRuntimeToolWithParameters, embeddedAgentLog, filterProviderNormalizableTools, isHostScopedAgentToolActive, @@ -947,15 +946,17 @@ function addCodexMessageToolOnlyFinalControl( if (sourceReplyDeliveryMode !== "message_tool_only") { return tools; } - return tools.map((tool) => { + // allTools is attempt-fresh from createOpenClawCodingTools inside + // buildDynamicTools — never a shared/cached instance across attempts or + // delivery modes. Project the Codex-only `final` property in place so + // WeakMap ownership metadata stays attached without a public SDK clone helper. + for (const tool of tools) { if (normalizeCodexDynamicToolName(tool.name) !== "message") { - return tool; + continue; } - return cloneAgentRuntimeToolWithParameters( - tool, - addCodexMessageToolOnlyFinalParameter(tool.parameters), - ); - }); + tool.parameters = addCodexMessageToolOnlyFinalParameter(tool.parameters); + } + return tools; } function addCodexMessageToolOnlyFinalParameter(parameters: OpenClawDynamicTool["parameters"]) { diff --git a/extensions/codex/src/app-server/dynamic-tools.test.ts b/extensions/codex/src/app-server/dynamic-tools.test.ts index c3d759373553..6bea5e107432 100644 --- a/extensions/codex/src/app-server/dynamic-tools.test.ts +++ b/extensions/codex/src/app-server/dynamic-tools.test.ts @@ -1318,7 +1318,28 @@ describe("createCodexDynamicToolBridge", () => { ]); }); - it("requires final=true before a delivered message-tool-only source reply terminates", async () => { + it("marks delivered message-tool-only source replies as terminal when final is omitted", async () => { + const bridge = createBridgeWithToolResult( + "message", + textToolResult("Sent.", { messageId: "imessage-6264" }), + { sourceReplyDeliveryMode: "message_tool_only" }, + ); + + const result = await handleMessageToolCall(bridge, { + action: "send", + message: "visible reply", + }); + + 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("requires explicit final=false to keep a delivered message-tool-only source reply non-terminal", async () => { const bridge = createBridgeWithToolResult( "message", textToolResult("Sent.", { messageId: "imessage-6264" }), @@ -1383,7 +1404,6 @@ describe("createCodexDynamicToolBridge", () => { const result = await handleMessageToolCall(bridge, { action: "send", message: "visible reply", - final: true, }); expect(result).toEqual(expectInputText("Sent.")); @@ -1435,7 +1455,6 @@ describe("createCodexDynamicToolBridge", () => { messageId: "853", message: "visible reply", buttons: [], - final: true, }); expect(result).toEqual(expectInputText("Sent.")); @@ -1482,7 +1501,6 @@ describe("createCodexDynamicToolBridge", () => { target: "+1 (206) 910-6512", messageId: "853", message: "visible reply", - final: true, }); expect(result).toEqual(expectInputText("Sent.")); @@ -1522,7 +1540,6 @@ describe("createCodexDynamicToolBridge", () => { messageId: "857", message: "visible reply", buttons: [], - final: true, }); expect(result).toEqual(expectInputText("Sent.")); @@ -1559,7 +1576,6 @@ describe("createCodexDynamicToolBridge", () => { messageId: "861", message: "visible reply", buttons: [], - final: true, }); expect(result).toEqual(expectInputText(receiptText)); @@ -1640,7 +1656,6 @@ describe("createCodexDynamicToolBridge", () => { messageId: "863", message: "visible reply", buttons: [], - final: true, }); expect(result).toEqual(expectInputText("Sent.")); @@ -1662,7 +1677,6 @@ describe("createCodexDynamicToolBridge", () => { messageId: "865", message: "visible reply", buttons: [], - final: true, }); expect(result).toEqual(expectInputText("Sent.")); @@ -1756,7 +1770,6 @@ describe("createCodexDynamicToolBridge", () => { const firstResult = await handleMessageToolCall(bridge, { action: "send", message: "visible reply", - final: true, }); const secondResult = await bridge.handleToolCall({ threadId: "thread-1", diff --git a/extensions/codex/src/app-server/dynamic-tools.ts b/extensions/codex/src/app-server/dynamic-tools.ts index 493cc1f8388a..57e9e8d7b107 100644 --- a/extensions/codex/src/app-server/dynamic-tools.ts +++ b/extensions/codex/src/app-server/dynamic-tools.ts @@ -689,16 +689,17 @@ 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"; + // Omitted final on a confirmed source reply must degrade to legacy + // terminate-on-delivery (completed marker), never progress; otherwise + // stranded-reply recovery re-delivers a duplicate of that reply. const sourceReplyFinal = params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" && toolName === "message" && (toolConfirmedSourceReply || deliveredSourceReply || receiptConfirmedSourceReply) - ? explicitFinalSourceReply || (toolConfirmedSourceReply && !hasExplicitFinalControl) + ? hasExplicitFinalControl + ? executedArgs.final === true + : true : undefined; collectToolTelemetry({ toolName, @@ -723,7 +724,7 @@ export function createCodexDynamicToolBridge(params: { )) || isToolResultYield(rawResult) || isToolResultYield(result) || - (explicitFinalSourceReply && (deliveredSourceReply || receiptConfirmedSourceReply)), + ((deliveredSourceReply || receiptConfirmedSourceReply) && executedArgs.final !== false), ); const asyncStarted = isAsyncStartedToolResult(rawResult) || isAsyncStartedToolResult(result); diff --git a/src/agents/runtime-plan/tools.ts b/src/agents/runtime-plan/tools.ts index 117c9e3454c1..f4826a571536 100644 --- a/src/agents/runtime-plan/tools.ts +++ b/src/agents/runtime-plan/tools.ts @@ -74,16 +74,6 @@ 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/plugin-sdk/agent-harness-runtime.ts b/src/plugin-sdk/agent-harness-runtime.ts index 5d82f88b6f97..e14d9bba1b63 100644 --- a/src/plugin-sdk/agent-harness-runtime.ts +++ b/src/plugin-sdk/agent-harness-runtime.ts @@ -220,7 +220,6 @@ export function queueAgentHarnessMessage( } export { disposeRegisteredAgentHarnesses } from "../agents/harness/registry.js"; export { - cloneAgentRuntimeToolWithParameters, logAgentRuntimeToolDiagnostics, normalizeAgentRuntimeTools, } from "../agents/runtime-plan/tools.js";