mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -30,7 +30,6 @@ import {
|
|||||||
isSystemAgentOnlyCodexDynamicToolAllowlist,
|
isSystemAgentOnlyCodexDynamicToolAllowlist,
|
||||||
normalizeCodexDynamicToolName,
|
normalizeCodexDynamicToolName,
|
||||||
} from "./dynamic-tool-profile.js";
|
} from "./dynamic-tool-profile.js";
|
||||||
import { addCodexMessageToolOnlyFinalControl } from "./message-tool-final-control.js";
|
|
||||||
import {
|
import {
|
||||||
resolveCodexNodeExecToolOverrides,
|
resolveCodexNodeExecToolOverrides,
|
||||||
resolveCodexNativeExecutionPolicy,
|
resolveCodexNativeExecutionPolicy,
|
||||||
@@ -359,13 +358,9 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
|||||||
run: buildOpenClawCodingTools,
|
run: buildOpenClawCodingTools,
|
||||||
})
|
})
|
||||||
: buildOpenClawCodingTools();
|
: buildOpenClawCodingTools();
|
||||||
const codexScopedTools = addCodexMessageToolOnlyFinalControl(
|
|
||||||
allTools,
|
|
||||||
params.sourceReplyDeliveryMode,
|
|
||||||
);
|
|
||||||
toolBuildStages.mark("create-openclaw-coding-tools");
|
toolBuildStages.mark("create-openclaw-coding-tools");
|
||||||
const preNormalizationDiagnostics: RuntimeToolSchemaDiagnostic[] = [];
|
const preNormalizationDiagnostics: RuntimeToolSchemaDiagnostic[] = [];
|
||||||
const readableAllToolProjection = filterProviderNormalizableTools(codexScopedTools);
|
const readableAllToolProjection = filterProviderNormalizableTools(allTools);
|
||||||
preNormalizationDiagnostics.push(...readableAllToolProjection.diagnostics);
|
preNormalizationDiagnostics.push(...readableAllToolProjection.diagnostics);
|
||||||
const webSearchPlan = resolveCodexWebSearchPlan({
|
const webSearchPlan = resolveCodexWebSearchPlan({
|
||||||
config: params.config,
|
config: params.config,
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ import {
|
|||||||
type JsonValue,
|
type JsonValue,
|
||||||
} from "./protocol.js";
|
} from "./protocol.js";
|
||||||
import type { CodexRemoteWorkspaceFileReader } from "./remote-workspace-media.js";
|
import type { CodexRemoteWorkspaceFileReader } from "./remote-workspace-media.js";
|
||||||
import { settleCodexSourceReplyFinality } from "./source-reply-finality.js";
|
|
||||||
|
|
||||||
const CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE = "openclaw";
|
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(
|
const bridge = createBridgeWithToolResult(
|
||||||
"message",
|
"message",
|
||||||
textToolResult("Sent.", { messageId: "imessage-6264" }),
|
textToolResult("Sent.", { messageId: "imessage-6264" }),
|
||||||
@@ -1651,19 +1650,15 @@ describe("createCodexDynamicToolBridge", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual(expectInputText("Sent."));
|
expect(result).toEqual(expectInputText("Sent."));
|
||||||
expect(result.terminate).toBeUndefined();
|
expect(result.terminate).toBe(true);
|
||||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).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({
|
expect(bridge.telemetry.messagingToolSentTargets.at(-1)).toMatchObject({
|
||||||
sourceReplyFinal: true,
|
sourceReplyFinal: true,
|
||||||
});
|
});
|
||||||
expect(Object.keys(result)).not.toContain("terminate");
|
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(
|
const bridge = createBridgeWithToolResult(
|
||||||
"message",
|
"message",
|
||||||
{
|
{
|
||||||
@@ -1677,70 +1672,13 @@ describe("createCodexDynamicToolBridge", () => {
|
|||||||
action: "send",
|
action: "send",
|
||||||
message: "visible reply",
|
message: "visible reply",
|
||||||
});
|
});
|
||||||
expect(result.terminate).toBeUndefined();
|
expect(result.terminate).toBe(true);
|
||||||
expect(settleCodexSourceReplyFinality(bridge.telemetry, false)).toBe(false);
|
|
||||||
|
|
||||||
expect(bridge.telemetry.messagingToolSentTargets.at(-1)).toMatchObject({
|
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 () => {
|
it("honors explicit finality for delivered message-tool-only source replies", async () => {
|
||||||
const bridge = createBridgeWithToolResult(
|
const bridge = createBridgeWithToolResult(
|
||||||
"message",
|
"message",
|
||||||
@@ -2094,7 +2032,7 @@ describe("createCodexDynamicToolBridge", () => {
|
|||||||
expect(Object.keys(result)).not.toContain("terminate");
|
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(
|
const bridge = createBridgeWithToolResult(
|
||||||
"message",
|
"message",
|
||||||
{
|
{
|
||||||
@@ -2114,12 +2052,8 @@ describe("createCodexDynamicToolBridge", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual(expectInputText("Sent."));
|
expect(result).toEqual(expectInputText("Sent."));
|
||||||
expect(result.terminate).toBeUndefined();
|
expect(result.terminate).toBe(true);
|
||||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).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({
|
expect(bridge.telemetry.messagingToolSentTargets.at(-1)).toMatchObject({
|
||||||
sourceReplyFinal: true,
|
sourceReplyFinal: true,
|
||||||
});
|
});
|
||||||
@@ -2193,7 +2127,7 @@ describe("createCodexDynamicToolBridge", () => {
|
|||||||
arguments: { action: "inspect" },
|
arguments: { action: "inspect" },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(firstResult.terminate).toBeUndefined();
|
expect(firstResult.terminate).toBe(true);
|
||||||
expect(bridge.telemetry.didSendViaMessagingTool).toBe(true);
|
expect(bridge.telemetry.didSendViaMessagingTool).toBe(true);
|
||||||
expect(secondResult).toEqual(expectInputText("No message sent."));
|
expect(secondResult).toEqual(expectInputText("No message sent."));
|
||||||
expect(secondResult.terminate).toBeUndefined();
|
expect(secondResult.terminate).toBeUndefined();
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ import {
|
|||||||
prepareCodexRemoteWorkspaceMessageMedia,
|
prepareCodexRemoteWorkspaceMessageMedia,
|
||||||
type CodexRemoteWorkspaceFileReader,
|
type CodexRemoteWorkspaceFileReader,
|
||||||
} from "./remote-workspace-media.js";
|
} from "./remote-workspace-media.js";
|
||||||
import { recordCodexSourceReplyDeliveryIntent } from "./source-reply-finality.js";
|
|
||||||
import { resolveCodexToolAbortTerminalReason } from "./tool-abort-terminal-reason.js";
|
import { resolveCodexToolAbortTerminalReason } from "./tool-abort-terminal-reason.js";
|
||||||
|
|
||||||
type CodexDynamicToolHookContext = NonNullable<
|
type CodexDynamicToolHookContext = NonNullable<
|
||||||
@@ -779,17 +778,12 @@ export function createCodexDynamicToolBridge(params: {
|
|||||||
toolName === "message" &&
|
toolName === "message" &&
|
||||||
!resultIsError &&
|
!resultIsError &&
|
||||||
(rawResult.terminate === true || result.terminate === true);
|
(rawResult.terminate === true || result.terminate === true);
|
||||||
const hasExplicitFinalControl = typeof executedArgs.final === "boolean";
|
|
||||||
const confirmedSourceReply =
|
const confirmedSourceReply =
|
||||||
params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" &&
|
params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" &&
|
||||||
toolName === "message" &&
|
toolName === "message" &&
|
||||||
(toolConfirmedSourceReply || deliveredSourceReply || receiptConfirmedSourceReply);
|
(toolConfirmedSourceReply || deliveredSourceReply || receiptConfirmedSourceReply);
|
||||||
const sourceReplyFinal = confirmedSourceReply
|
const sourceReplyFinal = confirmedSourceReply ? executedArgs.final !== false : undefined;
|
||||||
? hasExplicitFinalControl
|
collectToolTelemetry({
|
||||||
? executedArgs.final === true
|
|
||||||
: undefined
|
|
||||||
: undefined;
|
|
||||||
const sourceReplyRecord = collectToolTelemetry({
|
|
||||||
toolName,
|
toolName,
|
||||||
args: executedArgs,
|
args: executedArgs,
|
||||||
result,
|
result,
|
||||||
@@ -799,26 +793,19 @@ export function createCodexDynamicToolBridge(params: {
|
|||||||
messagingTarget: confirmedMessagingTarget,
|
messagingTarget: confirmedMessagingTarget,
|
||||||
sourceReplyFinal,
|
sourceReplyFinal,
|
||||||
});
|
});
|
||||||
if (confirmedSourceReply && sourceReplyRecord) {
|
|
||||||
recordCodexSourceReplyDeliveryIntent(telemetry, {
|
|
||||||
record: sourceReplyRecord,
|
|
||||||
final: sourceReplyFinal,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (deliveredSourceReply || receiptConfirmedSourceReply || toolConfirmedSourceReply) {
|
if (deliveredSourceReply || receiptConfirmedSourceReply || toolConfirmedSourceReply) {
|
||||||
telemetry.didDeliverSourceReplyViaMessageTool = true;
|
telemetry.didDeliverSourceReplyViaMessageTool = true;
|
||||||
}
|
}
|
||||||
const defersInferredSourceReplyTermination =
|
const continuesSourceReplyProgress = confirmedSourceReply && sourceReplyFinal === false;
|
||||||
confirmedSourceReply && executedArgs.final !== true;
|
|
||||||
withDynamicToolTermination(
|
withDynamicToolTermination(
|
||||||
response,
|
response,
|
||||||
((rawResult.terminate === true || result.terminate === true) &&
|
((rawResult.terminate === true || result.terminate === true) &&
|
||||||
!defersInferredSourceReplyTermination) ||
|
!continuesSourceReplyProgress) ||
|
||||||
// Yield is an explicit owner-level turn handoff, not termination
|
// Yield is an explicit owner-level turn handoff, not termination
|
||||||
// inferred from source-reply delivery, so finality does not mask it.
|
// inferred from source-reply delivery, so finality does not mask it.
|
||||||
isToolResultYield(rawResult) ||
|
isToolResultYield(rawResult) ||
|
||||||
isToolResultYield(result) ||
|
isToolResultYield(result) ||
|
||||||
(confirmedSourceReply && executedArgs.final === true),
|
(confirmedSourceReply && sourceReplyFinal === true),
|
||||||
);
|
);
|
||||||
const asyncStarted =
|
const asyncStarted =
|
||||||
isAsyncStartedToolResult(rawResult) || isAsyncStartedToolResult(result);
|
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 { prepareCodexAttemptTurnRequest } from "./run-attempt-turn-request.js";
|
||||||
import type { CodexAttemptTurnState } from "./run-attempt-turn-state.js";
|
import type { CodexAttemptTurnState } from "./run-attempt-turn-state.js";
|
||||||
import { captureCodexSettledTurnFinalizationContext } from "./settled-turn-context.js";
|
import { captureCodexSettledTurnFinalizationContext } from "./settled-turn-context.js";
|
||||||
import { settleCodexSourceReplyFinality } from "./source-reply-finality.js";
|
|
||||||
import { normalizeCodexTrajectoryError, recordCodexTrajectoryCompletion } from "./trajectory.js";
|
import { normalizeCodexTrajectoryError, recordCodexTrajectoryCompletion } from "./trajectory.js";
|
||||||
import { codexTranscriptMirrorRuntime } from "./transcript-mirror.js";
|
import { codexTranscriptMirrorRuntime } from "./transcript-mirror.js";
|
||||||
import {
|
import {
|
||||||
@@ -270,10 +269,9 @@ export async function finalizeCodexAttempt(
|
|||||||
!effectiveTimedOut &&
|
!effectiveTimedOut &&
|
||||||
(finalPromptError === null || finalPromptError === undefined) &&
|
(finalPromptError === null || finalPromptError === undefined) &&
|
||||||
(completedTurnStatus === "completed" || recoveredTurnWatchTimeout || locallyCompletedTurn);
|
(completedTurnStatus === "completed" || recoveredTurnWatchTimeout || locallyCompletedTurn);
|
||||||
// buildResult retains the bridge's delivery records. Resolve omitted final
|
const completedSourceReply = toolBridge.telemetry.messagingToolSentTargets.some(
|
||||||
// intent only after the authoritative turn outcome is known, before any
|
(target) => target.sourceReplyFinal === true,
|
||||||
// terminal observer consumes the result.
|
);
|
||||||
const completedSourceReply = settleCodexSourceReplyFinality(toolBridge.telemetry, turnSucceeded);
|
|
||||||
if (completedSourceReply) {
|
if (completedSourceReply) {
|
||||||
// Harness classification only sees assistant/reasoning/plan projections.
|
// Harness classification only sees assistant/reasoning/plan projections.
|
||||||
// A reply delivered entirely through the source message tool is visible
|
// 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,
|
messageToolAvailable: boolean,
|
||||||
): string {
|
): string {
|
||||||
if (params.sourceReplyDeliveryMode === "message_tool_only" && messageToolAvailable) {
|
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) {
|
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.";
|
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.";
|
||||||
|
|||||||
@@ -984,6 +984,48 @@ describe("runCliAgent reliability", () => {
|
|||||||
expect(supervisorSpawnMock).toHaveBeenCalledTimes(1);
|
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 () => {
|
it("clears a soft-resumed binding after confirmed message send followed by failure", async () => {
|
||||||
supervisorSpawnMock.mockClear();
|
supervisorSpawnMock.mockClear();
|
||||||
supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => {
|
supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => {
|
||||||
@@ -1153,7 +1195,9 @@ describe("runCliAgent reliability", () => {
|
|||||||
|
|
||||||
expect(result.didSendViaMessagingTool).toBe(true);
|
expect(result.didSendViaMessagingTool).toBe(true);
|
||||||
expect(result.didDeliverSourceReplyViaMessageTool).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(result.payloads).toEqual([{ text: "sent before failure" }]);
|
||||||
expect(getReplyPayloadMetadata(result.payloads?.[0] as object)).toMatchObject({
|
expect(getReplyPayloadMetadata(result.payloads?.[0] as object)).toMatchObject({
|
||||||
deliverDespiteSourceReplySuppression: true,
|
deliverDespiteSourceReplySuppression: true,
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ import { claudeCliSessionTranscriptHasContent as claudeCliSessionTranscriptHasCo
|
|||||||
import { classifyFailoverReason, isFailoverErrorMessage } from "./embedded-agent-helpers.js";
|
import { classifyFailoverReason, isFailoverErrorMessage } from "./embedded-agent-helpers.js";
|
||||||
import type { EmbeddedAgentRunResult } from "./embedded-agent-runner.js";
|
import type { EmbeddedAgentRunResult } from "./embedded-agent-runner.js";
|
||||||
import { waitForDeferredTurnMaintenanceForSession } from "./embedded-agent-runner/context-engine-maintenance.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 { resolveAuthProfileFailureReason } from "./embedded-agent-runner/run/auth-profile-failure-policy.js";
|
||||||
import { buildEmbeddedRunPayloads } from "./embedded-agent-runner/run/payloads.js";
|
import { buildEmbeddedRunPayloads } from "./embedded-agent-runner/run/payloads.js";
|
||||||
import { FailoverError, isFailoverError, resolveFailoverStatus } from "./failover-error.js";
|
import { FailoverError, isFailoverError, resolveFailoverStatus } from "./failover-error.js";
|
||||||
@@ -942,6 +943,7 @@ export async function runPreparedCliAgent(
|
|||||||
CliOutput,
|
CliOutput,
|
||||||
| "didSendViaMessagingTool"
|
| "didSendViaMessagingTool"
|
||||||
| "didDeliverSourceReplyViaMessageTool"
|
| "didDeliverSourceReplyViaMessageTool"
|
||||||
|
| "messagingToolSentTargets"
|
||||||
| "messagingToolSourceReplyPayloads"
|
| "messagingToolSourceReplyPayloads"
|
||||||
>,
|
>,
|
||||||
): ReplyPayload[] => {
|
): ReplyPayload[] => {
|
||||||
@@ -955,6 +957,7 @@ export async function runPreparedCliAgent(
|
|||||||
model: context.modelId,
|
model: context.modelId,
|
||||||
didSendViaMessagingTool: evidence.didSendViaMessagingTool,
|
didSendViaMessagingTool: evidence.didSendViaMessagingTool,
|
||||||
didDeliverSourceReplyViaMessageTool: evidence.didDeliverSourceReplyViaMessageTool,
|
didDeliverSourceReplyViaMessageTool: evidence.didDeliverSourceReplyViaMessageTool,
|
||||||
|
messagingToolSentTargets: evidence.messagingToolSentTargets,
|
||||||
messagingToolSourceReplyPayloads: evidence.messagingToolSourceReplyPayloads,
|
messagingToolSourceReplyPayloads: evidence.messagingToolSourceReplyPayloads,
|
||||||
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
|
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
|
||||||
agentId: params.agentId,
|
agentId: params.agentId,
|
||||||
@@ -967,6 +970,7 @@ export async function runPreparedCliAgent(
|
|||||||
CliOutput,
|
CliOutput,
|
||||||
| "didSendViaMessagingTool"
|
| "didSendViaMessagingTool"
|
||||||
| "didDeliverSourceReplyViaMessageTool"
|
| "didDeliverSourceReplyViaMessageTool"
|
||||||
|
| "messagingToolSentTargets"
|
||||||
| "messagingToolSourceReplyPayloads"
|
| "messagingToolSourceReplyPayloads"
|
||||||
>,
|
>,
|
||||||
) => {
|
) => {
|
||||||
@@ -989,9 +993,15 @@ export async function runPreparedCliAgent(
|
|||||||
): EmbeddedAgentRunResult => {
|
): EmbeddedAgentRunResult => {
|
||||||
const message = formatErrorMessage(error);
|
const message = formatErrorMessage(error);
|
||||||
const { payloads } = resolveCliSourceReplyMirror(evidence);
|
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;
|
deliveredMessagingSideEffect = true;
|
||||||
return {
|
return {
|
||||||
...(payloads.length > 0 ? { payloads } : {}),
|
...(visiblePayloads ? { payloads: visiblePayloads } : {}),
|
||||||
meta: {
|
meta: {
|
||||||
durationMs: Date.now() - context.started,
|
durationMs: Date.now() - context.started,
|
||||||
systemPromptReport: context.systemPromptReport,
|
systemPromptReport: context.systemPromptReport,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { CliOutput, CliToolUseStartDelta } from "../cli-output-contracts.js
|
|||||||
import {
|
import {
|
||||||
isDeliveredMessageToolOnlySourceReplyResult,
|
isDeliveredMessageToolOnlySourceReplyResult,
|
||||||
isDeliveredMessagingToolResult,
|
isDeliveredMessagingToolResult,
|
||||||
|
resolveMessageToolSourceReplyFinal,
|
||||||
} from "../embedded-agent-message-tool-source-reply.js";
|
} from "../embedded-agent-message-tool-source-reply.js";
|
||||||
import {
|
import {
|
||||||
isMessagingTool,
|
isMessagingTool,
|
||||||
@@ -226,6 +227,18 @@ export function createCliToolTracking(context: PreparedCliRunContext) {
|
|||||||
const toolArgs = params.args ?? {};
|
const toolArgs = params.args ?? {};
|
||||||
const isMessagingSend = isMessagingToolSendAction(params.toolName, toolArgs);
|
const isMessagingSend = isMessagingToolSendAction(params.toolName, toolArgs);
|
||||||
const content = isMessagingSend ? extractCliMessagingContent(toolArgs, params.result) : {};
|
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) {
|
if (isMessagingSend) {
|
||||||
appendUniqueCliMessagingEvidence(
|
appendUniqueCliMessagingEvidence(
|
||||||
messagingToolSentTexts,
|
messagingToolSentTexts,
|
||||||
@@ -237,15 +250,7 @@ export function createCliToolTracking(context: PreparedCliRunContext) {
|
|||||||
messagingToolSentMediaUrlKeys,
|
messagingToolSentMediaUrlKeys,
|
||||||
content.mediaUrls ?? [],
|
content.mediaUrls ?? [],
|
||||||
);
|
);
|
||||||
if (
|
if (deliveredCurrentSourceReply) {
|
||||||
isDeliveredMessageToolOnlySourceReplyResult({
|
|
||||||
sourceReplyDeliveryMode: context.params.sourceReplyDeliveryMode,
|
|
||||||
toolName: params.toolName,
|
|
||||||
args: params.args,
|
|
||||||
result: params.result,
|
|
||||||
isError: params.isError,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
didDeliverSourceReplyViaMessageTool = true;
|
didDeliverSourceReplyViaMessageTool = true;
|
||||||
const payload = extractMessagingToolSourceReplyPayload(params.result);
|
const payload = extractMessagingToolSourceReplyPayload(params.result);
|
||||||
if (payload) {
|
if (payload) {
|
||||||
@@ -254,7 +259,10 @@ export function createCliToolTracking(context: PreparedCliRunContext) {
|
|||||||
}
|
}
|
||||||
// Each internal source-reply send is a distinct delivery, even when
|
// Each internal source-reply send is a distinct delivery, even when
|
||||||
// two intentional sends have identical text or media.
|
// 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 = {
|
const targetWithContent = {
|
||||||
...extractMessagingToolSendResult(params.target, params.result),
|
...extractMessagingToolSendResult(params.target, params.result),
|
||||||
...content,
|
...content,
|
||||||
|
...(sourceReplyFinal !== undefined ? { sourceReplyFinal } : {}),
|
||||||
};
|
};
|
||||||
const evidenceKey = buildMessagingToolSendEvidenceKey(targetWithContent);
|
const evidenceKey = buildMessagingToolSendEvidenceKey(targetWithContent);
|
||||||
if (messagingToolSentTargetKeys.has(evidenceKey)) {
|
if (messagingToolSentTargetKeys.has(evidenceKey)) {
|
||||||
|
|||||||
@@ -2440,10 +2440,12 @@ describe("executePreparedCliRun supervisor output capture", () => {
|
|||||||
{
|
{
|
||||||
text: "implicit reply",
|
text: "implicit reply",
|
||||||
mediaUrl: "https://example.com/implicit.png",
|
mediaUrl: "https://example.com/implicit.png",
|
||||||
|
sourceReplyFinal: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "implicit reply",
|
text: "implicit reply",
|
||||||
mediaUrl: "https://example.com/implicit.png",
|
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 SESSIONS_SEND_DELIVERY_STATUSES = new Set(["accepted", "ok"]);
|
||||||
const BARE_OK_DELIVERY_STATUS = "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 {
|
function resultConfirmsCurrentSourceRoute(value: unknown): boolean {
|
||||||
return (
|
return (
|
||||||
(asOptionalRecord(asOptionalRecord(value)?.details) ?? {}).sourceReplyRoute === "current-source"
|
(asOptionalRecord(asOptionalRecord(value)?.details) ?? {}).sourceReplyRoute === "current-source"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export type MessagingToolSend = {
|
|||||||
text?: string;
|
text?: string;
|
||||||
mediaUrls?: string[];
|
mediaUrls?: string[];
|
||||||
hasRichContent?: true;
|
hasRichContent?: true;
|
||||||
/** Present only when Codex classified this current-source delivery intent. */
|
/** Current-source progress (`false`) or completed reply (`true`). */
|
||||||
sourceReplyFinal?: boolean;
|
sourceReplyFinal?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,6 +29,6 @@ export type MessagingToolSourceReplyPayload = Pick<
|
|||||||
| "text"
|
| "text"
|
||||||
> & {
|
> & {
|
||||||
idempotencyKey?: string;
|
idempotencyKey?: string;
|
||||||
/** Present only when Codex classified this current-source delivery intent. */
|
/** Current-source progress (`false`) or completed reply (`true`). */
|
||||||
sourceReplyFinal?: boolean;
|
sourceReplyFinal?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -204,12 +204,13 @@ describe("message-tool-only source replies", () => {
|
|||||||
).resolves.toEqual({
|
).resolves.toEqual({
|
||||||
content: [{ type: "text", text: "rewritten" }],
|
content: [{ type: "text", text: "rewritten" }],
|
||||||
details: { rewritten: true },
|
details: { rewritten: true },
|
||||||
|
terminate: true,
|
||||||
});
|
});
|
||||||
expect(previousAfterToolCall).toHaveBeenCalledTimes(1);
|
expect(previousAfterToolCall).toHaveBeenCalledTimes(1);
|
||||||
expect(onDeliveredSourceReply).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 agent = {} as unknown as Agent;
|
||||||
const onDeliveredSourceReply = vi.fn();
|
const onDeliveredSourceReply = vi.fn();
|
||||||
installMessageToolOnlyTerminalHook({
|
installMessageToolOnlyTerminalHook({
|
||||||
@@ -225,10 +226,27 @@ describe("message-tool-only source replies", () => {
|
|||||||
args: { action: "send", message: "visible reply" },
|
args: { action: "send", message: "visible reply" },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
).resolves.toBeUndefined();
|
).resolves.toEqual({ terminate: true });
|
||||||
expect(onDeliveredSourceReply).toHaveBeenCalledTimes(1);
|
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 () => {
|
it("leaves existing after-tool-call output alone when the send failed", async () => {
|
||||||
const previousAfterToolCall = vi.fn(async () => ({
|
const previousAfterToolCall = vi.fn(async () => ({
|
||||||
content: [{ type: "text" as const, text: "failed" }],
|
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.
|
* 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";
|
import type { AfterToolCallContext, AfterToolCallResult, Agent } from "../../runtime/index.js";
|
||||||
|
|
||||||
function argsRecordForToolCall(context: AfterToolCallContext): Record<string, unknown> {
|
function argsRecordForToolCall(context: AfterToolCallContext): Record<string, unknown> {
|
||||||
@@ -55,7 +58,9 @@ export function installMessageToolOnlyTerminalHook(params: {
|
|||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
params.onDeliveredSourceReply?.();
|
params.onDeliveredSourceReply?.();
|
||||||
return hookResult;
|
if (resolveMessageToolSourceReplyFinal(argsRecordForToolCall(context))) {
|
||||||
|
return { ...hookResult, terminate: true };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return hookResult;
|
return hookResult;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3485,6 +3485,7 @@ describe("messaging tool media URL tracking", () => {
|
|||||||
|
|
||||||
it("commits internal-ui source replies from successful message sends", async () => {
|
it("commits internal-ui source replies from successful message sends", async () => {
|
||||||
const { ctx } = createTestContext();
|
const { ctx } = createTestContext();
|
||||||
|
ctx.params.sourceReplyDeliveryMode = "message_tool_only";
|
||||||
|
|
||||||
const startEvt: ToolExecutionStartEvent = {
|
const startEvt: ToolExecutionStartEvent = {
|
||||||
toolName: "message",
|
toolName: "message",
|
||||||
@@ -3519,6 +3520,7 @@ describe("messaging tool media URL tracking", () => {
|
|||||||
mediaUrls: ["file:///tmp/reply.png"],
|
mediaUrls: ["file:///tmp/reply.png"],
|
||||||
channelData: { source: "tui" },
|
channelData: { source: "tui" },
|
||||||
idempotencyKey: "stable-source-reply",
|
idempotencyKey: "stable-source-reply",
|
||||||
|
sourceReplyFinal: true,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
isDeliveredMessageToolOnlySourceReplyResult,
|
isDeliveredMessageToolOnlySourceReplyResult,
|
||||||
isDeliveredMessagingToolResult,
|
isDeliveredMessagingToolResult,
|
||||||
readMessageToolSourceReplyText,
|
readMessageToolSourceReplyText,
|
||||||
|
resolveMessageToolSourceReplyFinal,
|
||||||
} from "./embedded-agent-message-tool-source-reply.js";
|
} from "./embedded-agent-message-tool-source-reply.js";
|
||||||
import {
|
import {
|
||||||
isMessagingTool,
|
isMessagingTool,
|
||||||
@@ -1644,6 +1645,18 @@ export async function handleToolExecutionEnd(
|
|||||||
didDeliverMessagingResult && isMessagingSend
|
didDeliverMessagingResult && isMessagingSend
|
||||||
? [...argumentMediaUrls, ...collectMessagingMediaUrlsFromToolResult(result)]
|
? [...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.pendingMessagingTexts.delete(toolCallId);
|
||||||
ctx.state.pendingMessagingTargets.delete(toolCallId);
|
ctx.state.pendingMessagingTargets.delete(toolCallId);
|
||||||
ctx.state.pendingMessagingMediaUrls.delete(toolCallId);
|
ctx.state.pendingMessagingMediaUrls.delete(toolCallId);
|
||||||
@@ -1661,18 +1674,10 @@ export async function handleToolExecutionEnd(
|
|||||||
...(messageText ? { text: messageText } : {}),
|
...(messageText ? { text: messageText } : {}),
|
||||||
...(committedMediaUrls.length > 0 ? { mediaUrls: committedMediaUrls.slice() } : {}),
|
...(committedMediaUrls.length > 0 ? { mediaUrls: committedMediaUrls.slice() } : {}),
|
||||||
...(hasRichContent ? { hasRichContent: true as const } : {}),
|
...(hasRichContent ? { hasRichContent: true as const } : {}),
|
||||||
|
...(sourceReplyFinal !== undefined ? { sourceReplyFinal } : {}),
|
||||||
});
|
});
|
||||||
ctx.trimMessagingToolSent();
|
ctx.trimMessagingToolSent();
|
||||||
}
|
}
|
||||||
const deliveredCurrentSourceReply =
|
|
||||||
didDeliverMessagingResult &&
|
|
||||||
isDeliveredMessageToolOnlySourceReplyResult({
|
|
||||||
sourceReplyDeliveryMode: ctx.params.sourceReplyDeliveryMode,
|
|
||||||
toolName,
|
|
||||||
args: startArgs,
|
|
||||||
result,
|
|
||||||
isError: isToolError,
|
|
||||||
});
|
|
||||||
if (deliveredCurrentSourceReply) {
|
if (deliveredCurrentSourceReply) {
|
||||||
ctx.state.messageToolOnlySourceReplyDelivered = true;
|
ctx.state.messageToolOnlySourceReplyDelivered = true;
|
||||||
const sourceReplyText = readMessageToolSourceReplyText(startArgs);
|
const sourceReplyText = readMessageToolSourceReplyText(startArgs);
|
||||||
@@ -1692,7 +1697,10 @@ export async function handleToolExecutionEnd(
|
|||||||
}
|
}
|
||||||
const sourceReplyPayload = extractMessagingToolSourceReplyPayload(result);
|
const sourceReplyPayload = extractMessagingToolSourceReplyPayload(result);
|
||||||
if (sourceReplyPayload) {
|
if (sourceReplyPayload) {
|
||||||
ctx.state.messagingToolSourceReplyPayloads.push(sourceReplyPayload);
|
ctx.state.messagingToolSourceReplyPayloads.push({
|
||||||
|
...sourceReplyPayload,
|
||||||
|
...(sourceReplyFinal !== undefined ? { sourceReplyFinal } : {}),
|
||||||
|
});
|
||||||
ctx.trimMessagingToolSent();
|
ctx.trimMessagingToolSent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -601,7 +601,7 @@ function buildMessagingSection(params: {
|
|||||||
}) {
|
}) {
|
||||||
const messageToolOnly = params.sourceReplyDeliveryMode === "message_tool_only";
|
const messageToolOnly = params.sourceReplyDeliveryMode === "message_tool_only";
|
||||||
const visibleReplyInstruction = messageToolOnly
|
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)`.";
|
: "- Current-session final text normally routes to source. If turn says final private, visible output uses `message(action=send)`.";
|
||||||
const messageToolTargetInstruction = params.requireExplicitMessageTarget
|
const messageToolTargetInstruction = params.requireExplicitMessageTarget
|
||||||
? "- `send`: `target` + `message`; target required this turn."
|
? "- `send`: `target` + `message`; target required this turn."
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function appendMessageToolVisibleReplyHint(
|
|||||||
const targetGuidance = requireExplicitTarget
|
const targetGuidance = requireExplicitTarget
|
||||||
? "send needs target."
|
? "send needs target."
|
||||||
: "target defaults current source; set only elsewhere.";
|
: "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(
|
export function appendMessageToolReadHint(
|
||||||
|
|||||||
@@ -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("final");
|
||||||
expect(getToolProperties(createMessageTool())).not.toHaveProperty("idempotencyKey");
|
expect(getToolProperties(createMessageTool())).not.toHaveProperty("idempotencyKey");
|
||||||
});
|
});
|
||||||
@@ -636,7 +636,16 @@ describe("completion source-reply authority", () => {
|
|||||||
|
|
||||||
expect(getActionEnum(properties)).toEqual(["send"]);
|
expect(getActionEnum(properties)).toEqual(["send"]);
|
||||||
expect(Object.keys(properties).toSorted()).toEqual(
|
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, {
|
expectStringSchema(properties.message, {
|
||||||
description: "Text to send to the current source conversation.",
|
description: "Text to send to the current source conversation.",
|
||||||
@@ -778,7 +787,7 @@ describe("completion source-reply authority", () => {
|
|||||||
expect(mocks.runMessageAction).not.toHaveBeenCalled();
|
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" });
|
mockSendResult({ channel: "discord", to: "channel:source" });
|
||||||
const tool = createRestrictedTool();
|
const tool = createRestrictedTool();
|
||||||
|
|
||||||
@@ -1045,11 +1054,13 @@ describe("message tool secret scoping", () => {
|
|||||||
const defaultTool = createMessageTool();
|
const defaultTool = createMessageTool();
|
||||||
|
|
||||||
expect(scopedTool.description).toContain('visible reply: action="send" + message');
|
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("target defaults current source");
|
||||||
expect(scopedTool.description).toContain("Final answer private");
|
expect(scopedTool.description).toContain("Final answer private");
|
||||||
expect(explicitTargetTool.description).toContain("send needs target");
|
expect(explicitTargetTool.description).toContain("send needs target");
|
||||||
expect(explicitTargetTool.description).not.toContain("target defaults current source");
|
expect(explicitTargetTool.description).not.toContain("target defaults current source");
|
||||||
expect(defaultTool.description).not.toContain('visible reply: action="send" + message');
|
expect(defaultTool.description).not.toContain('visible reply: action="send" + message');
|
||||||
|
expect(getToolProperties(defaultTool)).not.toHaveProperty("final");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forwards source reply delivery mode through createOpenClawTools", () => {
|
it("forwards source reply delivery mode through createOpenClawTools", () => {
|
||||||
@@ -1059,6 +1070,7 @@ describe("message tool secret scoping", () => {
|
|||||||
}).find((candidate) => candidate.name === "message");
|
}).find((candidate) => candidate.name === "message");
|
||||||
|
|
||||||
expect(tool?.description).toContain('visible reply: action="send" + 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 () => {
|
it("passes source reply delivery mode to the outbound runner", async () => {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
normalizeOptionalStringifiedId,
|
normalizeOptionalStringifiedId,
|
||||||
} from "@openclaw/normalization-core/string-coerce";
|
} from "@openclaw/normalization-core/string-coerce";
|
||||||
import { sortUniqueStrings, uniqueValues } from "@openclaw/normalization-core/string-normalization";
|
import { sortUniqueStrings, uniqueValues } from "@openclaw/normalization-core/string-normalization";
|
||||||
import { Type, type TSchema } from "typebox";
|
import { Type, type TObject, type TSchema } from "typebox";
|
||||||
import {
|
import {
|
||||||
GATEWAY_CLIENT_IDS,
|
GATEWAY_CLIENT_IDS,
|
||||||
GATEWAY_CLIENT_MODES,
|
GATEWAY_CLIENT_MODES,
|
||||||
@@ -1095,6 +1095,22 @@ const SOURCE_REPLY_ONLY_MESSAGE_SCHEMA = Type.Object({
|
|||||||
threadId: Type.Optional(Type.String()),
|
threadId: Type.Optional(Type.String()),
|
||||||
});
|
});
|
||||||
const SOURCE_REPLY_ONLY_RUNTIME_ARG_NAMES = new Set(["to", "channelId", "final"]);
|
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 {
|
function enforceSourceReplyOnlyTextDirectives(args: Record<string, unknown>): void {
|
||||||
if (typeof args.message !== "string" || !args.message.trim()) {
|
if (typeof args.message !== "string" || !args.message.trim()) {
|
||||||
@@ -1574,11 +1590,12 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
|||||||
const actions = messageToolDiscoveryParams
|
const actions = messageToolDiscoveryParams
|
||||||
? resolveMessageToolActionSchemaActions(messageToolDiscoveryParams)
|
? resolveMessageToolActionSchemaActions(messageToolDiscoveryParams)
|
||||||
: undefined;
|
: undefined;
|
||||||
const schema = options?.sourceReplyOnly
|
const baseSchema = options?.sourceReplyOnly
|
||||||
? SOURCE_REPLY_ONLY_MESSAGE_SCHEMA
|
? SOURCE_REPLY_ONLY_MESSAGE_SCHEMA
|
||||||
: messageToolDiscoveryParams
|
: messageToolDiscoveryParams
|
||||||
? buildMessageToolSchema(messageToolDiscoveryParams, actions ?? [])
|
? buildMessageToolSchema(messageToolDiscoveryParams, actions ?? [])
|
||||||
: MessageToolSchema;
|
: MessageToolSchema;
|
||||||
|
const schema = addSourceReplyFinalControl(baseSchema, sourceReplySinkDeliveryMode);
|
||||||
const description = options?.sourceReplyOnly
|
const description = options?.sourceReplyOnly
|
||||||
? appendMessageToolVisibleReplyHint(
|
? appendMessageToolVisibleReplyHint(
|
||||||
"Send a message to the current source conversation. Supports actions: send.",
|
"Send a message to the current source conversation. Supports actions: send.",
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ describe("restart recovery terminal delivery receipt", () => {
|
|||||||
await seedClaim();
|
await seedClaim();
|
||||||
await beginRestartRecoveryTerminalDelivery(scope());
|
await beginRestartRecoveryTerminalDelivery(scope());
|
||||||
|
|
||||||
await expect(beginRestartRecoveryTerminalDelivery(scope())).resolves.toBe("blocked");
|
await expect(beginRestartRecoveryTerminalDelivery(scope())).resolves.toBe("delivery-ambiguous");
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([undefined, "done" as const])(
|
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 () => {
|
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. */
|
/** Persists ambiguity before a terminal external send is allowed to start. */
|
||||||
export async function beginRestartRecoveryTerminalDelivery(
|
export async function beginRestartRecoveryTerminalDelivery(
|
||||||
scope: RestartRecoveryTerminalDeliveryScope,
|
scope: RestartRecoveryTerminalDeliveryScope,
|
||||||
): Promise<"started" | "blocked" | "stale" | "not-applicable"> {
|
): Promise<"started" | "already-delivered" | "delivery-ambiguous" | "stale" | "not-applicable"> {
|
||||||
let started = false;
|
let started = false;
|
||||||
const updated = await updateSessionEntry(
|
const updated = await updateSessionEntry(
|
||||||
{ sessionKey: scope.sessionKey, storePath: scope.storePath },
|
{ sessionKey: scope.sessionKey, storePath: scope.storePath },
|
||||||
@@ -89,7 +89,7 @@ export async function beginRestartRecoveryTerminalDelivery(
|
|||||||
current?.sessionId === scope.sessionId &&
|
current?.sessionId === scope.sessionId &&
|
||||||
hasRestartRecoveryTerminalRun(current, scope.sourceTurnId)
|
hasRestartRecoveryTerminalRun(current, scope.sourceTurnId)
|
||||||
) {
|
) {
|
||||||
return "blocked";
|
return "already-delivered";
|
||||||
}
|
}
|
||||||
// The gateway already verified a short-lived current-turn capability. Room
|
// The gateway already verified a short-lived current-turn capability. Room
|
||||||
// events intentionally persist no running recovery state, so only durable
|
// events intentionally persist no running recovery state, so only durable
|
||||||
@@ -101,7 +101,9 @@ export async function beginRestartRecoveryTerminalDelivery(
|
|||||||
return "stale";
|
return "stale";
|
||||||
}
|
}
|
||||||
if (current.restartRecoveryDeliveryReceiptState || current.restartRecoveryDeliveryToolCallId) {
|
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");
|
throw new Error("failed to persist terminal delivery intent");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ const mocks = vi.hoisted(() => ({
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
beginRestartRecoveryTerminalDelivery: vi.fn<
|
beginRestartRecoveryTerminalDelivery: vi.fn<
|
||||||
() => Promise<"started" | "blocked" | "stale" | "not-applicable">
|
() => Promise<
|
||||||
|
"started" | "already-delivered" | "delivery-ambiguous" | "stale" | "not-applicable"
|
||||||
|
>
|
||||||
>(async () => "started"),
|
>(async () => "started"),
|
||||||
cancelRestartRecoveryTerminalDelivery: vi.fn(async () => "cleared" as const),
|
cancelRestartRecoveryTerminalDelivery: vi.fn(async () => "cleared" as const),
|
||||||
completeRestartRecoveryTerminalDelivery: vi.fn(async () => "recorded" as const),
|
completeRestartRecoveryTerminalDelivery: vi.fn(async () => "recorded" as const),
|
||||||
@@ -3084,14 +3086,30 @@ describe("gateway send mirroring", () => {
|
|||||||
idempotencyKey: "idem-shared-source-message-action",
|
idempotencyKey: "idem-shared-source-message-action",
|
||||||
});
|
});
|
||||||
|
|
||||||
await runMessageActionRequest(request("progress"), identity(false));
|
const progress = await runMessageActionRequest(request("progress"), identity(false));
|
||||||
await runMessageActionRequest(request("terminal"), identity(true));
|
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(mocks.appendAssistantMessageToSessionTranscript.mock.calls).toHaveLength(2);
|
||||||
expect(appendTranscriptCall(0)?.idempotencyKey).toBe("idem-shared-source-message-action");
|
expect(appendTranscriptCall(0)?.idempotencyKey).toBe("idem-shared-source-message-action");
|
||||||
expect(appendTranscriptCall(1)?.idempotencyKey).toBe(
|
expect(appendTranscriptCall(1)?.idempotencyKey).toBe(
|
||||||
"idem-shared-source-message-action:terminal-receipt:channel-user:v1:shared-key",
|
"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 () => {
|
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();
|
expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks a repeated terminal send before provider dispatch", async () => {
|
it("returns an already-delivered outcome for a repeated terminal send", async () => {
|
||||||
mocks.beginRestartRecoveryTerminalDelivery.mockResolvedValueOnce("blocked");
|
mocks.beginRestartRecoveryTerminalDelivery.mockResolvedValueOnce("already-delivered");
|
||||||
const { respond } = await runTelegramTerminalAction({
|
const { respond } = await runTelegramTerminalAction({
|
||||||
sessionId: "session-duplicate-terminal",
|
sessionId: "session-duplicate-terminal",
|
||||||
idempotencyKey: "idem-duplicate-terminal",
|
idempotencyKey: "idem-duplicate-terminal",
|
||||||
@@ -3141,7 +3159,11 @@ describe("gateway send mirroring", () => {
|
|||||||
message: "duplicate terminal",
|
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();
|
expect(mocks.dispatchChannelMessageAction).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1009,10 +1009,19 @@ export const sendHandlers: GatewayRequestHandlers = {
|
|||||||
? { sourceReplyFinal: trustedContext.sourceReplyFinal }
|
? { sourceReplyFinal: trustedContext.sourceReplyFinal }
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
const terminalDeliveryReceipt =
|
const terminalDeliveryStart =
|
||||||
trustedContext.sourceReplyFinal === true
|
trustedContext.sourceReplyFinal === true
|
||||||
? await beginTerminalSourceReplyDelivery(sourceReplyMirror)
|
? await beginTerminalSourceReplyDelivery(sourceReplyMirror)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
if (terminalDeliveryStart && "outcome" in terminalDeliveryStart) {
|
||||||
|
return createGatewayInflightSuccess({
|
||||||
|
context,
|
||||||
|
dedupeKey,
|
||||||
|
payload: terminalDeliveryStart.result,
|
||||||
|
channel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const terminalDeliveryReceipt = terminalDeliveryStart;
|
||||||
const gatewayClientScopes = client?.connect?.scopes ?? [];
|
const gatewayClientScopes = client?.connect?.scopes ?? [];
|
||||||
const handled = await dispatchChannelMessageAction({
|
const handled = await dispatchChannelMessageAction({
|
||||||
channel,
|
channel,
|
||||||
|
|||||||
@@ -338,9 +338,13 @@ export async function executeGatewayAction(params: {
|
|||||||
sourceReplyFinal: params.input.sourceReplyFinal,
|
sourceReplyFinal: params.input.sourceReplyFinal,
|
||||||
toolCallId: params.input.sourceReplyToolCallId,
|
toolCallId: params.input.sourceReplyToolCallId,
|
||||||
};
|
};
|
||||||
const terminalDeliveryReceipt = callerOwnsTerminalReceipt
|
const terminalDeliveryStart = callerOwnsTerminalReceipt
|
||||||
? await beginTerminalSourceReplyDelivery(sourceReplyMirror)
|
? await beginTerminalSourceReplyDelivery(sourceReplyMirror)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
if (terminalDeliveryStart && "outcome" in terminalDeliveryStart) {
|
||||||
|
return params.result(terminalDeliveryStart.result);
|
||||||
|
}
|
||||||
|
const terminalDeliveryReceipt = terminalDeliveryStart;
|
||||||
let hadUnknownDeliveryOutcome = false;
|
let hadUnknownDeliveryOutcome = false;
|
||||||
let payload: unknown;
|
let payload: unknown;
|
||||||
try {
|
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 () => {
|
it("cancels caller receipts after confirmed gateway request rejection", async () => {
|
||||||
const gatewayPlugin = createGatewayActionPlugin({
|
const gatewayPlugin = createGatewayActionPlugin({
|
||||||
pluginId: "gatewaychat",
|
pluginId: "gatewaychat",
|
||||||
|
|||||||
@@ -43,6 +43,28 @@ type SourceReplyTranscriptMirrorParams = {
|
|||||||
replyToIsExplicit?: boolean;
|
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 & {
|
type MirrorableSourceReplyTranscriptParams = SourceReplyTranscriptMirrorParams & {
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
};
|
};
|
||||||
@@ -220,7 +242,7 @@ function resolveTerminalSourceReplyDeliveryReceipt(
|
|||||||
/** Arms the fail-closed state before a terminal source reply can reach a provider. */
|
/** Arms the fail-closed state before a terminal source reply can reach a provider. */
|
||||||
export async function beginTerminalSourceReplyDelivery(
|
export async function beginTerminalSourceReplyDelivery(
|
||||||
params: SourceReplyTranscriptMirrorParams,
|
params: SourceReplyTranscriptMirrorParams,
|
||||||
): Promise<TerminalSourceReplyDeliveryReceipt | undefined> {
|
): Promise<TerminalSourceReplyDeliveryStart> {
|
||||||
const receipt = resolveTerminalSourceReplyDeliveryReceipt(params);
|
const receipt = resolveTerminalSourceReplyDeliveryReceipt(params);
|
||||||
if (!receipt) {
|
if (!receipt) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -229,11 +251,11 @@ export async function beginTerminalSourceReplyDelivery(
|
|||||||
if (result === "not-applicable") {
|
if (result === "not-applicable") {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (result === "blocked") {
|
if (result === "already-delivered") {
|
||||||
throw new Error("terminal source reply already has a durable delivery outcome");
|
return buildTerminalSourceReplyNoSendResult("already_delivered");
|
||||||
}
|
}
|
||||||
if (result === "stale") {
|
if (result === "delivery-ambiguous" || result === "stale") {
|
||||||
throw new Error("terminal source reply lost restart recovery ownership");
|
return buildTerminalSourceReplyNoSendResult("delivery_ambiguous");
|
||||||
}
|
}
|
||||||
return receipt;
|
return receipt;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user