fix(agent): allow progress before final message-tool replies

Treat message(final=false) as progress and final=true or omission as the terminal source reply. Repeated terminal delivery now returns a non-error outcome without another provider send.

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
Co-authored-by: 宇宙熊Yzx <53250620+849261680@users.noreply.github.com>
Co-authored-by: Markus <markuscontasul@gmail.com>
This commit is contained in:
Glucksberg
2026-08-10 08:22:59 -04:00
committed by GitHub
parent 4f046b5dc6
commit cd7b7f639d
28 changed files with 304 additions and 253 deletions
@@ -30,7 +30,6 @@ import {
isSystemAgentOnlyCodexDynamicToolAllowlist,
normalizeCodexDynamicToolName,
} from "./dynamic-tool-profile.js";
import { addCodexMessageToolOnlyFinalControl } from "./message-tool-final-control.js";
import {
resolveCodexNodeExecToolOverrides,
resolveCodexNativeExecutionPolicy,
@@ -359,13 +358,9 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
run: buildOpenClawCodingTools,
})
: buildOpenClawCodingTools();
const codexScopedTools = addCodexMessageToolOnlyFinalControl(
allTools,
params.sourceReplyDeliveryMode,
);
toolBuildStages.mark("create-openclaw-coding-tools");
const preNormalizationDiagnostics: RuntimeToolSchemaDiagnostic[] = [];
const readableAllToolProjection = filterProviderNormalizableTools(codexScopedTools);
const readableAllToolProjection = filterProviderNormalizableTools(allTools);
preNormalizationDiagnostics.push(...readableAllToolProjection.diagnostics);
const webSearchPlan = resolveCodexWebSearchPlan({
config: params.config,
@@ -41,7 +41,6 @@ import {
type JsonValue,
} from "./protocol.js";
import type { CodexRemoteWorkspaceFileReader } from "./remote-workspace-media.js";
import { settleCodexSourceReplyFinality } from "./source-reply-finality.js";
const CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE = "openclaw";
@@ -1638,7 +1637,7 @@ describe("createCodexDynamicToolBridge", () => {
]);
});
it("keeps omitted source-reply finality non-terminal until a successful attempt settles", async () => {
it("treats omitted source-reply finality as terminal", async () => {
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", { messageId: "imessage-6264" }),
@@ -1651,19 +1650,15 @@ describe("createCodexDynamicToolBridge", () => {
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBeUndefined();
expect(result.terminate).toBe(true);
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("settles omitted source-reply finality as progress when the attempt fails", async () => {
it("keeps omitted source-reply finality terminal when the tool requests termination", async () => {
const bridge = createBridgeWithToolResult(
"message",
{
@@ -1677,70 +1672,13 @@ describe("createCodexDynamicToolBridge", () => {
action: "send",
message: "visible reply",
});
expect(result.terminate).toBeUndefined();
expect(settleCodexSourceReplyFinality(bridge.telemetry, false)).toBe(false);
expect(result.terminate).toBe(true);
expect(bridge.telemetry.messagingToolSentTargets.at(-1)).toMatchObject({
sourceReplyFinal: false,
sourceReplyFinal: true,
});
});
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",
@@ -2094,7 +2032,7 @@ describe("createCodexDynamicToolBridge", () => {
expect(Object.keys(result)).not.toContain("terminate");
});
it("defers omitted finality even when the message tool returns legacy termination", async () => {
it("keeps omitted finality terminal when the message tool returns termination", async () => {
const bridge = createBridgeWithToolResult(
"message",
{
@@ -2114,12 +2052,8 @@ describe("createCodexDynamicToolBridge", () => {
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBeUndefined();
expect(result.terminate).toBe(true);
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,
});
@@ -2193,7 +2127,7 @@ describe("createCodexDynamicToolBridge", () => {
arguments: { action: "inspect" },
});
expect(firstResult.terminate).toBeUndefined();
expect(firstResult.terminate).toBe(true);
expect(bridge.telemetry.didSendViaMessagingTool).toBe(true);
expect(secondResult).toEqual(expectInputText("No message sent."));
expect(secondResult.terminate).toBeUndefined();
@@ -79,7 +79,6 @@ import {
prepareCodexRemoteWorkspaceMessageMedia,
type CodexRemoteWorkspaceFileReader,
} from "./remote-workspace-media.js";
import { recordCodexSourceReplyDeliveryIntent } from "./source-reply-finality.js";
import { resolveCodexToolAbortTerminalReason } from "./tool-abort-terminal-reason.js";
type CodexDynamicToolHookContext = NonNullable<
@@ -779,17 +778,12 @@ export function createCodexDynamicToolBridge(params: {
toolName === "message" &&
!resultIsError &&
(rawResult.terminate === true || result.terminate === true);
const hasExplicitFinalControl = typeof executedArgs.final === "boolean";
const confirmedSourceReply =
params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" &&
toolName === "message" &&
(toolConfirmedSourceReply || deliveredSourceReply || receiptConfirmedSourceReply);
const sourceReplyFinal = confirmedSourceReply
? hasExplicitFinalControl
? executedArgs.final === true
: undefined
: undefined;
const sourceReplyRecord = collectToolTelemetry({
const sourceReplyFinal = confirmedSourceReply ? executedArgs.final !== false : undefined;
collectToolTelemetry({
toolName,
args: executedArgs,
result,
@@ -799,26 +793,19 @@ 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;
const continuesSourceReplyProgress = confirmedSourceReply && sourceReplyFinal === false;
withDynamicToolTermination(
response,
((rawResult.terminate === true || result.terminate === true) &&
!defersInferredSourceReplyTermination) ||
!continuesSourceReplyProgress) ||
// 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) ||
(confirmedSourceReply && executedArgs.final === true),
(confirmedSourceReply && sourceReplyFinal === true),
);
const asyncStarted =
isAsyncStartedToolResult(rawResult) || isAsyncStartedToolResult(result);
@@ -1,52 +0,0 @@
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
import { normalizeCodexDynamicToolName } from "./dynamic-tool-profile.js";
type MutableDynamicTool = {
name: string;
parameters?: unknown;
};
/**
* `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.
*/
export function addCodexMessageToolOnlyFinalControl<T extends MutableDynamicTool>(
tools: T[],
sourceReplyDeliveryMode: EmbeddedRunAttemptParams["sourceReplyDeliveryMode"],
): T[] {
if (sourceReplyDeliveryMode !== "message_tool_only") {
return tools;
}
// These tools are attempt-fresh. Mutating preserves their WeakMap ownership
// metadata without exposing a clone helper through the public plugin SDK.
for (const tool of tools) {
if (normalizeCodexDynamicToolName(tool.name) === "message") {
const mutableTool: MutableDynamicTool = tool;
mutableTool.parameters = addCodexMessageToolOnlyFinalParameter(mutableTool.parameters);
}
}
return tools;
}
function addCodexMessageToolOnlyFinalParameter(parameters: unknown): unknown {
if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) {
return parameters;
}
const schema = parameters as Record<string, unknown>;
const rawProperties = schema.properties;
if (!rawProperties || typeof rawProperties !== "object" || Array.isArray(rawProperties)) {
return parameters;
}
return {
...schema,
properties: {
...rawProperties,
final: {
type: "boolean",
description:
"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.",
},
},
};
}
@@ -31,7 +31,6 @@ import {
import type { prepareCodexAttemptTurnRequest } from "./run-attempt-turn-request.js";
import type { CodexAttemptTurnState } from "./run-attempt-turn-state.js";
import { captureCodexSettledTurnFinalizationContext } from "./settled-turn-context.js";
import { settleCodexSourceReplyFinality } from "./source-reply-finality.js";
import { normalizeCodexTrajectoryError, recordCodexTrajectoryCompletion } from "./trajectory.js";
import { codexTranscriptMirrorRuntime } from "./transcript-mirror.js";
import {
@@ -270,10 +269,9 @@ export async function finalizeCodexAttempt(
!effectiveTimedOut &&
(finalPromptError === null || finalPromptError === undefined) &&
(completedTurnStatus === "completed" || recoveredTurnWatchTimeout || locallyCompletedTurn);
// 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);
const completedSourceReply = toolBridge.telemetry.messagingToolSentTargets.some(
(target) => target.sourceReplyFinal === true,
);
if (completedSourceReply) {
// Harness classification only sees assistant/reasoning/plan projections.
// A reply delivered entirely through the source message tool is visible
@@ -1,44 +0,0 @@
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);
}
@@ -84,7 +84,7 @@ function buildVisibleReplyInstruction(
messageToolAvailable: boolean,
): string {
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. 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.";
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`. Set `final=true`, or omit it, for the completed reply to the current source conversation; OpenClaw stops after confirming delivery. 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` for supported non-text actions in the current conversation, such as reacting to its current message. Reserve other `message` actions for explicit out-of-band sends or media/file delivery. Reactions are not delivered automatically.";
+45 -1
View File
@@ -984,6 +984,48 @@ describe("runCliAgent reliability", () => {
expect(supervisorSpawnMock).toHaveBeenCalledTimes(1);
});
it("surfaces a CLI failure after a delivered progress reply", async () => {
supervisorSpawnMock.mockClear();
supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => {
const input = args[0] as Parameters<ReturnType<typeof getProcessSupervisor>["spawn"]>[0];
const captureHandle = markMcpLoopbackToolCallStarted({
captureKey: input.env?.OPENCLAW_MCP_CLI_CAPTURE_KEY ?? "",
toolName: "message",
args: { action: "send", message: "still working", final: false },
});
if (!captureHandle) {
throw new Error("Expected message delivery capture");
}
recordMcpLoopbackToolCallResult({
captureHandle,
toolName: "message",
args: { action: "send", message: "still working", final: false },
result: { status: "sent", messageId: "progress-1" },
outcome: "completed",
});
markMcpLoopbackToolCallFinished(captureHandle);
return makeManagedRun({ exitCode: 1, durationMs: 150, stderr: "failed after progress" });
});
const context = makeClaudePreparedContext({
sessionKey: "agent:main:telegram:direct:chat123",
runId: "run-progress-failure",
});
context.mcpDeliveryCapture = true;
context.params.sourceReplyDeliveryMode = "message_tool_only";
context.params.messageChannel = "telegram";
context.params.currentChannelId = "chat123";
const result = await runPreparedCliAgent(context);
expect(result.messagingToolSentTargets).toEqual([
expect.objectContaining({ sourceReplyFinal: false }),
]);
expect(result.payloads).toEqual([
{ text: "The reply stopped after sending progress. Please try again.", isError: true },
]);
expect(supervisorSpawnMock).toHaveBeenCalledTimes(1);
});
it("clears a soft-resumed binding after confirmed message send followed by failure", async () => {
supervisorSpawnMock.mockClear();
supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => {
@@ -1153,7 +1195,9 @@ describe("runCliAgent reliability", () => {
expect(result.didSendViaMessagingTool).toBe(true);
expect(result.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(result.messagingToolSourceReplyPayloads).toEqual([{ text: "sent before failure" }]);
expect(result.messagingToolSourceReplyPayloads).toEqual([
{ text: "sent before failure", sourceReplyFinal: true },
]);
expect(result.payloads).toEqual([{ text: "sent before failure" }]);
expect(getReplyPayloadMetadata(result.payloads?.[0] as object)).toMatchObject({
deliverDespiteSourceReplySuppression: true,
+11 -1
View File
@@ -66,6 +66,7 @@ import { claudeCliSessionTranscriptHasContent as claudeCliSessionTranscriptHasCo
import { classifyFailoverReason, isFailoverErrorMessage } from "./embedded-agent-helpers.js";
import type { EmbeddedAgentRunResult } from "./embedded-agent-runner.js";
import { waitForDeferredTurnMaintenanceForSession } from "./embedded-agent-runner/context-engine-maintenance.js";
import { resolveExplicitFinalSourceReplyDeliveryEvidence } from "./embedded-agent-runner/delivery-evidence.js";
import { resolveAuthProfileFailureReason } from "./embedded-agent-runner/run/auth-profile-failure-policy.js";
import { buildEmbeddedRunPayloads } from "./embedded-agent-runner/run/payloads.js";
import { FailoverError, isFailoverError, resolveFailoverStatus } from "./failover-error.js";
@@ -942,6 +943,7 @@ export async function runPreparedCliAgent(
CliOutput,
| "didSendViaMessagingTool"
| "didDeliverSourceReplyViaMessageTool"
| "messagingToolSentTargets"
| "messagingToolSourceReplyPayloads"
>,
): ReplyPayload[] => {
@@ -955,6 +957,7 @@ export async function runPreparedCliAgent(
model: context.modelId,
didSendViaMessagingTool: evidence.didSendViaMessagingTool,
didDeliverSourceReplyViaMessageTool: evidence.didDeliverSourceReplyViaMessageTool,
messagingToolSentTargets: evidence.messagingToolSentTargets,
messagingToolSourceReplyPayloads: evidence.messagingToolSourceReplyPayloads,
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
agentId: params.agentId,
@@ -967,6 +970,7 @@ export async function runPreparedCliAgent(
CliOutput,
| "didSendViaMessagingTool"
| "didDeliverSourceReplyViaMessageTool"
| "messagingToolSentTargets"
| "messagingToolSourceReplyPayloads"
>,
) => {
@@ -989,9 +993,15 @@ export async function runPreparedCliAgent(
): EmbeddedAgentRunResult => {
const message = formatErrorMessage(error);
const { payloads } = resolveCliSourceReplyMirror(evidence);
const visiblePayloads =
payloads.length > 0
? payloads
: resolveExplicitFinalSourceReplyDeliveryEvidence(evidence) === false
? [{ text: "The reply stopped after sending progress. Please try again.", isError: true }]
: undefined;
deliveredMessagingSideEffect = true;
return {
...(payloads.length > 0 ? { payloads } : {}),
...(visiblePayloads ? { payloads: visiblePayloads } : {}),
meta: {
durationMs: Date.now() - context.started,
systemPromptReport: context.systemPromptReport,
+19 -10
View File
@@ -11,6 +11,7 @@ import type { CliOutput, CliToolUseStartDelta } from "../cli-output-contracts.js
import {
isDeliveredMessageToolOnlySourceReplyResult,
isDeliveredMessagingToolResult,
resolveMessageToolSourceReplyFinal,
} from "../embedded-agent-message-tool-source-reply.js";
import {
isMessagingTool,
@@ -226,6 +227,18 @@ export function createCliToolTracking(context: PreparedCliRunContext) {
const toolArgs = params.args ?? {};
const isMessagingSend = isMessagingToolSendAction(params.toolName, toolArgs);
const content = isMessagingSend ? extractCliMessagingContent(toolArgs, params.result) : {};
const deliveredCurrentSourceReply =
isMessagingSend &&
isDeliveredMessageToolOnlySourceReplyResult({
sourceReplyDeliveryMode: context.params.sourceReplyDeliveryMode,
toolName: params.toolName,
args: params.args,
result: params.result,
isError: params.isError,
});
const sourceReplyFinal = deliveredCurrentSourceReply
? resolveMessageToolSourceReplyFinal(toolArgs)
: undefined;
if (isMessagingSend) {
appendUniqueCliMessagingEvidence(
messagingToolSentTexts,
@@ -237,15 +250,7 @@ export function createCliToolTracking(context: PreparedCliRunContext) {
messagingToolSentMediaUrlKeys,
content.mediaUrls ?? [],
);
if (
isDeliveredMessageToolOnlySourceReplyResult({
sourceReplyDeliveryMode: context.params.sourceReplyDeliveryMode,
toolName: params.toolName,
args: params.args,
result: params.result,
isError: params.isError,
})
) {
if (deliveredCurrentSourceReply) {
didDeliverSourceReplyViaMessageTool = true;
const payload = extractMessagingToolSourceReplyPayload(params.result);
if (payload) {
@@ -254,7 +259,10 @@ export function createCliToolTracking(context: PreparedCliRunContext) {
}
// Each internal source-reply send is a distinct delivery, even when
// two intentional sends have identical text or media.
messagingToolSourceReplyPayloads.push(payload);
messagingToolSourceReplyPayloads.push({
...payload,
...(sourceReplyFinal !== undefined ? { sourceReplyFinal } : {}),
});
}
}
}
@@ -264,6 +272,7 @@ export function createCliToolTracking(context: PreparedCliRunContext) {
const targetWithContent = {
...extractMessagingToolSendResult(params.target, params.result),
...content,
...(sourceReplyFinal !== undefined ? { sourceReplyFinal } : {}),
};
const evidenceKey = buildMessagingToolSendEvidenceKey(targetWithContent);
if (messagingToolSentTargetKeys.has(evidenceKey)) {
@@ -2440,10 +2440,12 @@ describe("executePreparedCliRun supervisor output capture", () => {
{
text: "implicit reply",
mediaUrl: "https://example.com/implicit.png",
sourceReplyFinal: true,
},
{
text: "implicit reply",
mediaUrl: "https://example.com/implicit.png",
sourceReplyFinal: true,
},
]);
});
@@ -33,6 +33,11 @@ const PARTIAL_DELIVERY_ENVELOPE_KEYS = [...RESULT_ENVELOPE_KEYS, "error", "cause
const SESSIONS_SEND_DELIVERY_STATUSES = new Set(["accepted", "ok"]);
const BARE_OK_DELIVERY_STATUS = "ok";
/** Omission preserves the established one-shot send behavior. */
export function resolveMessageToolSourceReplyFinal(args: unknown): boolean {
return (asOptionalRecord(args) ?? {}).final !== false;
}
function resultConfirmsCurrentSourceRoute(value: unknown): boolean {
return (
(asOptionalRecord(asOptionalRecord(value)?.details) ?? {}).sourceReplyRoute === "current-source"
+2 -2
View File
@@ -14,7 +14,7 @@ export type MessagingToolSend = {
text?: string;
mediaUrls?: string[];
hasRichContent?: true;
/** Present only when Codex classified this current-source delivery intent. */
/** Current-source progress (`false`) or completed reply (`true`). */
sourceReplyFinal?: boolean;
};
@@ -29,6 +29,6 @@ export type MessagingToolSourceReplyPayload = Pick<
| "text"
> & {
idempotencyKey?: string;
/** Present only when Codex classified this current-source delivery intent. */
/** Current-source progress (`false`) or completed reply (`true`). */
sourceReplyFinal?: boolean;
};
@@ -204,12 +204,13 @@ describe("message-tool-only source replies", () => {
).resolves.toEqual({
content: [{ type: "text", text: "rewritten" }],
details: { rewritten: true },
terminate: true,
});
expect(previousAfterToolCall).toHaveBeenCalledTimes(1);
expect(onDeliveredSourceReply).toHaveBeenCalledTimes(1);
});
it("records delivery evidence without rewriting the default result", async () => {
it("terminates after a delivered completed source reply", async () => {
const agent = {} as unknown as Agent;
const onDeliveredSourceReply = vi.fn();
installMessageToolOnlyTerminalHook({
@@ -225,10 +226,27 @@ describe("message-tool-only source replies", () => {
args: { action: "send", message: "visible reply" },
}),
),
).resolves.toBeUndefined();
).resolves.toEqual({ terminate: true });
expect(onDeliveredSourceReply).toHaveBeenCalledTimes(1);
});
it("continues after delivered progress", async () => {
const agent = {} as unknown as Agent;
installMessageToolOnlyTerminalHook({
agent,
sourceReplyDeliveryMode: "message_tool_only",
});
await expect(
agent.afterToolCall?.(
createAfterToolCallContext({
toolName: "message",
args: { action: "send", message: "still working", final: false },
}),
),
).resolves.toBeUndefined();
});
it("leaves existing after-tool-call output alone when the send failed", async () => {
const previousAfterToolCall = vi.fn(async () => ({
content: [{ type: "text" as const, text: "failed" }],
@@ -2,7 +2,10 @@ import type { SourceReplyDeliveryMode } from "../../../auto-reply/get-reply-opti
/**
* Detects message-tool-only sends that delivered a visible source reply.
*/
import { isDeliveredMessageToolOnlySourceReplyResult } from "../../embedded-agent-message-tool-source-reply.js";
import {
isDeliveredMessageToolOnlySourceReplyResult,
resolveMessageToolSourceReplyFinal,
} from "../../embedded-agent-message-tool-source-reply.js";
import type { AfterToolCallContext, AfterToolCallResult, Agent } from "../../runtime/index.js";
function argsRecordForToolCall(context: AfterToolCallContext): Record<string, unknown> {
@@ -55,7 +58,9 @@ export function installMessageToolOnlyTerminalHook(params: {
})
) {
params.onDeliveredSourceReply?.();
return hookResult;
if (resolveMessageToolSourceReplyFinal(argsRecordForToolCall(context))) {
return { ...hookResult, terminate: true };
}
}
return hookResult;
};
@@ -3485,6 +3485,7 @@ describe("messaging tool media URL tracking", () => {
it("commits internal-ui source replies from successful message sends", async () => {
const { ctx } = createTestContext();
ctx.params.sourceReplyDeliveryMode = "message_tool_only";
const startEvt: ToolExecutionStartEvent = {
toolName: "message",
@@ -3519,6 +3520,7 @@ describe("messaging tool media URL tracking", () => {
mediaUrls: ["file:///tmp/reply.png"],
channelData: { source: "tui" },
idempotencyKey: "stable-source-reply",
sourceReplyFinal: true,
},
]);
});
@@ -57,6 +57,7 @@ import {
isDeliveredMessageToolOnlySourceReplyResult,
isDeliveredMessagingToolResult,
readMessageToolSourceReplyText,
resolveMessageToolSourceReplyFinal,
} from "./embedded-agent-message-tool-source-reply.js";
import {
isMessagingTool,
@@ -1644,6 +1645,18 @@ export async function handleToolExecutionEnd(
didDeliverMessagingResult && isMessagingSend
? [...argumentMediaUrls, ...collectMessagingMediaUrlsFromToolResult(result)]
: [];
const deliveredCurrentSourceReply =
didDeliverMessagingResult &&
isDeliveredMessageToolOnlySourceReplyResult({
sourceReplyDeliveryMode: ctx.params.sourceReplyDeliveryMode,
toolName,
args: startArgs,
result,
isError: isToolError,
});
const sourceReplyFinal = deliveredCurrentSourceReply
? resolveMessageToolSourceReplyFinal(startArgs)
: undefined;
ctx.state.pendingMessagingTexts.delete(toolCallId);
ctx.state.pendingMessagingTargets.delete(toolCallId);
ctx.state.pendingMessagingMediaUrls.delete(toolCallId);
@@ -1661,18 +1674,10 @@ export async function handleToolExecutionEnd(
...(messageText ? { text: messageText } : {}),
...(committedMediaUrls.length > 0 ? { mediaUrls: committedMediaUrls.slice() } : {}),
...(hasRichContent ? { hasRichContent: true as const } : {}),
...(sourceReplyFinal !== undefined ? { sourceReplyFinal } : {}),
});
ctx.trimMessagingToolSent();
}
const deliveredCurrentSourceReply =
didDeliverMessagingResult &&
isDeliveredMessageToolOnlySourceReplyResult({
sourceReplyDeliveryMode: ctx.params.sourceReplyDeliveryMode,
toolName,
args: startArgs,
result,
isError: isToolError,
});
if (deliveredCurrentSourceReply) {
ctx.state.messageToolOnlySourceReplyDelivered = true;
const sourceReplyText = readMessageToolSourceReplyText(startArgs);
@@ -1692,7 +1697,10 @@ export async function handleToolExecutionEnd(
}
const sourceReplyPayload = extractMessagingToolSourceReplyPayload(result);
if (sourceReplyPayload) {
ctx.state.messagingToolSourceReplyPayloads.push(sourceReplyPayload);
ctx.state.messagingToolSourceReplyPayloads.push({
...sourceReplyPayload,
...(sourceReplyFinal !== undefined ? { sourceReplyFinal } : {}),
});
ctx.trimMessagingToolSent();
}
}
+1 -1
View File
@@ -601,7 +601,7 @@ function buildMessagingSection(params: {
}) {
const messageToolOnly = params.sourceReplyDeliveryMode === "message_tool_only";
const visibleReplyInstruction = messageToolOnly
? "- Current source visible reply MUST use `message(action=send)`; final text is private. Skip tool = user gets nothing. Brief tool-call progress is visible; no hidden instructions/private data/reasoning."
? "- Current source visible reply MUST use `message(action=send)`; final text is private. Set `final=false` for progress. Set `final=true`, or omit it, for the completed reply. Skip tool = user gets nothing. No hidden instructions/private data/reasoning."
: "- Current-session final text normally routes to source. If turn says final private, visible output uses `message(action=send)`.";
const messageToolTargetInstruction = params.requireExplicitMessageTarget
? "- `send`: `target` + `message`; target required this turn."
+1 -1
View File
@@ -14,7 +14,7 @@ export function appendMessageToolVisibleReplyHint(
const targetGuidance = requireExplicitTarget
? "send needs target."
: "target defaults current source; set only elsewhere.";
return `${description} This turn visible reply: action="send" + message; ${targetGuidance} Final answer private.`;
return `${description} This turn visible reply: action="send" + message; ${targetGuidance} Set final=false for progress. Set final=true, or omit it, for the completed reply. Final answer private.`;
}
export function appendMessageToolReadHint(
+15 -3
View File
@@ -524,7 +524,7 @@ describe("message tool gateway timeout", () => {
});
});
it("does not advertise the Codex-only final delivery control", () => {
it("does not advertise source-reply finality on ordinary message tools", () => {
expect(getToolProperties(createMessageTool())).not.toHaveProperty("final");
expect(getToolProperties(createMessageTool())).not.toHaveProperty("idempotencyKey");
});
@@ -636,7 +636,16 @@ describe("completion source-reply authority", () => {
expect(getActionEnum(properties)).toEqual(["send"]);
expect(Object.keys(properties).toSorted()).toEqual(
["accountId", "action", "channel", "message", "replyTo", "target", "threadId"].toSorted(),
[
"accountId",
"action",
"channel",
"final",
"message",
"replyTo",
"target",
"threadId",
].toSorted(),
);
expectStringSchema(properties.message, {
description: "Text to send to the current source conversation.",
@@ -778,7 +787,7 @@ describe("completion source-reply authority", () => {
expect(mocks.runMessageAction).not.toHaveBeenCalled();
});
it("allows Codex final controls and matched canonical source-thread text sends", async () => {
it("allows shared final controls and matched canonical source-thread text sends", async () => {
mockSendResult({ channel: "discord", to: "channel:source" });
const tool = createRestrictedTool();
@@ -1045,11 +1054,13 @@ describe("message tool secret scoping", () => {
const defaultTool = createMessageTool();
expect(scopedTool.description).toContain('visible reply: action="send" + message');
expect(getToolProperties(scopedTool).final).toMatchObject({ type: "boolean" });
expect(scopedTool.description).toContain("target defaults current source");
expect(scopedTool.description).toContain("Final answer private");
expect(explicitTargetTool.description).toContain("send needs target");
expect(explicitTargetTool.description).not.toContain("target defaults current source");
expect(defaultTool.description).not.toContain('visible reply: action="send" + message');
expect(getToolProperties(defaultTool)).not.toHaveProperty("final");
});
it("forwards source reply delivery mode through createOpenClawTools", () => {
@@ -1059,6 +1070,7 @@ describe("message tool secret scoping", () => {
}).find((candidate) => candidate.name === "message");
expect(tool?.description).toContain('visible reply: action="send" + message');
expect(getToolProperties(tool!).final).toMatchObject({ type: "boolean" });
});
it("passes source reply delivery mode to the outbound runner", async () => {
+19 -2
View File
@@ -9,7 +9,7 @@ import {
normalizeOptionalStringifiedId,
} from "@openclaw/normalization-core/string-coerce";
import { sortUniqueStrings, uniqueValues } from "@openclaw/normalization-core/string-normalization";
import { Type, type TSchema } from "typebox";
import { Type, type TObject, type TSchema } from "typebox";
import {
GATEWAY_CLIENT_IDS,
GATEWAY_CLIENT_MODES,
@@ -1095,6 +1095,22 @@ const SOURCE_REPLY_ONLY_MESSAGE_SCHEMA = Type.Object({
threadId: Type.Optional(Type.String()),
});
const SOURCE_REPLY_ONLY_RUNTIME_ARG_NAMES = new Set(["to", "channelId", "final"]);
const SOURCE_REPLY_FINAL_PROPERTY = Type.Optional(
Type.Boolean({
description:
"Set false for progress. Set true, or omit, for the completed current-source reply.",
}),
);
function addSourceReplyFinalControl<T extends TObject>(
schema: T,
sourceReplyDeliveryMode: SourceReplyDeliveryMode | undefined,
): T | TObject {
if (sourceReplyDeliveryMode !== "message_tool_only") {
return schema;
}
return Type.Object({ ...schema.properties, final: SOURCE_REPLY_FINAL_PROPERTY });
}
function enforceSourceReplyOnlyTextDirectives(args: Record<string, unknown>): void {
if (typeof args.message !== "string" || !args.message.trim()) {
@@ -1574,11 +1590,12 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
const actions = messageToolDiscoveryParams
? resolveMessageToolActionSchemaActions(messageToolDiscoveryParams)
: undefined;
const schema = options?.sourceReplyOnly
const baseSchema = options?.sourceReplyOnly
? SOURCE_REPLY_ONLY_MESSAGE_SCHEMA
: messageToolDiscoveryParams
? buildMessageToolSchema(messageToolDiscoveryParams, actions ?? [])
: MessageToolSchema;
const schema = addSourceReplyFinalControl(baseSchema, sourceReplySinkDeliveryMode);
const description = options?.sourceReplyOnly
? appendMessageToolVisibleReplyHint(
"Send a message to the current source conversation. Supports actions: send.",
@@ -58,7 +58,7 @@ describe("restart recovery terminal delivery receipt", () => {
await seedClaim();
await beginRestartRecoveryTerminalDelivery(scope());
await expect(beginRestartRecoveryTerminalDelivery(scope())).resolves.toBe("blocked");
await expect(beginRestartRecoveryTerminalDelivery(scope())).resolves.toBe("delivery-ambiguous");
});
it.each([undefined, "done" as const])(
@@ -103,7 +103,7 @@ describe("restart recovery terminal delivery receipt", () => {
},
);
await expect(beginRestartRecoveryTerminalDelivery(scope())).resolves.toBe("blocked");
await expect(beginRestartRecoveryTerminalDelivery(scope())).resolves.toBe("already-delivered");
});
it("clears pending only after a proven non-delivery", async () => {
@@ -54,7 +54,7 @@ function loadCurrent(scope: RestartRecoveryTerminalDeliveryScope): SessionEntry
/** Persists ambiguity before a terminal external send is allowed to start. */
export async function beginRestartRecoveryTerminalDelivery(
scope: RestartRecoveryTerminalDeliveryScope,
): Promise<"started" | "blocked" | "stale" | "not-applicable"> {
): Promise<"started" | "already-delivered" | "delivery-ambiguous" | "stale" | "not-applicable"> {
let started = false;
const updated = await updateSessionEntry(
{ sessionKey: scope.sessionKey, storePath: scope.storePath },
@@ -89,7 +89,7 @@ export async function beginRestartRecoveryTerminalDelivery(
current?.sessionId === scope.sessionId &&
hasRestartRecoveryTerminalRun(current, scope.sourceTurnId)
) {
return "blocked";
return "already-delivered";
}
// The gateway already verified a short-lived current-turn capability. Room
// events intentionally persist no running recovery state, so only durable
@@ -101,7 +101,9 @@ export async function beginRestartRecoveryTerminalDelivery(
return "stale";
}
if (current.restartRecoveryDeliveryReceiptState || current.restartRecoveryDeliveryToolCallId) {
return "blocked";
return current.restartRecoveryDeliveryReceiptState === "delivered-terminal"
? "already-delivered"
: "delivery-ambiguous";
}
throw new Error("failed to persist terminal delivery intent");
}
+28 -6
View File
@@ -38,7 +38,9 @@ const mocks = vi.hoisted(() => ({
}),
),
beginRestartRecoveryTerminalDelivery: vi.fn<
() => Promise<"started" | "blocked" | "stale" | "not-applicable">
() => Promise<
"started" | "already-delivered" | "delivery-ambiguous" | "stale" | "not-applicable"
>
>(async () => "started"),
cancelRestartRecoveryTerminalDelivery: vi.fn(async () => "cleared" as const),
completeRestartRecoveryTerminalDelivery: vi.fn(async () => "recorded" as const),
@@ -3084,14 +3086,30 @@ describe("gateway send mirroring", () => {
idempotencyKey: "idem-shared-source-message-action",
});
await runMessageActionRequest(request("progress"), identity(false));
await runMessageActionRequest(request("terminal"), identity(true));
const progress = await runMessageActionRequest(request("progress"), identity(false));
const terminal = await runMessageActionRequest(request("terminal"), identity(true));
mocks.beginRestartRecoveryTerminalDelivery.mockResolvedValueOnce("already-delivered");
const repeatedTerminal = await runMessageActionRequest(
{
...request("repeated terminal"),
idempotencyKey: "idem-repeated-terminal",
},
identity(true),
);
expect(mocks.appendAssistantMessageToSessionTranscript.mock.calls).toHaveLength(2);
expect(appendTranscriptCall(0)?.idempotencyKey).toBe("idem-shared-source-message-action");
expect(appendTranscriptCall(1)?.idempotencyKey).toBe(
"idem-shared-source-message-action:terminal-receipt:channel-user:v1:shared-key",
);
expect(firstRespondCall(progress.respond)[0]).toBe(true);
expect(firstRespondCall(terminal.respond)[0]).toBe(true);
expect(firstRespondCall(repeatedTerminal.respond)[0]).toBe(true);
expect(firstRespondCall(repeatedTerminal.respond)[1]).toMatchObject({
status: "already_delivered",
delivered: false,
});
expect(mocks.dispatchChannelMessageAction).toHaveBeenCalledTimes(2);
});
it("rejects a terminal source send without tool-call correlation before dispatch", async () => {
@@ -3131,8 +3149,8 @@ describe("gateway send mirroring", () => {
expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledOnce();
});
it("blocks a repeated terminal send before provider dispatch", async () => {
mocks.beginRestartRecoveryTerminalDelivery.mockResolvedValueOnce("blocked");
it("returns an already-delivered outcome for a repeated terminal send", async () => {
mocks.beginRestartRecoveryTerminalDelivery.mockResolvedValueOnce("already-delivered");
const { respond } = await runTelegramTerminalAction({
sessionId: "session-duplicate-terminal",
idempotencyKey: "idem-duplicate-terminal",
@@ -3141,7 +3159,11 @@ describe("gateway send mirroring", () => {
message: "duplicate terminal",
});
expect(firstRespondCall(respond)[0]).toBe(false);
expect(firstRespondCall(respond)[0]).toBe(true);
expect(firstRespondCall(respond)[1]).toMatchObject({
status: "already_delivered",
delivered: false,
});
expect(mocks.dispatchChannelMessageAction).not.toHaveBeenCalled();
});
+10 -1
View File
@@ -1009,10 +1009,19 @@ export const sendHandlers: GatewayRequestHandlers = {
? { sourceReplyFinal: trustedContext.sourceReplyFinal }
: {}),
};
const terminalDeliveryReceipt =
const terminalDeliveryStart =
trustedContext.sourceReplyFinal === true
? await beginTerminalSourceReplyDelivery(sourceReplyMirror)
: undefined;
if (terminalDeliveryStart && "outcome" in terminalDeliveryStart) {
return createGatewayInflightSuccess({
context,
dedupeKey,
payload: terminalDeliveryStart.result,
channel,
});
}
const terminalDeliveryReceipt = terminalDeliveryStart;
const gatewayClientScopes = client?.connect?.scopes ?? [];
const handled = await dispatchChannelMessageAction({
channel,
@@ -338,9 +338,13 @@ export async function executeGatewayAction(params: {
sourceReplyFinal: params.input.sourceReplyFinal,
toolCallId: params.input.sourceReplyToolCallId,
};
const terminalDeliveryReceipt = callerOwnsTerminalReceipt
const terminalDeliveryStart = callerOwnsTerminalReceipt
? await beginTerminalSourceReplyDelivery(sourceReplyMirror)
: undefined;
if (terminalDeliveryStart && "outcome" in terminalDeliveryStart) {
return params.result(terminalDeliveryStart.result);
}
const terminalDeliveryReceipt = terminalDeliveryStart;
let hadUnknownDeliveryOutcome = false;
let payload: unknown;
try {
@@ -490,6 +490,48 @@ describe("runMessageAction plugin dispatch", () => {
);
});
it("returns an ambiguous terminal outcome without remote provider I/O", async () => {
const gatewayPlugin = createGatewayActionPlugin({
pluginId: "gatewaychat",
label: "Gateway Chat",
blurb: "Gateway Chat ambiguous source reply test plugin.",
actions: ["send"],
messaging: { targetResolver: { looksLikeId: () => true } },
handleAction: vi.fn(async () => jsonResult({ ok: true, local: true })),
});
setTestPlugin(gatewayPlugin, "gatewaychat");
mocks.beginTerminalSourceReplyDelivery.mockResolvedValue({
outcome: "delivery_ambiguous",
result: {
status: "delivery_ambiguous",
delivered: false,
message: "The completed reply may already have been delivered. Do not retry it.",
},
});
const result = await runMessageAction({
cfg: { channels: { gatewaychat: { enabled: true } } } as OpenClawConfig,
action: "send",
params: { channel: "gatewaychat", target: "user-123", message: "terminal answer" },
sourceReplyFinal: true,
sourceReplyToolCallId: "message-call-1",
gateway: {
terminalSourceReplyReceiptOwner: "caller",
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
mode: GATEWAY_CLIENT_MODES.BACKEND,
},
dryRun: false,
});
expect(extractToolPayload(result)).toMatchObject({
payload: {
status: "delivery_ambiguous",
delivered: false,
},
});
expect(mocks.callGatewayLeastPrivilege).not.toHaveBeenCalled();
});
it("cancels caller receipts after confirmed gateway request rejection", async () => {
const gatewayPlugin = createGatewayActionPlugin({
pluginId: "gatewaychat",
+27 -5
View File
@@ -43,6 +43,28 @@ type SourceReplyTranscriptMirrorParams = {
replyToIsExplicit?: boolean;
};
export type TerminalSourceReplyDeliveryStart =
| TerminalSourceReplyDeliveryReceipt
| {
outcome: "already_delivered" | "delivery_ambiguous";
result: { status: string; delivered: false; message: string };
}
| undefined;
function buildTerminalSourceReplyNoSendResult(outcome: "already_delivered" | "delivery_ambiguous") {
return {
outcome,
result: {
status: outcome,
delivered: false as const,
message:
outcome === "already_delivered"
? "The completed reply was already delivered. Do not retry it."
: "The completed reply may already have been delivered. Do not retry it.",
},
};
}
type MirrorableSourceReplyTranscriptParams = SourceReplyTranscriptMirrorParams & {
sessionKey: string;
};
@@ -220,7 +242,7 @@ function resolveTerminalSourceReplyDeliveryReceipt(
/** Arms the fail-closed state before a terminal source reply can reach a provider. */
export async function beginTerminalSourceReplyDelivery(
params: SourceReplyTranscriptMirrorParams,
): Promise<TerminalSourceReplyDeliveryReceipt | undefined> {
): Promise<TerminalSourceReplyDeliveryStart> {
const receipt = resolveTerminalSourceReplyDeliveryReceipt(params);
if (!receipt) {
return undefined;
@@ -229,11 +251,11 @@ export async function beginTerminalSourceReplyDelivery(
if (result === "not-applicable") {
return undefined;
}
if (result === "blocked") {
throw new Error("terminal source reply already has a durable delivery outcome");
if (result === "already-delivered") {
return buildTerminalSourceReplyNoSendResult("already_delivered");
}
if (result === "stale") {
throw new Error("terminal source reply lost restart recovery ownership");
if (result === "delivery-ambiguous" || result === "stale") {
return buildTerminalSourceReplyNoSendResult("delivery_ambiguous");
}
return receipt;
}