fix(codex): continue turns after progress replies (#108487)

* fix(codex): defer omitted source reply finality

* test(codex): refresh source reply prompt snapshots
This commit is contained in:
Josh Avant
2026-07-15 16:27:28 -07:00
committed by GitHub
parent 4d4b1762fc
commit 8e6f966482
9 changed files with 214 additions and 57 deletions
@@ -31,6 +31,7 @@ import {
type CodexDynamicToolSpec,
type JsonValue,
} from "./protocol.js";
import { settleCodexSourceReplyFinality } from "./source-reply-finality.js";
const CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE = "openclaw";
@@ -1388,7 +1389,7 @@ describe("createCodexDynamicToolBridge", () => {
]);
});
it("marks delivered message-tool-only source replies as terminal when final is omitted", async () => {
it("keeps omitted source-reply finality non-terminal until a successful attempt settles", async () => {
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", { messageId: "imessage-6264" }),
@@ -1401,15 +1402,97 @@ describe("createCodexDynamicToolBridge", () => {
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBe(true);
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(bridge.telemetry.messagingToolSentTargets.at(-1)).not.toHaveProperty("sourceReplyFinal");
expect(settleCodexSourceReplyFinality(bridge.telemetry, true)).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 () => {
it("settles omitted source-reply finality as progress when the attempt fails", async () => {
const bridge = createBridgeWithToolResult(
"message",
{
...textToolResult("Sent.", { messageId: "imessage-6264" }),
terminate: true,
},
{ sourceReplyDeliveryMode: "message_tool_only" },
);
const result = await handleMessageToolCall(bridge, {
action: "send",
message: "visible reply",
});
expect(result.terminate).toBeUndefined();
expect(settleCodexSourceReplyFinality(bridge.telemetry, false)).toBe(false);
expect(bridge.telemetry.messagingToolSentTargets.at(-1)).toMatchObject({
sourceReplyFinal: false,
});
});
it("settles only the latest omitted source reply as final after success", async () => {
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", { messageId: "imessage-6264" }),
{ sourceReplyDeliveryMode: "message_tool_only" },
);
await handleMessageToolCall(bridge, { action: "send", message: "first update" });
await handleMessageToolCall(bridge, { action: "send", message: "second update" });
settleCodexSourceReplyFinality(bridge.telemetry, true);
expect(
bridge.telemetry.messagingToolSentTargets.map((target) => target.sourceReplyFinal),
).toEqual([false, true]);
});
it("does not promote an omitted reply past a later explicit progress reply", async () => {
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", { messageId: "imessage-6264" }),
{ sourceReplyDeliveryMode: "message_tool_only" },
);
await handleMessageToolCall(bridge, { action: "send", message: "first update" });
await handleMessageToolCall(bridge, {
action: "send",
message: "still working",
final: false,
});
settleCodexSourceReplyFinality(bridge.telemetry, true);
expect(
bridge.telemetry.messagingToolSentTargets.map((target) => target.sourceReplyFinal),
).toEqual([false, false]);
});
it("keeps a later explicit final reply authoritative over an omitted reply", async () => {
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", { messageId: "imessage-6264" }),
{ sourceReplyDeliveryMode: "message_tool_only" },
);
await handleMessageToolCall(bridge, { action: "send", message: "first update" });
await handleMessageToolCall(bridge, {
action: "send",
message: "finished",
final: true,
});
settleCodexSourceReplyFinality(bridge.telemetry, true);
expect(
bridge.telemetry.messagingToolSentTargets.map((target) => target.sourceReplyFinal),
).toEqual([false, true]);
});
it("honors explicit finality for delivered message-tool-only source replies", async () => {
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", { messageId: "imessage-6264" }),
@@ -1474,6 +1557,7 @@ describe("createCodexDynamicToolBridge", () => {
const result = await handleMessageToolCall(bridge, {
action: "send",
message: "visible reply",
final: true,
});
expect(result).toEqual(expectInputText("Sent."));
@@ -1525,6 +1609,7 @@ describe("createCodexDynamicToolBridge", () => {
messageId: "853",
message: "visible reply",
buttons: [],
final: true,
});
expect(result).toEqual(expectInputText("Sent."));
@@ -1571,6 +1656,7 @@ describe("createCodexDynamicToolBridge", () => {
target: "+1 (206) 910-6512",
messageId: "853",
message: "visible reply",
final: true,
});
expect(result).toEqual(expectInputText("Sent."));
@@ -1610,6 +1696,7 @@ describe("createCodexDynamicToolBridge", () => {
messageId: "857",
message: "visible reply",
buttons: [],
final: true,
});
expect(result).toEqual(expectInputText("Sent."));
@@ -1646,6 +1733,7 @@ describe("createCodexDynamicToolBridge", () => {
messageId: "861",
message: "visible reply",
buttons: [],
final: true,
});
expect(result).toEqual(expectInputText(receiptText));
@@ -1726,6 +1814,7 @@ describe("createCodexDynamicToolBridge", () => {
messageId: "863",
message: "visible reply",
buttons: [],
final: true,
});
expect(result).toEqual(expectInputText("Sent."));
@@ -1747,6 +1836,7 @@ describe("createCodexDynamicToolBridge", () => {
messageId: "865",
message: "visible reply",
buttons: [],
final: true,
});
expect(result).toEqual(expectInputText("Sent."));
@@ -1755,7 +1845,7 @@ describe("createCodexDynamicToolBridge", () => {
expect(Object.keys(result)).not.toContain("terminate");
});
it("records message-tool-owned terminal replies as delivered source replies", async () => {
it("defers omitted finality even when the message tool returns legacy termination", async () => {
const bridge = createBridgeWithToolResult(
"message",
{
@@ -1775,8 +1865,12 @@ describe("createCodexDynamicToolBridge", () => {
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBe(true);
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(bridge.telemetry.messagingToolSentTargets.at(-1)).not.toHaveProperty("sourceReplyFinal");
settleCodexSourceReplyFinality(bridge.telemetry, true);
expect(bridge.telemetry.messagingToolSentTargets.at(-1)).toMatchObject({
sourceReplyFinal: true,
});
@@ -1850,7 +1944,7 @@ describe("createCodexDynamicToolBridge", () => {
arguments: { action: "inspect" },
});
expect(firstResult.terminate).toBe(true);
expect(firstResult.terminate).toBeUndefined();
expect(bridge.telemetry.didSendViaMessagingTool).toBe(true);
expect(secondResult).toEqual(expectInputText("No message sent."));
expect(secondResult.terminate).toBeUndefined();
@@ -61,6 +61,7 @@ import {
type CodexDynamicToolSpec,
type JsonValue,
} from "./protocol.js";
import { recordCodexSourceReplyDeliveryIntent } from "./source-reply-finality.js";
import { resolveCodexToolAbortTerminalReason } from "./tool-abort-terminal-reason.js";
type CodexDynamicToolHookContext = {
@@ -688,18 +689,16 @@ export function createCodexDynamicToolBridge(params: {
!resultIsError &&
(rawResult.terminate === true || result.terminate === 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 =
const confirmedSourceReply =
params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" &&
toolName === "message" &&
(toolConfirmedSourceReply || deliveredSourceReply || receiptConfirmedSourceReply)
? hasExplicitFinalControl
? executedArgs.final === true
: true
: undefined;
collectToolTelemetry({
(toolConfirmedSourceReply || deliveredSourceReply || receiptConfirmedSourceReply);
const sourceReplyFinal = confirmedSourceReply
? hasExplicitFinalControl
? executedArgs.final === true
: undefined
: undefined;
const sourceReplyRecord = collectToolTelemetry({
toolName,
args: executedArgs,
result,
@@ -709,20 +708,26 @@ export function createCodexDynamicToolBridge(params: {
messagingTarget: confirmedMessagingTarget,
sourceReplyFinal,
});
if (confirmedSourceReply && sourceReplyRecord) {
recordCodexSourceReplyDeliveryIntent(telemetry, {
record: sourceReplyRecord,
final: sourceReplyFinal,
});
}
if (deliveredSourceReply || receiptConfirmedSourceReply || toolConfirmedSourceReply) {
telemetry.didDeliverSourceReplyViaMessageTool = true;
}
const defersInferredSourceReplyTermination =
confirmedSourceReply && executedArgs.final !== true;
withDynamicToolTermination(
response,
((rawResult.terminate === true || result.terminate === true) &&
!(
params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" &&
toolName === "message" &&
executedArgs.final === false
)) ||
!defersInferredSourceReplyTermination) ||
// Yield is an explicit owner-level turn handoff, not termination
// inferred from source-reply delivery, so finality does not mask it.
isToolResultYield(rawResult) ||
isToolResultYield(result) ||
((deliveredSourceReply || receiptConfirmedSourceReply) && executedArgs.final !== false),
(confirmedSourceReply && executedArgs.final === true),
);
const asyncStarted =
isAsyncStartedToolResult(rawResult) || isAsyncStartedToolResult(result);
@@ -1146,9 +1151,9 @@ function collectToolTelemetry(params: {
isError: boolean;
messagingTarget?: MessagingToolSend;
sourceReplyFinal?: boolean;
}): void {
}): MessagingToolSend | MessagingToolSourceReplyPayload | undefined {
if (params.isError) {
return;
return undefined;
}
if (!params.isError && params.toolName === "cron" && isCronAddAction(params.args)) {
params.telemetry.successfulCronAdds = (params.telemetry.successfulCronAdds ?? 0) + 1;
@@ -1180,11 +1185,11 @@ function collectToolTelemetry(params: {
}
}
if (!isMessagingTool(params.toolName)) {
return;
return undefined;
}
const isMessagingSendAction = isMessagingToolSendAction(params.toolName, params.args);
if (!isMessagingSendAction && !params.messagingTarget) {
return;
return undefined;
}
if (
!isMessagingSendAction &&
@@ -1196,18 +1201,19 @@ function collectToolTelemetry(params: {
isError: params.isError,
})
) {
return;
return undefined;
}
params.telemetry.didSendViaMessagingTool = true;
const sourceReplyPayload = extractInternalSourceReplyPayload(params.result?.details);
if (sourceReplyPayload) {
params.telemetry.messagingToolSourceReplyPayloads.push({
const record = {
...sourceReplyPayload,
...(params.sourceReplyFinal !== undefined
? { sourceReplyFinal: params.sourceReplyFinal }
: {}),
});
return;
};
params.telemetry.messagingToolSourceReplyPayloads.push(record);
return record;
}
const text = readFirstString(params.args, ["text", "message", "body", "content"]);
if (text) {
@@ -1215,7 +1221,7 @@ function collectToolTelemetry(params: {
}
const mediaUrls = collectMediaUrls(params.args);
params.telemetry.messagingToolSentMediaUrls.push(...mediaUrls);
params.telemetry.messagingToolSentTargets.push({
const record = {
...(params.messagingTarget ?? {
tool: params.toolName,
provider: readFirstString(params.args, ["provider", "channel"]) ?? params.toolName,
@@ -1226,7 +1232,9 @@ function collectToolTelemetry(params: {
...(text ? { text } : {}),
...(mediaUrls.length > 0 ? { mediaUrls } : {}),
...(params.sourceReplyFinal !== undefined ? { sourceReplyFinal: params.sourceReplyFinal } : {}),
});
};
params.telemetry.messagingToolSentTargets.push(record);
return record;
}
function extractInternalSourceReplyPayload(
details: unknown,
@@ -45,7 +45,7 @@ function addCodexMessageToolOnlyFinalParameter(parameters: unknown): unknown {
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.",
"Set false for progress or true to complete the current source reply. If omitted, OpenClaw continues and resolves the latest omitted source reply when the turn ends.",
},
},
};
@@ -35,6 +35,7 @@ import {
} from "./run-attempt-state.js";
import type { prepareCodexAttemptTurnRequest } from "./run-attempt-turn-request.js";
import type { CodexAttemptTurnState } from "./run-attempt-turn-state.js";
import { settleCodexSourceReplyFinality } from "./source-reply-finality.js";
import { normalizeCodexTrajectoryError, recordCodexTrajectoryCompletion } from "./trajectory.js";
import { codexTranscriptMirrorRuntime } from "./transcript-mirror.js";
import {
@@ -270,14 +271,24 @@ export async function finalizeCodexAttempt(
!state.terminalTurnNotificationQueued &&
!state.timedOut &&
clientClosedPromptErrorForFinal === undefined;
const attemptSucceeded =
const turnSucceeded =
!finalAborted &&
!effectiveTimedOut &&
(finalPromptError === null || finalPromptError === undefined) &&
result.agentHarnessResultClassification === undefined &&
(completedTurnStatus === "completed" ||
recoveredTurnWatchTimeout ||
completedWithoutTerminalNotification);
// buildResult retains the bridge's delivery records. Resolve omitted final
// intent only after the authoritative turn outcome is known, before any
// terminal observer consumes the result.
const completedSourceReply = settleCodexSourceReplyFinality(toolBridge.telemetry, turnSucceeded);
if (completedSourceReply) {
// Harness classification only sees assistant/reasoning/plan projections.
// A reply delivered entirely through the source message tool is visible
// output, so an empty/reasoning-only classification is stale at this point.
result.agentHarnessResultClassification = undefined;
}
const attemptSucceeded = turnSucceeded && result.agentHarnessResultClassification === undefined;
terminalState.sharedAbortAllowedAfterTerminalOutcome = shouldKeepCodexSharedAbortOpen({
trigger: params.trigger,
result,
@@ -0,0 +1,44 @@
import type {
MessagingToolSend,
MessagingToolSourceReplyPayload,
} from "openclaw/plugin-sdk/agent-harness-runtime";
type SourceReplyDeliveryIntent = {
record: MessagingToolSend | MessagingToolSourceReplyPayload;
final: boolean | undefined;
};
const sourceReplyDeliveryIntents = new WeakMap<object, SourceReplyDeliveryIntent[]>();
/** Retain source-reply intent until the owning Codex turn has an authoritative outcome. */
export function recordCodexSourceReplyDeliveryIntent(
owner: object,
intent: SourceReplyDeliveryIntent,
): void {
const intents = sourceReplyDeliveryIntents.get(owner);
if (intents) {
intents.push(intent);
return;
}
sourceReplyDeliveryIntents.set(owner, [intent]);
}
/** Resolve omitted finality without changing explicit progress or final markers. */
export function settleCodexSourceReplyFinality(owner: object, turnSucceeded: boolean): boolean {
const intents = sourceReplyDeliveryIntents.get(owner);
if (!intents) {
return false;
}
const lastIntent = intents.at(-1);
for (const intent of intents) {
if (intent.final !== undefined) {
continue;
}
// An omitted marker is progress until the owning turn succeeds. Only the
// latest omitted reply can complete the conversation; a later explicit
// progress/final marker remains authoritative.
intent.record.sourceReplyFinal = turnSucceeded && intent === lastIntent;
}
sourceReplyDeliveryIntents.delete(owner);
return turnSucceeded && intents.some((intent) => intent.record.sourceReplyFinal === true);
}
@@ -66,7 +66,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. 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.";
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. If `final` is omitted, OpenClaw continues and resolves the latest omitted source reply only when the turn ends successfully. Do not repeat visible message 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.";
@@ -212,16 +212,16 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 13146
},
"openClawDeveloperInstructions": {
"chars": 3431,
"roughTokens": 858
"chars": 3559,
"roughTokens": 890
},
"totalTextOnly": {
"chars": 27956,
"roughTokens": 6989
"chars": 28084,
"roughTokens": 7021
},
"totalWithDynamicToolsJson": {
"chars": 80541,
"roughTokens": 20136
"chars": 80669,
"roughTokens": 20168
},
"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. 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.
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. If `final` is omitted, OpenClaw continues and resolves the latest omitted source reply only when the turn ends successfully. Do not repeat visible message 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.
@@ -212,16 +212,16 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 13078
},
"openClawDeveloperInstructions": {
"chars": 2322,
"roughTokens": 581
"chars": 2450,
"roughTokens": 613
},
"totalTextOnly": {
"chars": 26438,
"roughTokens": 6610
"chars": 26566,
"roughTokens": 6642
},
"totalWithDynamicToolsJson": {
"chars": 78750,
"roughTokens": 19688
"chars": 78878,
"roughTokens": 19720
},
"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. 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.
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. If `final` is omitted, OpenClaw continues and resolves the latest omitted source reply only when the turn ends successfully. Do not repeat visible message 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.
@@ -213,16 +213,16 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 13400
},
"openClawDeveloperInstructions": {
"chars": 2341,
"roughTokens": 586
"chars": 2469,
"roughTokens": 618
},
"totalTextOnly": {
"chars": 26977,
"roughTokens": 6745
"chars": 27105,
"roughTokens": 6777
},
"totalWithDynamicToolsJson": {
"chars": 80579,
"roughTokens": 20145
"chars": 80707,
"roughTokens": 20177
},
"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. 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.
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. If `final` is omitted, OpenClaw continues and resolves the latest omitted source reply only when the turn ends successfully. Do not repeat visible message 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.