fix(agents): preserve CLI message delivery evidence

This commit is contained in:
Vincent Koc
2026-06-16 02:27:12 +08:00
parent 04d8a96b6c
commit 0ea08076c3
53 changed files with 5561 additions and 683 deletions
+12
View File
@@ -15,6 +15,16 @@ type SlackActionInvoke = (
let slackActionRuntimePromise: Promise<typeof import("./action-runtime.runtime.js")> | undefined;
const SLACK_TOOL_DELIVERY_ACTIONS = new Set([
"deleteMessage",
"editMessage",
"pinMessage",
"react",
"sendMessage",
"unpinMessage",
"uploadFile",
]);
async function loadSlackActionRuntime() {
slackActionRuntimePromise ??= import("./action-runtime.runtime.js");
return await slackActionRuntimePromise;
@@ -42,6 +52,8 @@ export function createSlackActions(
return {
describeMessageTool: describeSlackMessageTool,
extractToolSend: ({ args }) => extractSlackToolSend(args),
isToolDeliveryAction: ({ args }) =>
typeof args.action === "string" && SLACK_TOOL_DELIVERY_ACTIONS.has(args.action),
prepareSendPayload: ({ ctx, payload }) => (ctx.action === "send" ? payload : null),
handleAction: async (ctx) => {
return await handleSlackMessageAction({
@@ -1,6 +1,7 @@
// Slack tests cover message tools plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import { createSlackActions } from "./channel-actions.js";
import { listSlackMessageActions } from "./message-actions.js";
import { describeSlackMessageTool } from "./message-tool-api.js";
@@ -24,6 +25,16 @@ function requireSchemaProperty(
}
describe("Slack message tools", () => {
it("classifies provider-native mutation actions", () => {
const actions = createSlackActions("slack");
for (const action of ["sendMessage", "editMessage", "deleteMessage", "pinMessage"]) {
expect(actions.isToolDeliveryAction?.({ args: { action } })).toBe(true);
}
for (const action of ["readMessages", "listPins", "downloadFile"]) {
expect(actions.isToolDeliveryAction?.({ args: { action } })).toBe(false);
}
});
it("describes configured Slack message actions without loading channel runtime", () => {
const discovery = describeSlackMessageTool({
cfg: {
@@ -27,6 +27,15 @@ describe("telegramMessageActions", () => {
}
});
it("classifies provider-native mutation actions", () => {
for (const action of ["sendMessage", "editMessage", "deleteMessage", "react", "topic-edit"]) {
expect(telegramMessageActions.isToolDeliveryAction?.({ args: { action } })).toBe(true);
}
for (const action of ["searchSticker", "stickerCacheStats"]) {
expect(telegramMessageActions.isToolDeliveryAction?.({ args: { action } })).toBe(false);
}
});
it("allows interactive-only sends", async () => {
await telegramMessageActions.handleAction!({
action: "send",
@@ -50,6 +50,23 @@ const TELEGRAM_MESSAGE_ACTION_MAP = {
"topic-edit": "editForumTopic",
} as const satisfies Partial<Record<ChannelMessageActionName, string>>;
const TELEGRAM_TOOL_DELIVERY_ACTIONS = new Set([
"createForumTopic",
"delete",
"deleteMessage",
"edit",
"editForumTopic",
"editMessage",
"poll",
"react",
"send",
"sendMessage",
"sendSticker",
"sticker",
"topic-create",
"topic-edit",
]);
function resolveTelegramMessageActionName(action: ChannelMessageActionName) {
return TELEGRAM_MESSAGE_ACTION_MAP[action as keyof typeof TELEGRAM_MESSAGE_ACTION_MAP];
}
@@ -181,6 +198,8 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
extractToolSend: ({ args }) => {
return extractToolSend(args, "sendMessage");
},
isToolDeliveryAction: ({ args }) =>
typeof args.action === "string" && TELEGRAM_TOOL_DELIVERY_ACTIONS.has(args.action),
handleAction: async ({
action,
params,
+4
View File
@@ -289,6 +289,10 @@ const telegramMessageActions: ChannelMessageActionAdapter = {
getOptionalTelegramRuntime()?.channel?.telegram?.messageActions?.extractToolSend?.(ctx) ??
telegramMessageActionsImpl.extractToolSend?.(ctx) ??
null,
isToolDeliveryAction: (ctx) =>
getOptionalTelegramRuntime()?.channel?.telegram?.messageActions?.isToolDeliveryAction?.(ctx) ??
telegramMessageActionsImpl.isToolDeliveryAction?.(ctx) ??
false,
handleAction: async (ctx) => {
const runtimeHandleAction =
getOptionalTelegramRuntime()?.channel?.telegram?.messageActions?.handleAction;
+16
View File
@@ -5,10 +5,26 @@ import {
extractCliErrorMessage,
parseCliJson,
parseCliJsonl,
supportsCliJsonlToolEvents,
type CliToolUseStartDelta,
} from "./cli-output.js";
import { createClaudeApiErrorFixture } from "./test-helpers/claude-api-error-fixture.js";
describe("supportsCliJsonlToolEvents", () => {
it.each([
["Claude provider", { command: "claude", output: "jsonl" as const }, "claude-cli", true],
[
"explicit Claude dialect",
{ command: "custom", output: "jsonl" as const, jsonlDialect: "claude-stream-json" as const },
"custom-cli",
true,
],
["generic JSONL", { command: "custom", output: "jsonl" as const }, "custom-cli", false],
])("%s: %s", (_name, backend, providerId, expected) => {
expect(supportsCliJsonlToolEvents({ backend, providerId })).toBe(expected);
});
});
describe("parseCliJson", () => {
it("recovers mixed-output Claude session metadata from embedded JSON objects", () => {
const result = parseCliJson(
+17 -6
View File
@@ -8,6 +8,10 @@ import { normalizeStringEntries } from "@openclaw/normalization-core/string-norm
import type { CliBackendConfig } from "../config/types.js";
import { extractBalancedJsonFragments } from "../shared/balanced-json.js";
import { isRecord } from "../utils.js";
import type {
MessagingToolSend,
MessagingToolSourceReplyPayload,
} from "./embedded-agent-messaging.types.js";
type CliUsage = {
input?: number;
@@ -24,6 +28,12 @@ export type CliOutput = {
sessionId?: string;
usage?: CliUsage;
finalPromptText?: string;
didSendViaMessagingTool?: boolean;
didDeliverSourceReplyViaMessageTool?: boolean;
messagingToolSentTexts?: string[];
messagingToolSentMediaUrls?: string[];
messagingToolSentTargets?: MessagingToolSend[];
messagingToolSourceReplyPayloads?: MessagingToolSourceReplyPayload[];
};
/** Incremental assistant text emitted while parsing a streaming CLI response. */
@@ -53,7 +63,8 @@ function isClaudeCliProvider(providerId: string): boolean {
return normalizeLowercaseStringOrEmpty(providerId) === "claude-cli";
}
function usesClaudeStreamJsonDialect(params: {
/** Returns whether JSONL output carries correlated Claude-style tool events. */
export function supportsCliJsonlToolEvents(params: {
backend: CliBackendConfig;
providerId: string;
}): boolean {
@@ -67,7 +78,7 @@ function isClaudeStreamJsonResult(params: {
providerId: string;
parsed: Record<string, unknown>;
}): boolean {
return usesClaudeStreamJsonDialect(params) && params.parsed.type === "result";
return supportsCliJsonlToolEvents(params) && params.parsed.type === "result";
}
function extractJsonObjectCandidates(raw: string): string[] {
@@ -368,7 +379,7 @@ function parseClaudeCliJsonlResult(params: {
sessionId?: string;
usage?: CliUsage;
}): CliOutput | null {
if (!usesClaudeStreamJsonDialect(params)) {
if (!supportsCliJsonlToolEvents(params)) {
return null;
}
if (
@@ -395,7 +406,7 @@ function parseClaudeCliStreamingDelta(params: {
sessionId?: string;
usage?: CliUsage;
}): CliStreamingDelta | null {
if (!usesClaudeStreamJsonDialect(params)) {
if (!supportsCliJsonlToolEvents(params)) {
return null;
}
if (params.parsed.type !== "stream_event" || !isRecord(params.parsed.event)) {
@@ -510,7 +521,7 @@ function dispatchClaudeCliStreamingToolEvent(params: {
onToolUseStart?: (delta: CliToolUseStartDelta) => void;
onToolResult?: (delta: CliToolResultDelta) => void;
}): void {
if (!usesClaudeStreamJsonDialect(params)) {
if (!supportsCliJsonlToolEvents(params)) {
return;
}
const tracker = params.tracker;
@@ -644,7 +655,7 @@ export function createCliJsonlStreamingParser(params: {
// Classification is keyed on consumer presence so reclassified pre-tool text
// always has a destination; a separate enable flag let it be dropped (#92092).
const classifyClaudeCommentary =
Boolean(params.onCommentaryText) && usesClaudeStreamJsonDialect(params);
Boolean(params.onCommentaryText) && supportsCliJsonlToolEvents(params);
const flushPendingClaudeAssistantText = () => {
if (!pendingClaudeText) {
@@ -1,6 +1,7 @@
/** Tests cron before_agent_reply gating at the CLI runner entrypoint. */
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import type { CliOutput } from "./cli-output.js";
import { cliBackendLog } from "./cli-runner/log.js";
// vi.mock factories are hoisted above imports, so any references inside them
@@ -28,9 +29,9 @@ const {
runBeforeAgentReplyMock: vi.fn<(event: unknown, ctx: unknown) => Promise<BeforeAgentReplyResult>>(
async () => undefined,
),
executePreparedCliRunMock: vi.fn(async (_context: unknown, _cliSessionIdToUse?: string) => ({
text: "",
})),
executePreparedCliRunMock: vi.fn<
(_context: unknown, _cliSessionIdToUse?: string) => Promise<CliOutput>
>(async () => ({ text: "" })),
prepareCliRunContextMock: vi.fn(),
closeClaudeLiveSessionForContextMock: vi.fn(),
closeMcpLoopbackServerMock: vi.fn(),
@@ -262,6 +263,35 @@ describe("runCliAgent cron before_agent_reply seam", () => {
expect(executePreparedCliRunMock).toHaveBeenCalledTimes(1);
});
it("reports confirmed CLI messaging delivery evidence without leaking it to later invocations", async () => {
executePreparedCliRunMock.mockResolvedValueOnce({
text: "sent",
didSendViaMessagingTool: true,
messagingToolSentTargets: [
{
tool: "message",
provider: "telegram",
to: "chat123",
},
],
});
executePreparedCliRunMock.mockResolvedValueOnce({ text: "later" });
const firstResult = await runCliAgent(baseRunParams);
expect(firstResult.didSendViaMessagingTool).toBe(true);
expect(firstResult.messagingToolSentTargets).toEqual([
expect.objectContaining({
tool: "message",
provider: "telegram",
to: "chat123",
}),
]);
const laterResult = await runCliAgent(baseRunParams);
expect(laterResult.didSendViaMessagingTool).toBeUndefined();
expect(laterResult.messagingToolSentTargets).toBeUndefined();
});
it("can close temporary CLI live sessions after a run", async () => {
executePreparedCliRunMock.mockResolvedValue({ text: "real reply" });
@@ -282,4 +312,27 @@ describe("runCliAgent cron before_agent_reply seam", () => {
expect(executePreparedCliRunMock).toHaveBeenCalledTimes(1);
expect(closeMcpLoopbackServerMock).toHaveBeenCalledTimes(1);
});
it("preserves confirmed delivery when bundle MCP cleanup fails", async () => {
executePreparedCliRunMock.mockResolvedValue({
text: "",
didSendViaMessagingTool: true,
});
closeMcpLoopbackServerMock.mockRejectedValue(new Error("loopback cleanup failed"));
await expect(
runCliAgent({ ...baseRunParams, cleanupBundleMcpOnRunEnd: true }),
).resolves.toMatchObject({
didSendViaMessagingTool: true,
});
});
it("surfaces bundle MCP cleanup failures when nothing was delivered", async () => {
executePreparedCliRunMock.mockResolvedValue({ text: "real reply" });
closeMcpLoopbackServerMock.mockRejectedValue(new Error("loopback cleanup failed"));
await expect(runCliAgent({ ...baseRunParams, cleanupBundleMcpOnRunEnd: true })).rejects.toThrow(
"loopback cleanup failed",
);
});
});
+27
View File
@@ -562,6 +562,33 @@ describe("resolveCliRunQueueKey", () => {
}),
).toBe("claude-cli:run-4");
});
it("keeps Claude live sessions serialized when serialize=false", () => {
expect(
resolveCliRunQueueKey({
backendId: "claude-cli",
liveSession: "claude-stdio",
serialize: false,
runId: "run-live",
workspaceDir: "/tmp/project-a",
ownerKey: "abcd1234",
}),
).toBe("claude-cli:owner:abcd1234");
});
it("keeps resumed Claude live sessions on the owner lane", () => {
expect(
resolveCliRunQueueKey({
backendId: "claude-cli",
liveSession: "claude-stdio",
serialize: true,
runId: "run-live-resumed",
workspaceDir: "/tmp/project-a",
cliSessionId: "claude-session-123",
ownerKey: "abcd1234",
}),
).toBe("claude-cli:owner:abcd1234");
});
});
describe("buildClaudeOwnerKey", () => {
+728
View File
@@ -12,7 +12,16 @@ import {
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import { CURRENT_SESSION_VERSION } from "../config/sessions/version.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
markMcpLoopbackRequestClassified,
markMcpLoopbackRequestStarted,
markMcpLoopbackToolCallFinished,
markMcpLoopbackToolCallStarted,
recordMcpLoopbackToolCallResult,
updateMcpLoopbackToolCallCapture,
} from "../gateway/mcp-http.loopback-runtime.js";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import type { getProcessSupervisor } from "../process/supervisor/index.js";
import {
createUserTurnTranscriptRecorder,
type UserTurnTranscriptRecorder,
@@ -572,6 +581,725 @@ describe("runCliAgent reliability", () => {
expect(supervisorSpawnMock).toHaveBeenCalledTimes(2);
});
it("does not retry or fail over after a confirmed message send", async () => {
supervisorSpawnMock.mockClear();
supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => {
const input = args[0] as Parameters<ReturnType<typeof getProcessSupervisor>["spawn"]>[0];
const captureKey = input.env?.OPENCLAW_MCP_CLI_CAPTURE_KEY ?? "";
const captureHandle = markMcpLoopbackToolCallStarted({
captureKey,
toolName: "message",
args: {
action: "send",
channel: "telegram",
target: "chat123",
message: "done",
mediaUrl: "https://example.com/done.png",
},
});
if (!captureHandle) {
throw new Error("Expected message delivery capture");
}
setTimeout(() => {
recordMcpLoopbackToolCallResult({
captureHandle,
toolName: "message",
args: {
action: "send",
channel: "telegram",
target: "chat123",
message: "done",
mediaUrl: "https://example.com/done.png",
},
result: { status: "sent" },
isError: false,
});
markMcpLoopbackToolCallFinished(captureHandle);
}, 10);
return createManagedRun({
reason: "no-output-timeout",
exitCode: null,
exitSignal: "SIGKILL",
durationMs: 200,
stdout: "",
stderr: "",
timedOut: true,
noOutputTimedOut: true,
});
});
const context = buildPreparedContext({
sessionKey: "agent:main:delivered-timeout",
runId: "run-delivered-timeout",
cliSessionId: "stale-cli-session",
provider: "claude-cli",
model: "opus",
openClawHistoryPrompt: CLI_RESEED_PROMPT,
});
context.mcpDeliveryCapture = true;
const result = await runPreparedCliAgent(context);
expect(result.payloads).toBeUndefined();
expect(result.didSendViaMessagingTool).toBe(true);
expect(result.messagingToolSentTexts).toEqual(["done"]);
expect(result.messagingToolSentMediaUrls).toEqual(["https://example.com/done.png"]);
expect(result.messagingToolSentTargets).toEqual([
expect.objectContaining({ tool: "message", provider: "telegram", to: "chat123" }),
]);
expect(result.meta.executionTrace?.attempts?.[0]?.result).toBe("error");
expect(result.meta.agentMeta?.clearCliSessionBinding).toBe(true);
expect(supervisorSpawnMock).toHaveBeenCalledTimes(1);
});
it("preserves first-turn delivery through cleanup without binding the OpenClaw session id", 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: "sent before failure",
},
});
if (!captureHandle) {
throw new Error("Expected message delivery capture");
}
recordMcpLoopbackToolCallResult({
captureHandle,
toolName: "message",
args: {
action: "send",
message: "sent before failure",
},
result: {
details: {
deliveryStatus: "sent",
sourceReplySink: "internal-ui",
sourceReply: { text: "sent before failure" },
},
},
isError: false,
});
markMcpLoopbackToolCallFinished(captureHandle);
return createManagedRun({
reason: "no-output-timeout",
exitCode: null,
exitSignal: "SIGKILL",
durationMs: 200,
stdout: "",
stderr: "",
timedOut: true,
noOutputTimedOut: true,
});
});
const context = buildPreparedContext({
sessionKey: "agent:main:first-turn-delivered",
runId: "run-first-turn-delivered",
provider: "claude-cli",
model: "opus",
});
context.mcpDeliveryCapture = true;
context.params.sourceReplyDeliveryMode = "message_tool_only";
context.preparedBackend.cleanup = async () => {
throw new Error("cleanup failed");
};
const result = await runPreparedCliAgent(context);
expect(result.didSendViaMessagingTool).toBe(true);
expect(result.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(result.messagingToolSourceReplyPayloads).toEqual([{ text: "sent before failure" }]);
expect(result.payloads).toEqual([{ text: "sent before failure" }]);
expect(getReplyPayloadMetadata(result.payloads?.[0] as object)).toMatchObject({
deliverDespiteSourceReplySuppression: true,
sourceReplyTranscriptMirror: {
sessionKey: "agent:main:first-turn-delivered",
text: "sent before failure",
idempotencyKey: "run-first-turn-delivered:internal-source-reply:0",
},
});
expect(result.meta.agentMeta?.sessionId).toBe("");
expect(result.meta.agentMeta?.clearCliSessionBinding).toBeUndefined();
expect(supervisorSpawnMock).toHaveBeenCalledTimes(1);
});
it("returns only the source-reply mirror after a successful CLI turn", 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: "sent through source reply",
},
});
if (!captureHandle) {
throw new Error("Expected message delivery capture");
}
recordMcpLoopbackToolCallResult({
captureHandle,
toolName: "message",
args: {
action: "send",
message: "sent through source reply",
},
result: {
details: {
deliveryStatus: "sent",
sourceReplySink: "internal-ui",
sourceReply: { text: "sent through source reply" },
},
},
isError: false,
});
markMcpLoopbackToolCallFinished(captureHandle);
return createManagedRun({
reason: "exit",
exitCode: 0,
exitSignal: null,
durationMs: 50,
stdout: "ordinary final should stay private",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
});
});
const context = buildPreparedContext({
sessionKey: "agent:main:successful-source-reply",
runId: "run-successful-source-reply",
provider: "claude-cli",
model: "opus",
});
context.mcpDeliveryCapture = true;
context.params.sourceReplyDeliveryMode = "message_tool_only";
const result = await runPreparedCliAgent(context);
expect(result.payloads).toEqual([{ text: "sent through source reply" }]);
expect(getReplyPayloadMetadata(result.payloads?.[0] as object)).toMatchObject({
deliverDespiteSourceReplySuppression: true,
sourceReplyTranscriptMirror: {
sessionKey: "agent:main:successful-source-reply",
text: "sent through source reply",
idempotencyKey: "run-successful-source-reply:internal-source-reply:0",
},
});
expect(result.meta.finalAssistantVisibleText).toBe("sent through source reply");
});
it("hooks the visible source reply without pre-persisting its dispatch mirror", async () => {
const { dir, sessionFile, storePath } = createSessionFile();
const hookRunner = {
hasHooks: vi.fn((hookName: string) => ["llm_output", "agent_end"].includes(hookName)),
runLlmInput: vi.fn(async () => undefined),
runLlmOutput: vi.fn(async () => undefined),
runAgentEnd: vi.fn(async () => undefined),
};
setHookRunnerForTest(hookRunner);
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: "visible source reply",
},
});
if (!captureHandle) {
throw new Error("Expected message delivery capture");
}
recordMcpLoopbackToolCallResult({
captureHandle,
toolName: "message",
args: {
action: "send",
message: "visible source reply",
},
result: {
details: {
deliveryStatus: "sent",
sourceReplySink: "internal-ui",
sourceReply: { text: "visible source reply" },
},
},
isError: false,
});
markMcpLoopbackToolCallFinished(captureHandle);
return createManagedRun({
reason: "exit",
exitCode: 0,
exitSignal: null,
durationMs: 50,
stdout: "private terminal confirmation",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
});
});
const context = buildPreparedContext({
sessionKey: "agent:main:main",
runId: "run-visible-source-reply",
provider: "claude-cli",
model: "opus",
});
context.mcpDeliveryCapture = true;
context.params.sourceReplyDeliveryMode = "message_tool_only";
context.params.sessionFile = sessionFile;
context.params.storePath = storePath;
context.params.persistAssistantTranscript = true;
try {
await runPreparedCliAgent(context);
const transcriptMessages = readTranscriptMessages(sessionFile);
expect(transcriptMessages).toHaveLength(0);
const llmOutputEvent = requireRecord(
callArg(hookRunner.runLlmOutput, 0, 0, "llm_output event"),
"llm_output event",
);
expect(llmOutputEvent.assistantTexts).toEqual(["visible source reply"]);
const agentEndEvent = requireRecord(
callArg(hookRunner.runAgentEnd, 0, 0, "agent_end event"),
"agent_end event",
);
const messages = requireArray(agentEndEvent.messages, "agent_end messages");
const lastMessage = requireRecord(messages.at(-1), "agent_end assistant message");
expect(lastMessage.role).toBe("assistant");
expect(lastMessage.content).toEqual([{ type: "text", text: "visible source reply" }]);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("accepts empty terminal output after a confirmed message delivery", 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",
channel: "telegram",
target: "chat123",
message: "sent without a terminal reply",
},
});
if (!captureHandle) {
throw new Error("Expected message delivery capture");
}
recordMcpLoopbackToolCallResult({
captureHandle,
toolName: "message",
args: {
action: "send",
channel: "telegram",
target: "chat123",
message: "sent without a terminal reply",
},
result: { status: "sent" },
isError: false,
});
markMcpLoopbackToolCallFinished(captureHandle);
input.onStdout?.(
`${JSON.stringify({ type: "result", session_id: "claude-session", result: "" })}\n`,
);
return createManagedRun({
reason: "exit",
exitCode: 0,
exitSignal: null,
durationMs: 50,
stdout: "",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
});
});
const context = buildPreparedContext({
sessionKey: "agent:main:successful-empty-delivery",
runId: "run-successful-empty-delivery",
provider: "claude-cli",
model: "opus",
});
context.backendResolved.config.output = "jsonl";
context.mcpDeliveryCapture = true;
const result = await runPreparedCliAgent(context);
expect(result.payloads).toBeUndefined();
expect(result.didSendViaMessagingTool).toBe(true);
expect(result.meta.executionTrace?.attempts?.[0]?.result).toBe("success");
});
it("keeps unresolved internal source replies retryable", async () => {
vi.useFakeTimers();
supervisorSpawnMock.mockClear();
let captureStarted: (() => void) | undefined;
const captureStartedPromise = new Promise<void>((resolve) => {
captureStarted = resolve;
});
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: "pending internal source reply",
},
});
if (!captureHandle) {
throw new Error("Expected internal source reply capture");
}
updateMcpLoopbackToolCallCapture(captureHandle, {
toolName: "message",
args: {
action: "send",
message: "pending internal source reply",
},
});
captureStarted?.();
return createManagedRun({
reason: "no-output-timeout",
exitCode: null,
exitSignal: "SIGKILL",
durationMs: 200,
stdout: "",
stderr: "",
timedOut: true,
noOutputTimedOut: true,
});
});
const context = buildPreparedContext({
sessionKey: "agent:main:unresolved-internal-source-reply",
runId: "run-unresolved-internal-source-reply",
provider: "claude-cli",
model: "opus",
});
context.mcpDeliveryCapture = true;
context.params.config = {};
context.params.messageChannel = "webchat";
context.params.sourceReplyDeliveryMode = "message_tool_only";
const resultPromise = runPreparedCliAgent(context);
const resultAssertion = expect(resultPromise).rejects.toThrow("CLI produced no output");
await captureStartedPromise;
await vi.runAllTimersAsync();
await resultAssertion;
expect(supervisorSpawnMock).toHaveBeenCalledTimes(1);
});
it("fails closed when an unresolved implicit send resolves to an external session route", async () => {
vi.useFakeTimers();
supervisorSpawnMock.mockClear();
let captureStarted: (() => void) | undefined;
const captureStartedPromise = new Promise<void>((resolve) => {
captureStarted = resolve;
});
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: "pending external session reply",
},
});
if (!captureHandle) {
throw new Error("Expected external session reply capture");
}
updateMcpLoopbackToolCallCapture(captureHandle, {
toolName: "message",
args: {
action: "send",
message: "pending external session reply",
},
});
captureStarted?.();
return createManagedRun({
reason: "no-output-timeout",
exitCode: null,
exitSignal: "SIGKILL",
durationMs: 200,
stdout: "",
stderr: "",
timedOut: true,
noOutputTimedOut: true,
});
});
const context = buildPreparedContext({
sessionKey: "agent:main:telegram:direct:123456789",
runId: "run-unresolved-external-session-reply",
provider: "claude-cli",
model: "opus",
});
context.mcpDeliveryCapture = true;
context.params.config = {};
context.params.messageChannel = "webchat";
context.params.sourceReplyDeliveryMode = "message_tool_only";
const resultPromise = runPreparedCliAgent(context);
await captureStartedPromise;
await vi.runAllTimersAsync();
const result = await resultPromise;
expect(result.didSendViaMessagingTool).toBe(true);
expect(supervisorSpawnMock).toHaveBeenCalledTimes(1);
});
it("surfaces prepared backend cleanup failures when nothing was delivered", async () => {
supervisorSpawnMock.mockResolvedValueOnce(
createManagedRun({
reason: "exit",
exitCode: 0,
exitSignal: null,
durationMs: 50,
stdout: "ok",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
}),
);
const context = buildPreparedContext({
sessionKey: "agent:main:cleanup-failure",
runId: "run-cleanup-failure",
});
context.preparedBackend.cleanup = async () => {
throw new Error("cleanup failed");
};
await expect(runPreparedCliAgent(context)).rejects.toThrow("cleanup failed");
});
it("bounds unresolved message sends and does not retry them", async () => {
vi.useFakeTimers();
supervisorSpawnMock.mockClear();
let captureStarted: (() => void) | undefined;
const captureStartedPromise = new Promise<void>((resolve) => {
captureStarted = resolve;
});
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: "react",
channel: "telegram",
target: "chat123",
},
});
if (!captureHandle) {
throw new Error("Expected message delivery capture");
}
updateMcpLoopbackToolCallCapture(captureHandle, {
toolName: "message",
args: {
action: "send",
channel: "telegram",
target: "chat123",
message: "possibly sent",
},
});
captureStarted?.();
return createManagedRun({
reason: "no-output-timeout",
exitCode: null,
exitSignal: "SIGKILL",
durationMs: 200,
stdout: "",
stderr: "",
timedOut: true,
noOutputTimedOut: true,
});
});
const context = buildPreparedContext({
sessionKey: "agent:main:unresolved-send",
runId: "run-unresolved-send",
cliSessionId: "stale-cli-session",
provider: "claude-cli",
model: "opus",
openClawHistoryPrompt: CLI_RESEED_PROMPT,
});
context.mcpDeliveryCapture = true;
const resultPromise = runPreparedCliAgent(context);
await captureStartedPromise;
await vi.runAllTimersAsync();
const result = await resultPromise;
expect(result.payloads).toBeUndefined();
expect(result.didSendViaMessagingTool).toBe(true);
expect(supervisorSpawnMock).toHaveBeenCalledTimes(1);
});
it("bounds admitted requests that have not finished uploading", async () => {
vi.useFakeTimers();
supervisorSpawnMock.mockClear();
let captureStarted: (() => void) | undefined;
const captureStartedPromise = new Promise<void>((resolve) => {
captureStarted = resolve;
});
supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => {
const input = args[0] as Parameters<ReturnType<typeof getProcessSupervisor>["spawn"]>[0];
const captureHandle = markMcpLoopbackRequestStarted(
input.env?.OPENCLAW_MCP_CLI_CAPTURE_KEY ?? "",
);
if (!captureHandle) {
throw new Error("Expected request delivery capture");
}
captureStarted?.();
return createManagedRun({
reason: "no-output-timeout",
exitCode: null,
exitSignal: "SIGKILL",
durationMs: 200,
stdout: "",
stderr: "",
timedOut: true,
noOutputTimedOut: true,
});
});
const context = buildPreparedContext({
sessionKey: "agent:main:unresolved-request",
runId: "run-unresolved-request",
cliSessionId: "stale-cli-session",
provider: "claude-cli",
model: "opus",
openClawHistoryPrompt: CLI_RESEED_PROMPT,
});
context.mcpDeliveryCapture = true;
const resultPromise = runPreparedCliAgent(context);
await captureStartedPromise;
await vi.runAllTimersAsync();
const result = await resultPromise;
expect(result.payloads).toBeUndefined();
expect(result.didSendViaMessagingTool).toBe(true);
expect(supervisorSpawnMock).toHaveBeenCalledTimes(1);
});
it("does not treat classified non-message requests as delivery", async () => {
vi.useFakeTimers();
supervisorSpawnMock.mockClear();
let captureStarted: (() => void) | undefined;
const captureStartedPromise = new Promise<void>((resolve) => {
captureStarted = resolve;
});
supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => {
const input = args[0] as Parameters<ReturnType<typeof getProcessSupervisor>["spawn"]>[0];
const requestCaptureHandle = markMcpLoopbackRequestStarted(
input.env?.OPENCLAW_MCP_CLI_CAPTURE_KEY ?? "",
);
if (!requestCaptureHandle) {
throw new Error("Expected request delivery capture");
}
markMcpLoopbackToolCallStarted({
requestCaptureHandle,
toolName: "exec",
args: { command: "sleep 30" },
});
markMcpLoopbackRequestClassified(requestCaptureHandle);
captureStarted?.();
return createManagedRun({
reason: "no-output-timeout",
exitCode: null,
exitSignal: "SIGKILL",
durationMs: 200,
stdout: "",
stderr: "",
timedOut: true,
noOutputTimedOut: true,
});
});
const context = buildPreparedContext({
sessionKey: "agent:main:unresolved-non-message-request",
runId: "run-unresolved-non-message-request",
cliSessionId: "stale-cli-session",
provider: "claude-cli",
model: "opus",
openClawHistoryPrompt: CLI_RESEED_PROMPT,
});
context.mcpDeliveryCapture = true;
const resultPromise = runPreparedCliAgent(context);
const resultAssertion = expect(resultPromise).rejects.toThrow("produced no output");
await captureStartedPromise;
await vi.runAllTimersAsync();
await resultAssertion;
expect(supervisorSpawnMock).toHaveBeenCalledTimes(1);
});
it("fails normally after an unresolved prepared dry-run send", async () => {
vi.useFakeTimers();
supervisorSpawnMock.mockClear();
let captureStarted: (() => void) | undefined;
const captureStartedPromise = new Promise<void>((resolve) => {
captureStarted = resolve;
});
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",
channel: "telegram",
target: "chat123",
message: "preview",
},
});
updateMcpLoopbackToolCallCapture(captureHandle, {
toolName: "message",
args: {
action: "send",
channel: "telegram",
target: "chat123",
message: "preview",
dryRun: true,
},
});
captureStarted?.();
return createManagedRun({
reason: "no-output-timeout",
exitCode: null,
exitSignal: "SIGKILL",
durationMs: 200,
stdout: "",
stderr: "",
timedOut: true,
noOutputTimedOut: true,
});
});
const context = buildPreparedContext({
sessionKey: "agent:main:unresolved-dry-run",
runId: "run-unresolved-dry-run",
cliSessionId: "stale-cli-session",
provider: "claude-cli",
model: "opus",
openClawHistoryPrompt: CLI_RESEED_PROMPT,
});
context.mcpDeliveryCapture = true;
const resultPromise = runPreparedCliAgent(context);
const resultAssertion = expect(resultPromise).rejects.toThrow("produced no output");
await captureStartedPromise;
await vi.runAllTimersAsync();
await resultAssertion;
expect(supervisorSpawnMock).toHaveBeenCalledTimes(1);
});
it("does not retry an unclassified CLI failure with diagnostic output", async () => {
supervisorSpawnMock.mockClear();
const clearBeforeRetry = vi.fn(async () => true);
+219 -11
View File
@@ -8,6 +8,11 @@ import {
createReplyOperation,
replyRunRegistry,
} from "../auto-reply/reply/reply-run-registry.js";
import {
markMcpLoopbackToolCallFinished,
markMcpLoopbackToolCallStarted,
recordMcpLoopbackToolCallResult,
} from "../gateway/mcp-http.loopback-runtime.js";
import { onAgentEvent, resetAgentEventsForTest } from "../infra/agent-events.js";
import {
onInternalDiagnosticEvent,
@@ -18,6 +23,7 @@ import {
resetDiagnosticRunActivityForTest,
} from "../logging/diagnostic-run-activity.js";
import type { getProcessSupervisor } from "../process/supervisor/index.js";
import type { RunExit } from "../process/supervisor/types.js";
import {
makeBootstrapWarn as realMakeBootstrapWarn,
resolveBootstrapContextForRun as realResolveBootstrapContextForRun,
@@ -34,12 +40,17 @@ import {
resetClaudeLiveSessionsForTest,
runClaudeLiveSessionTurn,
} from "./cli-runner/claude-live-session.js";
import {
attachCliMessagingDeliveryEvidence,
getCliMessagingDeliveryEvidence,
} from "./cli-runner/delivery-evidence.js";
import {
buildCliEnvAuthLog,
buildCliExecLogLine,
executePreparedCliRun,
setCliRunnerExecuteTestDeps,
} from "./cli-runner/execute.js";
import { buildSystemPrompt } from "./cli-runner/helpers.js";
import { buildSystemPrompt, writeCliSystemPromptFile } from "./cli-runner/helpers.js";
import { cliBackendLog, formatCliBackendOutputDigest } from "./cli-runner/log.js";
import { setCliRunnerPrepareTestDeps } from "./cli-runner/prepare.js";
import type { PreparedCliRunContext } from "./cli-runner/types.js";
@@ -59,6 +70,7 @@ beforeEach(() => {
resetClaudeLiveSessionsForTest();
replyRunTesting.resetReplyRunRegistry();
restoreCliRunnerPrepareTestDeps();
setCliRunnerExecuteTestDeps({ writeCliSystemPromptFile });
supervisorSpawnMock.mockClear();
});
@@ -83,6 +95,7 @@ function buildPreparedCliRunContext(params: {
resolveExecutionArgs?: PreparedCliRunContext["backendResolved"]["resolveExecutionArgs"];
config?: PreparedCliRunContext["params"]["config"];
mcpConfigHash?: string;
mcpDeliveryCapture?: boolean;
skillsSnapshot?: PreparedCliRunContext["params"]["skillsSnapshot"];
thinkLevel?: PreparedCliRunContext["params"]["thinkLevel"];
executionMode?: PreparedCliRunContext["params"]["executionMode"];
@@ -161,6 +174,7 @@ function buildPreparedCliRunContext(params: {
systemPromptReport: {} as PreparedCliRunContext["systemPromptReport"],
bootstrapPromptWarningLines: [],
authEpochVersion: 2,
...(params.mcpDeliveryCapture ? { mcpDeliveryCapture: true } : {}),
};
}
@@ -700,6 +714,20 @@ describe("runCliAgent spawn path", () => {
expect(params.currentInboundEventKind).toBe("room_event");
});
it("forwards explicit message target policy through the compat wrapper", () => {
const params = buildRunClaudeCliAgentParams({
sessionId: "openclaw-session",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
prompt: "hi",
timeoutMs: 1_000,
runId: "run-claude-target-policy-wrapper",
requireExplicitMessageTarget: true,
});
expect(params.requireExplicitMessageTarget).toBe(true);
});
it("forwards static extra system prompt through the compat wrapper", () => {
const params = buildRunClaudeCliAgentParams({
sessionId: "openclaw-session",
@@ -1178,6 +1206,148 @@ describe("runCliAgent spawn path", () => {
await vi.waitFor(() => expect(preparedBackendCleanup).toHaveBeenCalledOnce());
});
it("keeps captured live prepared backend cleanup with the whole-run owner", async () => {
let stdoutListener: ((chunk: string) => void) | undefined;
let resolveExit: ((exit: RunExit) => void) | undefined;
const exited = new Promise<RunExit>((resolve) => {
resolveExit = resolve;
});
supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => {
const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void };
stdoutListener = input.onStdout;
return {
runId: "captured-live-cleanup-run",
pid: 2347,
startedAtMs: Date.now(),
stdin: {
write: vi.fn((dataValue: string, cb?: (err?: Error | null) => void) => {
stdoutListener?.(
[
JSON.stringify({
type: "system",
subtype: "init",
session_id: "captured-live-cleanup",
}),
JSON.stringify({
type: "result",
session_id: "captured-live-cleanup",
result: "ok",
}),
].join("\n") + "\n",
);
cb?.();
}),
end: vi.fn(),
},
wait: vi.fn(() => exited),
cancel: vi.fn(() =>
resolveExit?.({
reason: "manual-cancel",
exitCode: null,
exitSignal: null,
durationMs: 1,
stdout: "",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
}),
),
};
});
const preparedBackendCleanup = vi.fn(async () => {});
const context = buildPreparedCliRunContext({
provider: "claude-cli",
model: "sonnet",
runId: "run-captured-live-cleanup",
prompt: "first",
backend: {
args: ["-p", "--strict-mcp-config", "--mcp-config", "/tmp/mcp-captured.json"],
liveSession: "claude-stdio",
},
mcpConfigHash: "captured-cleanup-mcp-config",
mcpDeliveryCapture: true,
});
context.preparedBackend.cleanup = preparedBackendCleanup;
const result = await executePreparedCliRun(context);
expect(result.text).toBe("ok");
expect(context.preparedBackend.cleanup).toBe(preparedBackendCleanup);
expect(preparedBackendCleanup).not.toHaveBeenCalled();
await context.preparedBackend.cleanup?.();
expect(preparedBackendCleanup).toHaveBeenCalledOnce();
});
it("preserves completed output when system prompt cleanup fails after delivery", async () => {
const cleanupError = new Error("system prompt cleanup failed");
const logWarnSpy = vi.spyOn(cliBackendLog, "warn").mockImplementation(() => undefined);
setCliRunnerExecuteTestDeps({
writeCliSystemPromptFile: async () => ({
filePath: "/tmp/system-prompt.md",
cleanup: async () => {
throw cleanupError;
},
}),
});
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", target: "chat123", message: "done" },
});
if (!captureHandle) {
throw new Error("Expected message delivery capture");
}
recordMcpLoopbackToolCallResult({
captureHandle,
toolName: "message",
args: { action: "send", target: "chat123", message: "done" },
result: { status: "sent" },
isError: false,
});
markMcpLoopbackToolCallFinished(captureHandle);
input.onStdout?.("done");
return createManagedRun({
reason: "exit",
exitCode: 0,
exitSignal: null,
durationMs: 50,
stdout: "",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
});
});
const context = buildPreparedCliRunContext({
provider: "codex-cli",
model: "gpt-5.4",
runId: "run-cleanup-delivery-evidence",
mcpDeliveryCapture: true,
});
const result = await executePreparedCliRun(context);
setCliRunnerExecuteTestDeps({ writeCliSystemPromptFile });
expect(result.text).toBe("done");
expect(result.didSendViaMessagingTool).toBe(true);
expect(logWarnSpy).toHaveBeenCalledWith(
expect.stringContaining("outer resource cleanup failed after confirmed message delivery"),
);
});
it("wraps primitive and frozen failures to preserve delivery evidence", () => {
const evidence = { didSendViaMessagingTool: true };
const primitive = attachCliMessagingDeliveryEvidence("failed", evidence);
const frozen = attachCliMessagingDeliveryEvidence(Object.freeze(new Error("frozen")), evidence);
expect(primitive).toBeInstanceOf(Error);
expect(frozen).toBeInstanceOf(Error);
expect(getCliMessagingDeliveryEvidence(primitive)?.didSendViaMessagingTool).toBe(true);
expect(getCliMessagingDeliveryEvidence(frozen)?.didSendViaMessagingTool).toBe(true);
});
it("accepts Claude live stream-json lines larger than 256 KiB", async () => {
const largeText = "x".repeat(270 * 1024);
let stdoutListener: ((chunk: string) => void) | undefined;
@@ -2552,8 +2722,10 @@ ${JSON.stringify({
expect(requireArgAfter(spawnArg.argv, "--permission-mode")).toBe("bypassPermissions");
});
it("restarts Claude live sessions for env changes and fresh retries", async () => {
it("uses a fresh Claude live process and capture key for every captured turn", async () => {
const logWarnSpy = vi.spyOn(cliBackendLog, "warn").mockImplementation(() => undefined);
const cancels: Array<ReturnType<typeof vi.fn>> = [];
const captureKeys: string[] = [];
const turnResults = ["first-ok", "resume-ok", "env-ok", "fresh-ok"];
let turnIndex = 0;
supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => {
@@ -2561,6 +2733,30 @@ ${JSON.stringify({
const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void };
const cancel = vi.fn();
cancels.push(cancel);
let resolveExit: (() => void) | undefined;
const exited = new Promise<{
reason: "manual-cancel";
exitCode: null;
exitSignal: null;
durationMs: number;
stdout: string;
stderr: string;
timedOut: false;
noOutputTimedOut: false;
}>((resolve) => {
resolveExit = () =>
resolve({
reason: "manual-cancel",
exitCode: null,
exitSignal: null,
durationMs: 1,
stdout: "",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
});
});
cancel.mockImplementation(() => resolveExit?.());
return {
runId: `live-run-${spawnIndex}`,
pid: 2345 + spawnIndex,
@@ -2583,7 +2779,7 @@ ${JSON.stringify({
}),
end: vi.fn(),
},
wait: vi.fn(() => new Promise(() => {})),
wait: vi.fn(() => exited),
cancel,
};
});
@@ -2596,6 +2792,7 @@ ${JSON.stringify({
liveSession: "claude-stdio",
resumeArgs: ["-p", "--output-format", "stream-json", "--resume", "{sessionId}"],
},
mcpDeliveryCapture: true,
});
const result = await runClaudeLiveSessionTurn({
context,
@@ -2613,7 +2810,12 @@ ${JSON.stringify({
getRecord: vi.fn(),
}),
onAssistantDelta: () => {},
cleanup: async () => {},
onMcpCaptureReady: (captureKey) => captureKeys.push(captureKey),
cleanup: async () => {
if (runId === "run-live-resume") {
throw new Error("captured cleanup failed");
}
},
});
return result.output.text;
};
@@ -2626,14 +2828,17 @@ ${JSON.stringify({
await expect(
runTurn("run-live-resume", resumeArgs, { ANTHROPIC_BASE_URL: "https://one.example" }),
).resolves.toBe("resume-ok");
expect(supervisorSpawnMock).toHaveBeenCalledTimes(1);
expect(cancels[0]).not.toHaveBeenCalled();
expect(supervisorSpawnMock).toHaveBeenCalledTimes(2);
expect(cancels[0]).toHaveBeenCalledWith("manual-cancel");
expect(cancels[1]).toHaveBeenCalledWith("manual-cancel");
expect(captureKeys[1]).not.toBe(captureKeys[0]);
await expect(
runTurn("run-live-env-change", resumeArgs, { ANTHROPIC_BASE_URL: "https://two.example" }),
).resolves.toBe("env-ok");
expect(supervisorSpawnMock).toHaveBeenCalledTimes(2);
expect(cancels[0]).toHaveBeenCalledWith("manual-cancel");
expect(supervisorSpawnMock).toHaveBeenCalledTimes(3);
expect(cancels[2]).toHaveBeenCalledWith("manual-cancel");
expect(captureKeys[2]).not.toBe(captureKeys[1]);
await expect(
runTurn("run-live-fresh-retry", freshArgs, {
@@ -2641,9 +2846,12 @@ ${JSON.stringify({
}),
).resolves.toBe("fresh-ok");
expect(supervisorSpawnMock).toHaveBeenCalledTimes(3);
expect(cancels[1]).toHaveBeenCalledWith("manual-cancel");
expect(cancels[2]).not.toHaveBeenCalled();
expect(supervisorSpawnMock).toHaveBeenCalledTimes(4);
expect(cancels[3]).toHaveBeenCalledWith("manual-cancel");
expect(captureKeys[3]).not.toBe(captureKeys[2]);
expect(logWarnSpy).toHaveBeenCalledWith(
expect.stringContaining("Claude live session cleanup failed: captured cleanup failed"),
);
});
it("ignores non-JSON stdout lines from Claude live sessions", async () => {
+309 -62
View File
@@ -14,6 +14,11 @@ import { createSubsystemLogger } from "../logging/subsystem.js";
import { buildAgentHookContextChannelFields } from "../plugins/hook-agent-context.js";
import { resolveBlockMessage } from "../plugins/hook-decision-types.js";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import type { CliOutput } from "./cli-output.js";
import {
attachCliMessagingDeliveryEvidence,
getCliMessagingDeliveryEvidence,
} from "./cli-runner/delivery-evidence.js";
import { cliBackendLog, formatCliBackendOutputDigest } from "./cli-runner/log.js";
import {
loadCliSessionContextEngineMessages,
@@ -23,6 +28,7 @@ import type { PreparedCliRunContext, RunCliAgentParams } from "./cli-runner/type
import { claudeCliSessionTranscriptHasContent as claudeCliSessionTranscriptHasContentImpl } from "./command/attempt-execution.helpers.js";
import { classifyFailoverReason, isFailoverErrorMessage } from "./embedded-agent-helpers.js";
import type { EmbeddedAgentRunResult } from "./embedded-agent-runner.js";
import { buildEmbeddedRunPayloads } from "./embedded-agent-runner/run/payloads.js";
import { FailoverError, isFailoverError, resolveFailoverStatus } from "./failover-error.js";
import {
awaitAgentEndSideEffects,
@@ -420,19 +426,46 @@ async function runCliAgentInternal(params: RunCliAgentParams): Promise<EmbeddedA
}
const { prepareCliRunContext } = await import("./cli-runner/prepare.runtime.js");
const context = await prepareCliRunContext(params);
let result: EmbeddedAgentRunResult | undefined;
let runError: unknown;
try {
return await runPreparedCliAgent(context);
} finally {
if (params.cleanupCliLiveSessionOnRunEnd === true) {
result = await runPreparedCliAgent(context);
} catch (error) {
runError = error;
}
let cleanupError: unknown;
const recordCleanupError = (error: unknown) => {
cleanupError ??= error;
};
if (params.cleanupCliLiveSessionOnRunEnd === true) {
try {
const { closeClaudeLiveSessionForContext } =
await import("./cli-runner/claude-live-session.js");
await closeClaudeLiveSessionForContext(context);
}
if (params.cleanupBundleMcpOnRunEnd === true) {
const { closeMcpLoopbackServer } = await import("../gateway/mcp-http.js");
await closeMcpLoopbackServer();
} catch (error) {
recordCleanupError(error);
}
}
if (params.cleanupBundleMcpOnRunEnd === true) {
try {
const { closeMcpLoopbackServer } = await import("../gateway/mcp-http.js");
await closeMcpLoopbackServer();
} catch (error) {
recordCleanupError(error);
}
}
if (cleanupError) {
if (runError || result?.didSendViaMessagingTool === true) {
log.warn(`cli run cleanup failed after completion: ${formatErrorMessage(cleanupError)}`);
} else {
runError =
cleanupError instanceof Error ? cleanupError : new Error(formatErrorMessage(cleanupError));
}
}
if (runError) {
throw runError instanceof Error ? runError : new Error(formatErrorMessage(runError));
}
return result as EmbeddedAgentRunResult;
}
/** Runs an already-prepared CLI agent context through hooks and execution. */
@@ -557,6 +590,115 @@ export async function runPreparedCliAgent(
},
});
let deliveredMessagingSideEffect = false;
const buildCliSourceReplyMirrorPayloads = (
evidence: Pick<
CliOutput,
| "didSendViaMessagingTool"
| "didDeliverSourceReplyViaMessageTool"
| "messagingToolSourceReplyPayloads"
>,
): ReplyPayload[] => {
return buildEmbeddedRunPayloads({
assistantTexts: [],
toolMetas: [],
lastAssistant: undefined,
inlineToolResultsAllowed: false,
sessionKey: params.sessionKey ?? "",
provider: params.provider,
model: context.modelId,
didSendViaMessagingTool: evidence.didSendViaMessagingTool,
didDeliverSourceReplyViaMessageTool: evidence.didDeliverSourceReplyViaMessageTool,
messagingToolSourceReplyPayloads: evidence.messagingToolSourceReplyPayloads,
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
agentId: params.agentId,
runId: params.runId,
});
};
const resolveCliSourceReplyMirror = (
evidence: Pick<
CliOutput,
| "didSendViaMessagingTool"
| "didDeliverSourceReplyViaMessageTool"
| "messagingToolSourceReplyPayloads"
>,
) => {
const payloads = buildCliSourceReplyMirrorPayloads(evidence);
const delivered =
payloads.length > 0 ||
(params.sourceReplyDeliveryMode === "message_tool_only" &&
evidence.didDeliverSourceReplyViaMessageTool === true);
const visibleText =
payloads
.map((payload) => payload.text?.trim() ?? "")
.filter(Boolean)
.join("\n\n") || undefined;
return { payloads, delivered, visibleText };
};
const buildDeliveredFailureResult = (
error: unknown,
evidence: NonNullable<ReturnType<typeof getCliMessagingDeliveryEvidence>>,
): EmbeddedAgentRunResult => {
const message = formatErrorMessage(error);
const { payloads } = resolveCliSourceReplyMirror(evidence);
deliveredMessagingSideEffect = true;
return {
...(payloads.length > 0 ? { payloads } : {}),
meta: {
durationMs: Date.now() - context.started,
systemPromptReport: context.systemPromptReport,
stopReason: "error",
executionTrace: {
winnerProvider: params.provider,
winnerModel: context.modelId,
attempts: [
{
provider: params.provider,
model: context.modelId,
result: "error",
reason: message,
},
],
fallbackUsed: false,
runner: "cli",
},
requestShaping: {
...(params.thinkLevel ? { thinking: params.thinkLevel } : {}),
...(context.effectiveAuthProfileId ? { authMode: "auth-profile" } : {}),
},
completion: {
finishReason: "error",
stopReason: "error",
refusal: false,
},
agentMeta: {
sessionId: "",
provider: params.provider,
model: context.modelId,
...(context.reusableCliSession.sessionId ? { clearCliSessionBinding: true } : {}),
},
},
didSendViaMessagingTool: true,
...(evidence.didDeliverSourceReplyViaMessageTool
? { didDeliverSourceReplyViaMessageTool: true }
: {}),
...(evidence.messagingToolSentTexts?.length
? { messagingToolSentTexts: evidence.messagingToolSentTexts }
: {}),
...(evidence.messagingToolSentMediaUrls?.length
? { messagingToolSentMediaUrls: evidence.messagingToolSentMediaUrls }
: {}),
...(evidence.messagingToolSentTargets?.length
? { messagingToolSentTargets: evidence.messagingToolSentTargets }
: {}),
...(evidence.messagingToolSourceReplyPayloads?.length
? { messagingToolSourceReplyPayloads: evidence.messagingToolSourceReplyPayloads }
: {}),
};
};
const persistBlockedBeforeAgentRun = async (block: {
message: string;
pluginId: string;
@@ -618,15 +760,25 @@ export async function runPreparedCliAgent(
},
};
const output = await executePreparedCliRun(attemptContext, cliSessionIdToUse);
const assistantText = output.text.trim();
if (!assistantText && params.allowEmptyAssistantReplyAsSilent !== true) {
throw new FailoverError("CLI backend returned an empty response.", {
reason: "empty_response",
provider: params.provider,
model: context.modelId,
sessionId: params.sessionId,
lane: params.lane,
});
const sourceReplyMirror = resolveCliSourceReplyMirror(output);
const assistantText = sourceReplyMirror.delivered
? (sourceReplyMirror.visibleText ?? "")
: output.text.trim();
if (
!assistantText &&
!output.didSendViaMessagingTool &&
params.allowEmptyAssistantReplyAsSilent !== true
) {
throw attachCliMessagingDeliveryEvidence(
new FailoverError("CLI backend returned an empty response.", {
reason: "empty_response",
provider: params.provider,
model: context.modelId,
sessionId: params.sessionId,
lane: params.lane,
}),
output,
);
}
const assistantTexts = assistantText ? [assistantText] : [];
const lastAssistant =
@@ -663,7 +815,12 @@ export async function runPreparedCliAgent(
hookRunner,
});
}
return { output, assistantText, lastAssistant };
return {
output,
assistantText,
lastAssistant,
sourceReplyWasDelivered: sourceReplyMirror.delivered,
};
};
const buildCliRunResult = (resultParams: {
@@ -674,15 +831,27 @@ export async function runPreparedCliAgent(
}): EmbeddedAgentRunResult => {
const text = resultParams.output.text?.trim();
const rawText = resultParams.output.rawText?.trim();
const payloads = text
? [
resultParams.assistantTranscriptOwned
? setReplyPayloadMetadata({ text }, { assistantTranscriptOwned: true })
: { text },
]
: params.allowEmptyAssistantReplyAsSilent === true
? [{ text: SILENT_REPLY_TOKEN }]
: undefined;
const sourceReplyMirror = resolveCliSourceReplyMirror(resultParams.output);
const finalAssistantVisibleText = sourceReplyMirror.delivered
? sourceReplyMirror.visibleText
: text;
const payloads =
sourceReplyMirror.payloads.length > 0
? sourceReplyMirror.payloads
: sourceReplyMirror.delivered
? undefined
: text
? [
resultParams.assistantTranscriptOwned
? setReplyPayloadMetadata({ text }, { assistantTranscriptOwned: true })
: { text },
]
: params.allowEmptyAssistantReplyAsSilent === true
? [{ text: SILENT_REPLY_TOKEN }]
: undefined;
if (resultParams.output.didSendViaMessagingTool) {
deliveredMessagingSideEffect = true;
}
const unflushedCliSessionId =
resultParams.effectiveCliSessionId && resultParams.bindingFlushOk === false
? resultParams.effectiveCliSessionId
@@ -701,9 +870,9 @@ export async function runPreparedCliAgent(
...(resultParams.output.finalPromptText
? { finalPromptText: resultParams.output.finalPromptText }
: {}),
...(text || rawText
...(finalAssistantVisibleText || rawText
? {
...(text ? { finalAssistantVisibleText: text } : {}),
...(finalAssistantVisibleText ? { finalAssistantVisibleText } : {}),
...(rawText ? { finalAssistantRawText: rawText } : {}),
}
: {}),
@@ -748,6 +917,9 @@ export async function runPreparedCliAgent(
...(context.extraSystemPromptHash
? { extraSystemPromptHash: context.extraSystemPromptHash }
: {}),
...(context.messageToolPolicyHash
? { messageToolPolicyHash: context.messageToolPolicyHash }
: {}),
...(context.promptToolNamesHash
? { promptToolNamesHash: context.promptToolNamesHash }
: {}),
@@ -764,10 +936,26 @@ export async function runPreparedCliAgent(
...(unflushedCliSessionId ? { clearCliSessionBinding: true } : {}),
},
},
...(resultParams.output.didSendViaMessagingTool ? { didSendViaMessagingTool: true } : {}),
...(resultParams.output.didDeliverSourceReplyViaMessageTool
? { didDeliverSourceReplyViaMessageTool: true }
: {}),
...(resultParams.output.messagingToolSentTexts?.length
? { messagingToolSentTexts: resultParams.output.messagingToolSentTexts }
: {}),
...(resultParams.output.messagingToolSentMediaUrls?.length
? { messagingToolSentMediaUrls: resultParams.output.messagingToolSentMediaUrls }
: {}),
...(resultParams.output.messagingToolSentTargets?.length
? { messagingToolSentTargets: resultParams.output.messagingToolSentTargets }
: {}),
...(resultParams.output.messagingToolSourceReplyPayloads?.length
? { messagingToolSourceReplyPayloads: resultParams.output.messagingToolSourceReplyPayloads }
: {}),
};
};
try {
const executeRun = async (): Promise<EmbeddedAgentRunResult> => {
await bootstrapHarnessContextEngine({
hadSessionFile: context.hadSessionFile,
contextEngine: context.contextEngine,
@@ -790,41 +978,61 @@ export async function runPreparedCliAgent(
result: Awaited<ReturnType<typeof executeCliAttempt>>,
fallbackCliSessionId?: string,
) => {
const { output, lastAssistant } = result;
const assistantText = output.text.trim();
const effectiveCliSessionId = output.sessionId ?? fallbackCliSessionId;
await finalizeCliContextEngineTurn({
context,
historyMessages: context.contextEngine ? contextEngineHistoryMessages : historyMessages,
assistantText,
output,
});
const assistantTranscriptOwned = await persistCliAssistantTranscript({
runParams: params,
text: assistantText,
modelId: context.modelId,
usage: output.usage,
});
const bindingFlushOk = await isCliBindingFlushed(
effectiveCliSessionId,
params.provider,
context.cwd ?? context.workspaceDir,
);
const { output, assistantText, lastAssistant, sourceReplyWasDelivered } = result;
try {
const effectiveCliSessionId = output.sessionId ?? fallbackCliSessionId;
await finalizeCliContextEngineTurn({
context,
historyMessages: context.contextEngine ? contextEngineHistoryMessages : historyMessages,
assistantText,
output,
});
const assistantTranscriptOwned = await persistCliAssistantTranscript({
runParams: params,
// Dispatch owns source-reply transcript mirrors and their idempotency keys.
// Persisting them here would duplicate the same visible assistant reply.
text: sourceReplyWasDelivered ? "" : assistantText,
modelId: context.modelId,
usage: output.usage,
});
const bindingFlushOk = await isCliBindingFlushed(
effectiveCliSessionId,
params.provider,
context.cwd ?? context.workspaceDir,
);
await runCliAgentEndHook(params, {
event: {
messages: buildAgentEndMessages(lastAssistant),
success: true,
durationMs: Date.now() - context.started,
},
ctx: hookContext,
hookRunner,
});
return buildCliRunResult({
output,
effectiveCliSessionId,
bindingFlushOk,
assistantTranscriptOwned,
});
} catch (error) {
throw attachCliMessagingDeliveryEvidence(error, output);
}
};
const finishDeliveredFailure = async (
error: unknown,
): Promise<EmbeddedAgentRunResult | undefined> => {
const evidence = getCliMessagingDeliveryEvidence(error);
if (!evidence) {
return undefined;
}
await runCliAgentEndHook(params, {
event: {
messages: buildAgentEndMessages(lastAssistant),
success: true,
durationMs: Date.now() - context.started,
},
event: buildFailedAgentEndEvent(formatErrorMessage(error)),
ctx: hookContext,
hookRunner,
});
return buildCliRunResult({
output,
effectiveCliSessionId,
bindingFlushOk,
assistantTranscriptOwned,
});
return buildDeliveredFailureResult(error, evidence);
};
if (hasBeforeAgentRunHooks && hookRunner) {
@@ -894,6 +1102,10 @@ export async function runPreparedCliAgent(
context.reusableCliSession.sessionId,
);
} catch (err) {
const deliveredFailure = await finishDeliveredFailure(err);
if (deliveredFailure) {
return deliveredFailure;
}
if (isFailoverError(err)) {
const retryableSessionId = context.reusableCliSession.sessionId;
if (
@@ -924,6 +1136,10 @@ export async function runPreparedCliAgent(
);
return await finishCliAttempt(await executeCliAttempt(undefined, retryTimeoutMs));
} catch (retryErr) {
const deliveredRetryFailure = await finishDeliveredFailure(retryErr);
if (deliveredRetryFailure) {
return deliveredRetryFailure;
}
const retryMessage = formatErrorMessage(retryErr);
await runCliAgentEndHook(params, {
event: buildFailedAgentEndEvent(retryMessage),
@@ -948,9 +1164,39 @@ export async function runPreparedCliAgent(
});
return toCliRunFailure(err);
}
} finally {
await context.preparedBackend.cleanup?.();
};
let runResult: EmbeddedAgentRunResult | undefined;
let runError: unknown;
let runFailed = false;
try {
runResult = await executeRun();
} catch (error) {
runFailed = true;
runError = error;
}
try {
await context.preparedBackend.cleanup?.();
} catch (cleanupError) {
if (!deliveredMessagingSideEffect) {
if (runFailed) {
cliBackendLog.warn(
`CLI run also failed before backend cleanup: ${formatErrorMessage(runError)}`,
);
}
throw cleanupError;
}
cliBackendLog.warn(
`CLI backend cleanup failed after confirmed message delivery: ${formatErrorMessage(cleanupError)}`,
);
}
if (runFailed) {
throw runError;
}
if (!runResult) {
throw new Error("CLI run completed without a result");
}
return runResult;
}
/** Legacy Claude-specific wrapper params for the generic CLI runner. */
@@ -985,6 +1231,7 @@ export function buildRunClaudeCliAgentParams(params: RunClaudeCliAgentParams): R
extraSystemPrompt: params.extraSystemPrompt,
inputProvenance: params.inputProvenance,
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
requireExplicitMessageTarget: params.requireExplicitMessageTarget,
silentReplyPromptMode: params.silentReplyPromptMode,
extraSystemPromptStatic: params.extraSystemPromptStatic,
ownerNumbers: params.ownerNumbers,
@@ -101,3 +101,46 @@ export async function writeGeminiSystemSettings(
},
};
}
/** Writes per-attempt Gemini settings with the active loopback capture token. */
export async function writeGeminiMcpCaptureSettings(params: {
inheritedEnv: Record<string, string> | undefined;
captureKey: string;
}): Promise<{ env: Record<string, string>; cleanup: () => Promise<void> }> {
const existingSettingsPath = params.inheritedEnv?.GEMINI_CLI_SYSTEM_SETTINGS_PATH;
if (!existingSettingsPath) {
throw new Error("Gemini MCP capture requires prepared system settings");
}
const settings = await readJsonObject(existingSettingsPath);
const mcpServers = isRecord(settings.mcpServers) ? settings.mcpServers : {};
const openclaw = isRecord(mcpServers.openclaw) ? mcpServers.openclaw : {};
const headers = normalizeStringRecord(openclaw.headers) ?? {};
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gemini-mcp-attempt-"));
const settingsPath = path.join(tempDir, "settings.json");
await writeJson(
settingsPath,
{
...settings,
mcpServers: {
...mcpServers,
openclaw: {
...openclaw,
headers: {
...headers,
"x-openclaw-cli-capture-key": params.captureKey,
},
},
},
},
{ trailingNewline: true },
);
return {
env: {
...params.inheritedEnv,
GEMINI_CLI_SYSTEM_SETTINGS_PATH: settingsPath,
},
cleanup: async () => {
await fs.rm(tempDir, { recursive: true, force: true });
},
};
}
@@ -22,6 +22,7 @@ describe("prepareCliBundleMcpConfig codex", () => {
headers: {
Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}",
"x-session-key": "${OPENCLAW_MCP_SESSION_KEY}",
"x-openclaw-cli-capture-key": "${OPENCLAW_MCP_CLI_CAPTURE_KEY}",
},
},
},
@@ -34,14 +35,14 @@ describe("prepareCliBundleMcpConfig codex", () => {
"exec",
"--json",
"-c",
'mcp_servers={ openclaw = { url = "http://127.0.0.1:23119/mcp", default_tools_approval_mode = "approve", bearer_token_env_var = "OPENCLAW_MCP_TOKEN", env_http_headers = { x-session-key = "OPENCLAW_MCP_SESSION_KEY" } } }',
'mcp_servers={ openclaw = { url = "http://127.0.0.1:23119/mcp", default_tools_approval_mode = "approve", bearer_token_env_var = "OPENCLAW_MCP_TOKEN", env_http_headers = { x-session-key = "OPENCLAW_MCP_SESSION_KEY", x-openclaw-cli-capture-key = "OPENCLAW_MCP_CLI_CAPTURE_KEY" } } }',
]);
expect(prepared.backend.resumeArgs).toEqual([
"exec",
"resume",
"{sessionId}",
"-c",
'mcp_servers={ openclaw = { url = "http://127.0.0.1:23119/mcp", default_tools_approval_mode = "approve", bearer_token_env_var = "OPENCLAW_MCP_TOKEN", env_http_headers = { x-session-key = "OPENCLAW_MCP_SESSION_KEY" } } }',
'mcp_servers={ openclaw = { url = "http://127.0.0.1:23119/mcp", default_tools_approval_mode = "approve", bearer_token_env_var = "OPENCLAW_MCP_TOKEN", env_http_headers = { x-session-key = "OPENCLAW_MCP_SESSION_KEY", x-openclaw-cli-capture-key = "OPENCLAW_MCP_CLI_CAPTURE_KEY" } } }',
]);
expect(prepared.cleanup).toBeUndefined();
});
@@ -1,7 +1,7 @@
/** Tests Gemini CLI bundle-MCP system settings generation. */
import fs from "node:fs/promises";
import { describe, expect, it } from "vitest";
import { prepareCliBundleMcpConfig } from "./bundle-mcp.js";
import { prepareCliBundleMcpCaptureAttempt, prepareCliBundleMcpConfig } from "./bundle-mcp.js";
describe("prepareCliBundleMcpConfig gemini", () => {
it("writes Gemini system settings for bundle MCP servers", async () => {
@@ -95,4 +95,51 @@ describe("prepareCliBundleMcpConfig gemini", () => {
await prepared.cleanup?.();
});
it("writes a unique capture token into per-attempt Gemini settings", async () => {
const prepared = await prepareCliBundleMcpConfig({
enabled: true,
mode: "gemini-system-settings",
backend: {
command: "gemini",
args: ["--prompt", "{prompt}"],
},
workspaceDir: "/tmp/openclaw-bundle-mcp-gemini",
config: { plugins: { enabled: false } },
additionalConfig: {
mcpServers: {
openclaw: {
type: "http",
url: "http://127.0.0.1:23119/mcp",
headers: {
"x-openclaw-cli-capture-key": "${OPENCLAW_MCP_CLI_CAPTURE_KEY}",
},
},
},
},
env: {
OPENCLAW_MCP_CLI_CAPTURE_KEY: "",
},
});
const attempt = await prepareCliBundleMcpCaptureAttempt({
mode: "gemini-system-settings",
env: prepared.env,
captureKey: "attempt-123",
});
try {
const raw = JSON.parse(
await fs.readFile(attempt.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH as string, "utf-8"),
) as {
mcpServers?: Record<string, { headers?: Record<string, string> }>;
};
expect(raw.mcpServers?.openclaw?.headers?.["x-openclaw-cli-capture-key"]).toBe("attempt-123");
expect(attempt.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH).not.toBe(
prepared.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH,
);
} finally {
await attempt.cleanup?.();
await prepared.cleanup?.();
}
});
});
+24 -1
View File
@@ -15,7 +15,7 @@ import { loadMergedBundleMcpConfig, toCliBundleMcpServerConfig } from "../bundle
import { isRecord } from "./bundle-mcp-adapter-shared.js";
import { findClaudeMcpConfigPath, injectClaudeMcpConfigArgs } from "./bundle-mcp-claude.js";
import { injectCodexMcpConfigArgs } from "./bundle-mcp-codex.js";
import { writeGeminiSystemSettings } from "./bundle-mcp-gemini.js";
import { writeGeminiMcpCaptureSettings, writeGeminiSystemSettings } from "./bundle-mcp-gemini.js";
type PreparedCliBundleMcpConfig = {
backend: CliBackendConfig;
@@ -197,3 +197,26 @@ export async function prepareCliBundleMcpConfig(params: {
env: params.env,
});
}
/** Prepares a per-attempt capture token without changing resume compatibility hashes. */
export async function prepareCliBundleMcpCaptureAttempt(params: {
mode?: CliBundleMcpMode;
env?: Record<string, string>;
captureKey?: string;
}): Promise<{ env?: Record<string, string>; cleanup?: () => Promise<void> }> {
if (!params.captureKey) {
return { env: params.env };
}
if (resolveBundleMcpMode(params.mode) === "gemini-system-settings") {
return await writeGeminiMcpCaptureSettings({
inheritedEnv: params.env,
captureKey: params.captureKey,
});
}
return {
env: {
...params.env,
OPENCLAW_MCP_CLI_CAPTURE_KEY: params.captureKey,
},
};
}
+41 -12
View File
@@ -11,6 +11,7 @@ import {
type DiagnosticToolExecutionErrorEvent,
type DiagnosticToolExecutionCompletedEvent,
} from "../../infra/diagnostic-events.js";
import { formatErrorMessage } from "../../infra/errors.js";
import {
loadExecApprovals,
maxAsk,
@@ -71,8 +72,9 @@ type ClaudeLiveSession = {
drainingAbortedTurn: boolean;
idleTimer: NodeJS.Timeout | null;
cleanup: () => Promise<void>;
cleanupDone: boolean;
cleanupPromise: Promise<void> | null;
closing: boolean;
mcpCaptureKey?: string;
};
type ClaudeLiveRunResult = {
output: CliOutput;
@@ -163,6 +165,13 @@ export async function closeClaudeLiveSessionForContext(
liveSessionCreates.delete(key);
}
/** Close a tainted live process so its replacement gets a fresh MCP capture key. */
export async function rotateClaudeLiveMcpCaptureKeyForContext(
context: PreparedCliRunContext,
): Promise<void> {
await closeClaudeLiveSessionForContext(context);
}
/** Returns whether a prepared backend context is eligible for Claude live stdio reuse. */
export function shouldUseClaudeLiveSession(context: PreparedCliRunContext): boolean {
return (
@@ -418,12 +427,13 @@ function abortTurn(session: ClaudeLiveSession, error: Error): void {
closeLiveSession(session, "abort", error);
}
function cleanupLiveSession(session: ClaudeLiveSession): void {
if (session.cleanupDone) {
return;
function cleanupLiveSession(session: ClaudeLiveSession): Promise<void> {
if (!session.cleanupPromise) {
session.cleanupPromise = session.cleanup().catch((error: unknown) => {
cliBackendLog.warn(`Claude live session cleanup failed: ${formatErrorMessage(error)}`);
});
}
session.cleanupDone = true;
void session.cleanup();
return session.cleanupPromise;
}
function closeLiveSession(
@@ -450,7 +460,7 @@ function closeLiveSession(
failTurn(session, error);
}
session.managedRun.cancel("manual-cancel");
cleanupLiveSession(session);
void cleanupLiveSession(session);
}
function scheduleIdleClose(session: ClaudeLiveSession): void {
@@ -976,7 +986,7 @@ function handleClaudeExit(session: ClaudeLiveSession, exitCode: number | null):
if (liveSessions.get(session.key) === session) {
liveSessions.delete(session.key);
}
cleanupLiveSession(session);
void cleanupLiveSession(session);
if (!session.currentTurn) {
return;
}
@@ -1047,6 +1057,7 @@ async function createClaudeLiveSession(params: {
env: Record<string, string>;
fingerprint: string;
key: string;
mcpCaptureKey?: string;
noOutputTimeoutMs: number;
supervisor: ProcessSupervisor;
cleanup: () => Promise<void>;
@@ -1060,7 +1071,9 @@ async function createClaudeLiveSession(params: {
mode: "child",
argv: params.argv,
cwd: params.context.cwd ?? params.context.workspaceDir,
env: params.env,
env: params.mcpCaptureKey
? { ...params.env, OPENCLAW_MCP_CLI_CAPTURE_KEY: params.mcpCaptureKey }
: params.env,
stdinMode: "pipe-open",
captureOutput: false,
onStdout: (chunk) => {
@@ -1097,8 +1110,9 @@ async function createClaudeLiveSession(params: {
drainingAbortedTurn: false,
idleTimer: null,
cleanup: params.cleanup,
cleanupDone: false,
cleanupPromise: null,
closing: false,
mcpCaptureKey: params.mcpCaptureKey,
};
void managedRun.wait().then(
(exit) => handleClaudeExit(session, exit.exitCode),
@@ -1220,6 +1234,7 @@ export async function runClaudeLiveSessionTurn(params: {
onToolUseStart?: (delta: CliToolUseStartDelta) => void;
onToolResult?: (delta: CliToolResultDelta) => void;
onCommentaryText?: (text: string) => void;
onMcpCaptureReady?: (captureKey: string) => void;
cleanup: () => Promise<void>;
}): Promise<ClaudeLiveRunResult> {
const key = buildClaudeLiveKey(params.context);
@@ -1292,6 +1307,7 @@ export async function runClaudeLiveSessionTurn(params: {
env: params.env,
fingerprint,
key,
mcpCaptureKey: params.context.mcpDeliveryCapture ? crypto.randomUUID() : undefined,
noOutputTimeoutMs: params.noOutputTimeoutMs,
supervisor: params.getProcessSupervisor(),
cleanup,
@@ -1327,6 +1343,9 @@ export async function runClaudeLiveSessionTurn(params: {
throw new Error("Claude CLI live session is already handling a turn");
}
const liveSession = session;
if (liveSession.mcpCaptureKey) {
params.onMcpCaptureReady?.(liveSession.mcpCaptureKey);
}
liveSession.noOutputTimeoutMs = params.noOutputTimeoutMs;
liveSession.stderr = "";
@@ -1371,8 +1390,18 @@ export async function runClaudeLiveSessionTurn(params: {
} finally {
replyBackendCompleted = true;
params.context.params.abortSignal?.removeEventListener("abort", abort);
if (replyBackendHandle) {
params.context.params.replyOperation?.detachBackend(replyBackendHandle);
try {
if (replyBackendHandle) {
params.context.params.replyOperation?.detachBackend(replyBackendHandle);
}
} finally {
if (liveSession.mcpCaptureKey) {
// The capture key is process environment, so a captured turn must end its
// process before the attempt releases that key to avoid cross-turn sends.
closeLiveSession(liveSession, "restart");
await waitForManagedRunExit(liveSession.managedRun);
await cleanupLiveSession(liveSession);
}
}
}
}
@@ -0,0 +1,79 @@
/**
* Carries confirmed CLI messaging delivery across failed execution/finalization paths.
*/
import type { CliOutput } from "../cli-output.js";
const CLI_MESSAGING_DELIVERY_EVIDENCE_KEY = "cliMessagingDeliveryEvidence";
type CliMessagingDeliveryEvidence = Pick<
CliOutput,
| "didSendViaMessagingTool"
| "didDeliverSourceReplyViaMessageTool"
| "messagingToolSentTexts"
| "messagingToolSentMediaUrls"
| "messagingToolSentTargets"
| "messagingToolSourceReplyPayloads"
>;
function snapshotCliMessagingDeliveryEvidence(
output: CliMessagingDeliveryEvidence,
): CliMessagingDeliveryEvidence | undefined {
if (output.didSendViaMessagingTool !== true) {
return undefined;
}
return {
didSendViaMessagingTool: true,
...(output.didDeliverSourceReplyViaMessageTool
? { didDeliverSourceReplyViaMessageTool: true }
: {}),
...(output.messagingToolSentTexts?.length
? { messagingToolSentTexts: output.messagingToolSentTexts.slice() }
: {}),
...(output.messagingToolSentMediaUrls?.length
? { messagingToolSentMediaUrls: output.messagingToolSentMediaUrls.slice() }
: {}),
...(output.messagingToolSentTargets?.length
? { messagingToolSentTargets: output.messagingToolSentTargets.slice() }
: {}),
...(output.messagingToolSourceReplyPayloads?.length
? { messagingToolSourceReplyPayloads: output.messagingToolSourceReplyPayloads.slice() }
: {}),
};
}
/** Attaches confirmed delivery evidence so caller retries cannot duplicate a visible send. */
export function attachCliMessagingDeliveryEvidence(
error: unknown,
output: CliMessagingDeliveryEvidence,
): unknown {
const evidence = snapshotCliMessagingDeliveryEvidence(output);
if (!evidence) {
return error;
}
if (error && typeof error === "object") {
try {
Object.assign(error, { [CLI_MESSAGING_DELIVERY_EVIDENCE_KEY]: evidence });
return error;
} catch {
// Frozen and non-extensible failures need a mutable wrapper.
}
}
const wrapped = new Error(error instanceof Error ? error.message : String(error), {
cause: error,
});
Object.assign(wrapped, { [CLI_MESSAGING_DELIVERY_EVIDENCE_KEY]: evidence });
return wrapped;
}
/** Reads confirmed delivery evidence from a failed CLI attempt. */
export function getCliMessagingDeliveryEvidence(
error: unknown,
): CliMessagingDeliveryEvidence | undefined {
if (!error || typeof error !== "object") {
return undefined;
}
const evidence = (error as Record<string, unknown>)[CLI_MESSAGING_DELIVERY_EVIDENCE_KEY];
return evidence && typeof evidence === "object"
? snapshotCliMessagingDeliveryEvidence(evidence as CliMessagingDeliveryEvidence)
: undefined;
}
@@ -1,15 +1,42 @@
// Covers CLI execution paths where the process supervisor keeps stdout capture
// disabled and the runner must parse streamed chunks without relying on tails.
import { beforeEach, describe, expect, it } from "vitest";
import {
markMcpLoopbackToolCallFinished,
markMcpLoopbackToolCallStarted,
recordMcpLoopbackToolCallResult as recordMcpLoopbackToolCallResultForHandle,
} from "../../gateway/mcp-http.loopback-runtime.js";
import { onAgentEvent, resetAgentEventsForTest } from "../../infra/agent-events.js";
import type { getProcessSupervisor } from "../../process/supervisor/index.js";
import { createManagedRun, supervisorSpawnMock } from "../cli-runner.test-support.js";
import { getCliMessagingDeliveryEvidence } from "./delivery-evidence.js";
import { executePreparedCliRun } from "./execute.js";
import type { PreparedCliRunContext } from "./types.js";
type ProcessSupervisor = ReturnType<typeof getProcessSupervisor>;
type SupervisorSpawnInput = Parameters<ProcessSupervisor["spawn"]>[0];
function recordMcpLoopbackToolCallResult(params: {
captureKey: string;
toolName: string;
args: Record<string, unknown>;
result?: unknown;
isError: boolean;
}): void {
const captureHandle = markMcpLoopbackToolCallStarted(params);
if (!captureHandle) {
return;
}
recordMcpLoopbackToolCallResultForHandle({
captureHandle,
toolName: params.toolName,
args: params.args,
result: params.result,
isError: params.isError,
});
markMcpLoopbackToolCallFinished(captureHandle);
}
function buildPreparedCliRunContext(params: {
output: "jsonl" | "text";
provider?: string;
@@ -313,4 +340,737 @@ describe("executePreparedCliRun supervisor output capture", () => {
stop();
}
});
it("reports only confirmed message deliveries from correlated JSONL tool events", async () => {
const chunks = [
`${JSON.stringify({
type: "assistant",
message: {
role: "assistant",
content: [
{
type: "mcp_tool_use",
id: "message-send-1",
name: "mcp__openclaw__message",
input: {
action: "send",
channel: "telegram",
target: "chat123",
message: "done",
},
},
{
type: "mcp_tool_result",
tool_use_id: "message-send-1",
content: [{ type: "text", text: JSON.stringify({ result: { messageId: "msg-1" } }) }],
},
],
},
})}\n`,
`${JSON.stringify({ type: "result", session_id: "session-jsonl", result: "done" })}\n`,
];
supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => {
const input = args[0] as SupervisorSpawnInput;
recordMcpLoopbackToolCallResult({
captureKey: input.env?.OPENCLAW_MCP_CLI_CAPTURE_KEY ?? "",
toolName: "message",
args: {
action: "send",
channel: "telegram",
target: "chat123",
message: "done",
},
result: { ok: true, to: "spaces/AAA" },
isError: false,
});
for (const chunk of chunks) {
input.onStdout?.(chunk);
}
return createManagedRun({
reason: "exit",
exitCode: 0,
exitSignal: null,
durationMs: 50,
stdout: "",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
});
});
const context = buildPreparedCliRunContext({ output: "jsonl", provider: "claude-cli" });
context.mcpDeliveryCapture = true;
const result = await executePreparedCliRun(context);
expect(result.didSendViaMessagingTool).toBe(true);
expect(result.messagingToolSentTargets).toEqual([
expect.objectContaining({
tool: "message",
provider: "telegram",
to: "chat123",
text: "done",
}),
]);
});
it("captures message text aliases from correlated JSONL tool events", async () => {
const chunks = [
`${JSON.stringify({
type: "assistant",
message: {
role: "assistant",
content: [
{
type: "mcp_tool_use",
id: "message-send-text-alias",
name: "mcp__openclaw__message",
input: {
action: "send",
channel: "telegram",
target: "chat123",
text: "done",
},
},
{
type: "mcp_tool_result",
tool_use_id: "message-send-text-alias",
content: [{ type: "text", text: JSON.stringify({ status: "sent" }) }],
},
],
},
})}\n`,
`${JSON.stringify({ type: "result", session_id: "session-jsonl", result: "done" })}\n`,
];
supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => {
const input = args[0] as SupervisorSpawnInput;
for (const chunk of chunks) {
input.onStdout?.(chunk);
}
return createManagedRun({
reason: "exit",
exitCode: 0,
exitSignal: null,
durationMs: 50,
stdout: "",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
});
});
const result = await executePreparedCliRun(
buildPreparedCliRunContext({ output: "jsonl", provider: "claude-cli" }),
);
expect(result.messagingToolSentTexts).toEqual(["done"]);
expect(result.messagingToolSentTargets).toEqual([
expect.objectContaining({
tool: "message",
provider: "telegram",
to: "chat123",
text: "done",
}),
]);
});
it("bounds pending and committed JSONL message delivery evidence", async () => {
const starts = Array.from({ length: 65 }, (_, index) => ({
type: "mcp_tool_use",
id: `message-send-${index}`,
name: "mcp__openclaw__message",
input: {
action: "send",
channel: "telegram",
target: `chat${index}`,
message: "done",
},
}));
const results = starts.map((start) => ({
type: "mcp_tool_result",
tool_use_id: start.id,
content: [{ type: "text", text: JSON.stringify({ status: "sent" }) }],
}));
const chunks = [
`${JSON.stringify({
type: "assistant",
message: { role: "assistant", content: [...starts, ...results] },
})}\n`,
`${JSON.stringify({ type: "result", session_id: "session-jsonl", result: "done" })}\n`,
];
supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => {
const input = args[0] as SupervisorSpawnInput;
for (const chunk of chunks) {
input.onStdout?.(chunk);
}
return createManagedRun({
reason: "exit",
exitCode: 0,
exitSignal: null,
durationMs: 50,
stdout: "",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
});
});
const result = await executePreparedCliRun(
buildPreparedCliRunContext({ output: "jsonl", provider: "claude-cli" }),
);
expect(result.messagingToolSentTargets).toHaveLength(64);
expect(result.messagingToolSentTargets?.[0]?.to).toBe("chat1");
expect(result.messagingToolSentTargets?.at(-1)?.to).toBe("chat64");
});
it("fails closed when an unresolved JSONL message send is evicted", async () => {
const starts = Array.from({ length: 65 }, (_, index) => ({
type: "mcp_tool_use",
id: `message-send-${index}`,
name: "mcp__openclaw__message",
input: {
action: "send",
channel: "telegram",
target: `chat${index}`,
message: "done",
},
}));
const chunks = [
`${JSON.stringify({
type: "assistant",
message: {
role: "assistant",
content: [
...starts,
{
type: "mcp_tool_result",
tool_use_id: starts[0]?.id,
content: [{ type: "text", text: JSON.stringify({ status: "sent" }) }],
},
],
},
})}\n`,
];
supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => {
const input = args[0] as SupervisorSpawnInput;
for (const chunk of chunks) {
input.onStdout?.(chunk);
}
return createManagedRun({
reason: "exit",
exitCode: 1,
exitSignal: null,
durationMs: 50,
stdout: "",
stderr: "failed",
timedOut: false,
noOutputTimedOut: false,
});
});
let thrown: unknown;
try {
await executePreparedCliRun(
buildPreparedCliRunContext({ output: "jsonl", provider: "claude-cli" }),
);
} catch (error) {
thrown = error;
}
expect(getCliMessagingDeliveryEvidence(thrown)?.didSendViaMessagingTool).toBe(true);
});
it("fails closed when a JSONL message send remains unresolved after exit", async () => {
const chunk = `${JSON.stringify({
type: "assistant",
message: {
role: "assistant",
content: [
{
type: "mcp_tool_use",
id: "message-send-unresolved",
name: "mcp__openclaw__message",
input: {
action: "send",
channel: "telegram",
target: "chat123",
message: "done",
},
},
],
},
})}\n`;
supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => {
const input = args[0] as SupervisorSpawnInput;
input.onStdout?.(chunk);
return createManagedRun({
reason: "exit",
exitCode: 1,
exitSignal: null,
durationMs: 50,
stdout: "",
stderr: "failed",
timedOut: false,
noOutputTimedOut: false,
});
});
let thrown: unknown;
try {
await executePreparedCliRun(
buildPreparedCliRunContext({ output: "jsonl", provider: "claude-cli" }),
);
} catch (error) {
thrown = error;
}
expect(getCliMessagingDeliveryEvidence(thrown)?.didSendViaMessagingTool).toBe(true);
});
it("keeps an unresolved JSONL dry-run message retryable", async () => {
const chunk = `${JSON.stringify({
type: "assistant",
message: {
role: "assistant",
content: [
{
type: "mcp_tool_use",
id: "message-dry-run-unresolved",
name: "mcp__openclaw__message",
input: {
action: "send",
channel: "telegram",
target: "chat123",
message: "done",
dryRun: true,
},
},
],
},
})}\n`;
supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => {
const input = args[0] as SupervisorSpawnInput;
input.onStdout?.(chunk);
return createManagedRun({
reason: "exit",
exitCode: 1,
exitSignal: null,
durationMs: 50,
stdout: "",
stderr: "failed",
timedOut: false,
noOutputTimedOut: false,
});
});
let thrown: unknown;
try {
await executePreparedCliRun(
buildPreparedCliRunContext({ output: "jsonl", provider: "claude-cli" }),
);
} catch (error) {
thrown = error;
}
expect(getCliMessagingDeliveryEvidence(thrown)?.didSendViaMessagingTool).toBeUndefined();
});
it("fails closed for suppressed non-streaming MCP message results", async () => {
const context = buildPreparedCliRunContext({ output: "text", provider: "google-gemini-cli" });
context.mcpDeliveryCapture = true;
supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => {
const input = args[0] as SupervisorSpawnInput;
recordMcpLoopbackToolCallResult({
captureKey: input.env?.OPENCLAW_MCP_CLI_CAPTURE_KEY ?? "",
toolName: "message",
args: {
action: "send",
channel: "telegram",
target: "chat123",
message: "done",
},
result: { status: "suppressed" },
isError: false,
});
input.onStdout?.("done");
return createManagedRun({
reason: "exit",
exitCode: 0,
exitSignal: null,
durationMs: 50,
stdout: "",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
});
});
const result = await executePreparedCliRun(context);
expect(result.didSendViaMessagingTool).toBeUndefined();
expect(result.messagingToolSentTargets).toBeUndefined();
});
it("keeps mutation delivery out of sent-reply dedupe evidence", async () => {
const context = buildPreparedCliRunContext({ output: "text", provider: "google-gemini-cli" });
context.mcpDeliveryCapture = true;
supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => {
const input = args[0] as SupervisorSpawnInput;
recordMcpLoopbackToolCallResult({
captureKey: input.env?.OPENCLAW_MCP_CLI_CAPTURE_KEY ?? "",
toolName: "message",
args: {
action: "edit",
channel: "telegram",
target: "chat123",
message: "done",
},
result: { ok: true },
isError: false,
});
input.onStdout?.("done");
return createManagedRun({
reason: "exit",
exitCode: 0,
exitSignal: null,
durationMs: 50,
stdout: "",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
});
});
const result = await executePreparedCliRun(context);
expect(result.didSendViaMessagingTool).toBe(true);
expect(result.messagingToolSentTexts).toBeUndefined();
expect(result.messagingToolSentTargets).toBeUndefined();
});
it("preserves the current provider for implicit message send targets", async () => {
const context = buildPreparedCliRunContext({ output: "text", provider: "google-gemini-cli" });
context.mcpDeliveryCapture = true;
context.params.messageChannel = "slack";
context.params.currentChannelId = "C123";
context.params.currentThreadTs = "1700000000.000100";
supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => {
const input = args[0] as SupervisorSpawnInput;
recordMcpLoopbackToolCallResult({
captureKey: input.env?.OPENCLAW_MCP_CLI_CAPTURE_KEY ?? "",
toolName: "message",
args: {
action: "send",
target: "C123",
message: "done",
},
result: { status: "sent" },
isError: false,
});
input.onStdout?.("done");
return createManagedRun({
reason: "exit",
exitCode: 0,
exitSignal: null,
durationMs: 50,
stdout: "",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
});
});
const result = await executePreparedCliRun(context);
expect(result.messagingToolSentTargets).toEqual([
expect.objectContaining({
provider: "slack",
to: "C123",
}),
]);
});
it("preserves partial delivery evidence from failed MCP message calls", async () => {
const context = buildPreparedCliRunContext({ output: "text", provider: "google-gemini-cli" });
context.mcpDeliveryCapture = true;
supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => {
const input = args[0] as SupervisorSpawnInput;
recordMcpLoopbackToolCallResult({
captureKey: input.env?.OPENCLAW_MCP_CLI_CAPTURE_KEY ?? "",
toolName: "message",
args: {
action: "send",
channel: "telegram",
target: "chat123",
message: "done",
mediaUrl: "https://example.com/photo.png",
},
result: Object.assign(new Error("second chunk failed"), { sentBeforeError: true }),
isError: true,
});
input.onStdout?.("done");
return createManagedRun({
reason: "exit",
exitCode: 0,
exitSignal: null,
durationMs: 50,
stdout: "",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
});
});
const result = await executePreparedCliRun(context);
expect(result.didSendViaMessagingTool).toBe(true);
expect(result.messagingToolSentTargets).toEqual([
expect.objectContaining({
tool: "message",
provider: "telegram",
to: "chat123",
text: "done",
mediaUrls: ["https://example.com/photo.png"],
}),
]);
});
it("reports confirmed non-streaming MCP message results from the serialized capture", async () => {
const context = buildPreparedCliRunContext({ output: "text", provider: "google-gemini-cli" });
context.mcpDeliveryCapture = true;
supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => {
const input = args[0] as SupervisorSpawnInput;
recordMcpLoopbackToolCallResult({
captureKey: input.env?.OPENCLAW_MCP_CLI_CAPTURE_KEY ?? "",
toolName: "message",
args: {
action: "send",
channel: "telegram",
target: "chat123",
message: "done",
},
result: { result: { messageId: "msg-1" } },
isError: false,
});
input.onStdout?.("done");
return createManagedRun({
reason: "exit",
exitCode: 0,
exitSignal: null,
durationMs: 50,
stdout: "",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
});
});
const result = await executePreparedCliRun(context);
expect(result.didSendViaMessagingTool).toBe(true);
expect(result.messagingToolSentTargets).toEqual([
expect.objectContaining({
tool: "message",
provider: "telegram",
to: "chat123",
text: "done",
}),
]);
});
it("reports confirmed poll delivery from the serialized capture", async () => {
const context = buildPreparedCliRunContext({ output: "text", provider: "google-gemini-cli" });
context.mcpDeliveryCapture = true;
supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => {
const input = args[0] as SupervisorSpawnInput;
recordMcpLoopbackToolCallResult({
captureKey: input.env?.OPENCLAW_MCP_CLI_CAPTURE_KEY ?? "",
toolName: "message",
args: {
action: "poll",
channel: "telegram",
target: "chat123",
pollQuestion: "Lunch?",
pollOption: ["Pizza", "Sushi"],
},
result: { pollId: "poll-1" },
isError: false,
});
input.onStdout?.("done");
return createManagedRun({
reason: "exit",
exitCode: 0,
exitSignal: null,
durationMs: 50,
stdout: "",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
});
});
const result = await executePreparedCliRun(context);
expect(result.didSendViaMessagingTool).toBe(true);
});
it("preserves text and media evidence for confirmed implicit message sends", async () => {
const context = buildPreparedCliRunContext({ output: "text", provider: "google-gemini-cli" });
context.mcpDeliveryCapture = true;
context.params.sourceReplyDeliveryMode = "message_tool_only";
supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => {
const input = args[0] as SupervisorSpawnInput;
recordMcpLoopbackToolCallResult({
captureKey: input.env?.OPENCLAW_MCP_CLI_CAPTURE_KEY ?? "",
toolName: "message",
args: {
action: "send",
message: "implicit reply",
mediaUrl: "https://example.com/implicit.png",
},
result: {
ok: true,
details: {
deliveryStatus: "sent",
sourceReplySink: "internal-ui",
sourceReply: {
text: "implicit reply",
mediaUrl: "https://example.com/implicit.png",
},
},
},
isError: false,
});
recordMcpLoopbackToolCallResult({
captureKey: input.env?.OPENCLAW_MCP_CLI_CAPTURE_KEY ?? "",
toolName: "message",
args: {
action: "send",
message: "implicit reply",
mediaUrl: "https://example.com/implicit.png",
},
result: {
ok: true,
details: {
deliveryStatus: "sent",
sourceReplySink: "internal-ui",
sourceReply: {
text: "implicit reply",
mediaUrl: "https://example.com/implicit.png",
},
},
},
isError: false,
});
input.onStdout?.("done");
return createManagedRun({
reason: "exit",
exitCode: 0,
exitSignal: null,
durationMs: 50,
stdout: "",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
});
});
const result = await executePreparedCliRun(context);
expect(result.didSendViaMessagingTool).toBe(true);
expect(result.messagingToolSentTexts).toEqual(["implicit reply"]);
expect(result.messagingToolSentMediaUrls).toEqual(["https://example.com/implicit.png"]);
expect(result.messagingToolSentTargets).toBeUndefined();
expect(result.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(result.messagingToolSourceReplyPayloads).toEqual([
{
text: "implicit reply",
mediaUrl: "https://example.com/implicit.png",
},
{
text: "implicit reply",
mediaUrl: "https://example.com/implicit.png",
},
]);
});
it("retains confirmed delivery for long non-streaming message calls", async () => {
const context = buildPreparedCliRunContext({ output: "text", provider: "local-cli" });
context.mcpDeliveryCapture = true;
supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => {
const input = args[0] as SupervisorSpawnInput;
recordMcpLoopbackToolCallResult({
captureKey: input.env?.OPENCLAW_MCP_CLI_CAPTURE_KEY ?? "",
toolName: "message",
args: {
action: "send",
channel: "telegram",
target: "chat123",
message: "x".repeat(20 * 1024),
},
result: { status: "sent" },
isError: false,
});
input.onStdout?.("done");
return createManagedRun({
reason: "exit",
exitCode: 0,
exitSignal: null,
durationMs: 50,
stdout: "",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
});
});
const result = await executePreparedCliRun(context);
expect(result.didSendViaMessagingTool).toBe(true);
expect(result.messagingToolSentTargets).toEqual([
expect.objectContaining({ tool: "message", provider: "telegram", to: "chat123" }),
]);
});
it("captures non-Claude JSONL sends and gives every attempt a unique token", async () => {
const context = buildPreparedCliRunContext({ output: "jsonl", provider: "local-cli" });
context.mcpDeliveryCapture = true;
const captureKeys: string[] = [];
supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => {
const input = args[0] as SupervisorSpawnInput;
const captureKey = input.env?.OPENCLAW_MCP_CLI_CAPTURE_KEY ?? "";
captureKeys.push(captureKey);
recordMcpLoopbackToolCallResult({
captureKey,
toolName: "message",
args: {
action: "send",
channel: "telegram",
target: "chat123",
message: "done",
},
result: { status: "sent" },
isError: false,
});
input.onStdout?.(`${JSON.stringify({ item: { type: "message", text: "done" } })}\n`);
return createManagedRun({
reason: "exit",
exitCode: 0,
exitSignal: null,
durationMs: 50,
stdout: "",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
});
});
const first = await executePreparedCliRun(context);
const second = await executePreparedCliRun(context);
expect(first.didSendViaMessagingTool).toBe(true);
expect(second.didSendViaMessagingTool).toBe(true);
expect(captureKeys).toHaveLength(2);
expect(captureKeys[0]).not.toBe(captureKeys[1]);
});
});
File diff suppressed because it is too large Load Diff
@@ -124,4 +124,17 @@ describe("buildCliAgentSystemPrompt", () => {
expect(prompt).toContain("channel=telegram");
expect(prompt).not.toContain("### message tool");
});
it("requires an explicit message target when the CLI turn policy requires one", () => {
const prompt = buildCliAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
tools: [{ name: "message" } as never],
modelDisplay: "test/model",
sourceReplyDeliveryMode: "message_tool_only",
requireExplicitMessageTarget: true,
});
expect(prompt).toContain("include `target` and `message`; `target` is required for this turn");
expect(prompt).not.toContain("The target defaults to the current source channel");
});
});
+10 -2
View File
@@ -85,21 +85,27 @@ export function buildClaudeOwnerKey(input: {
/** Resolves the serialization key for a CLI backend run. */
export function resolveCliRunQueueKey(params: {
backendId: string;
liveSession?: CliBackendConfig["liveSession"];
serialize?: boolean;
runId: string;
workspaceDir: string;
cliSessionId?: string;
ownerKey?: string;
}): string {
if (params.serialize === false) {
const requiresLiveSessionSerialization =
isClaudeCliProvider(params.backendId) && params.liveSession === "claude-stdio";
if (params.serialize === false && !requiresLiveSessionSerialization) {
return `${params.backendId}:${params.runId}`;
}
if (isClaudeCliProvider(params.backendId)) {
const ownerKey = params.ownerKey?.trim();
if (requiresLiveSessionSerialization && ownerKey) {
return `${params.backendId}:owner:${ownerKey}`;
}
const sessionId = params.cliSessionId?.trim();
if (sessionId) {
return `${params.backendId}:session:${sessionId}`;
}
const ownerKey = params.ownerKey?.trim();
if (ownerKey) {
return `${params.backendId}:owner:${ownerKey}`;
}
@@ -119,6 +125,7 @@ export function buildCliAgentSystemPrompt(params: {
defaultThinkLevel?: ThinkLevel;
extraSystemPrompt?: string;
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
requireExplicitMessageTarget?: boolean;
silentReplyPromptMode?: SilentReplyPromptMode;
runtimeChannel?: string;
runtimeChatType?: ChatType;
@@ -168,6 +175,7 @@ export function buildCliAgentSystemPrompt(params: {
defaultThinkLevel: params.defaultThinkLevel,
extraSystemPrompt: params.extraSystemPrompt,
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
requireExplicitMessageTarget: params.requireExplicitMessageTarget,
silentReplyPromptMode: params.silentReplyPromptMode,
ownerNumbers: params.ownerNumbers,
reasoningTagHint: false,
+146 -1
View File
@@ -128,6 +128,7 @@ function createTestMcpLoopbackServerConfig(port: number) {
"x-openclaw-source-reply-delivery-mode": "${OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE}",
"x-openclaw-require-explicit-message-target":
"${OPENCLAW_MCP_REQUIRE_EXPLICIT_MESSAGE_TARGET}",
"x-openclaw-cli-capture-key": "${OPENCLAW_MCP_CLI_CAPTURE_KEY}",
},
},
},
@@ -1099,6 +1100,70 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
}
});
it("invalidates CLI session reuse when explicit message-target policy changes", async () => {
const { dir, sessionFile } = createSessionFile();
try {
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "test-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-message-policy",
sourceReplyDeliveryMode: "message_tool_only",
requireExplicitMessageTarget: true,
cliSessionBinding: {
sessionId: "cli-session",
messageToolPolicyHash: hashCliSessionText(
JSON.stringify({
sourceReplyDeliveryMode: "message_tool_only",
requireExplicitMessageTarget: false,
}),
),
},
config: createCliBackendConfig(),
});
expect(context.messageToolPolicyHash).toBeDefined();
expect(context.reusableCliSession).toEqual({ invalidatedReason: "system-prompt" });
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("requires explicit message targets by default for CLI subagents", async () => {
const { dir, sessionFile } = createSessionFile();
try {
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionKey: "agent:main:subagent:child",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "test-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-subagent-message-policy",
sourceReplyDeliveryMode: "message_tool_only",
config: createCliBackendConfig(),
});
expect(context.params.requireExplicitMessageTarget).toBe(true);
expect(context.messageToolPolicyHash).toBe(
hashCliSessionText(
JSON.stringify({
sourceReplyDeliveryMode: "message_tool_only",
requireExplicitMessageTarget: true,
}),
),
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("uses cwd for CLI system prompt workspace guidance", async () => {
const { dir, sessionFile } = createSessionFile();
const taskDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-task-"));
@@ -1444,6 +1509,8 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
accountId: undefined,
inboundEventKind: undefined,
sourceReplyDeliveryMode: undefined,
requireExplicitMessageTarget: false,
senderIsOwner: undefined,
});
expect(context.systemPrompt).toContain("## Memory Recall");
expect(context.systemPrompt).toContain("tools=memory_search");
@@ -1532,6 +1599,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
expect(context.systemPromptReport.tools.entries).toEqual([]);
expect(context.promptToolNamesHash).toBeUndefined();
expect(context.preparedBackend.env).toBeUndefined();
expect(context.mcpDeliveryCapture).toBeUndefined();
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
@@ -1547,10 +1615,23 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
}));
const ensureMcpLoopbackServer = vi.fn(createTestMcpLoopbackServer);
const createMcpLoopbackServerConfig = vi.fn(createTestMcpLoopbackServerConfig);
const resolveMcpLoopbackScopedTools = vi.fn(() => ({
agentId: "main",
tools: [
{
name: "message",
label: "Message",
description: "Send a message",
parameters: { type: "object", properties: {} },
execute: vi.fn(),
},
],
}));
setCliRunnerPrepareTestDeps({
getActiveMcpLoopbackRuntime,
ensureMcpLoopbackServer,
createMcpLoopbackServerConfig,
resolveMcpLoopbackScopedTools,
});
cliBackendsTesting.setDepsForTest({
resolvePluginSetupCliBackend: () => undefined,
@@ -1563,7 +1644,6 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
config: {
command: "native-cli",
args: ["--print"],
output: "text",
input: "arg",
sessionMode: "existing",
},
@@ -1600,6 +1680,71 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
OPENCLAW_MCP_INBOUND_EVENT_KIND: "room_event",
OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE: "message_tool_only",
OPENCLAW_MCP_REQUIRE_EXPLICIT_MESSAGE_TARGET: "true",
OPENCLAW_MCP_CLI_CAPTURE_KEY: "",
});
expect(context.mcpDeliveryCapture).toBe(true);
expect(resolveMcpLoopbackScopedTools).toHaveBeenCalledWith(
expect.objectContaining({
requireExplicitMessageTarget: true,
}),
);
expect(context.systemPrompt).toContain(
"include `target` and `message`; `target` is required for this turn",
);
expect(context.systemPrompt).not.toContain(
"The target defaults to the current source channel",
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("enables gateway delivery capture for Claude-style JSONL bundle MCP", async () => {
const { dir, sessionFile } = createSessionFile();
try {
setCliRunnerPrepareTestDeps({
getActiveMcpLoopbackRuntime: vi.fn(() => ({
port: 31783,
ownerToken: "loopback-owner-token",
nonOwnerToken: "loopback-non-owner-token",
})),
createMcpLoopbackServerConfig: vi.fn(createTestMcpLoopbackServerConfig),
});
cliBackendsTesting.setDepsForTest({
resolvePluginSetupCliBackend: () => undefined,
resolveRuntimeCliBackends: () => [
{
id: "claude-cli",
pluginId: "anthropic",
bundleMcp: true,
bundleMcpMode: "claude-config-file",
config: {
command: "claude",
args: ["--print"],
output: "jsonl",
jsonlDialect: "claude-stream-json",
input: "stdin",
sessionMode: "existing",
},
},
],
});
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "claude-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-claude-delivery-capture",
config: createCliBackendConfig(),
});
expect(context.mcpDeliveryCapture).toBe(true);
expect(context.preparedBackend.env).toMatchObject({
OPENCLAW_MCP_CLI_CAPTURE_KEY: "",
});
} finally {
fs.rmSync(dir, { recursive: true, force: true });
+26 -2
View File
@@ -24,6 +24,7 @@ import type {
} from "../../plugins/cli-backend.types.js";
import { buildAgentHookContextChannelFields } from "../../plugins/hook-agent-context.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { isSubagentSessionKey } from "../../routing/session-key.js";
import { annotateInterSessionPromptText } from "../../sessions/input-provenance.js";
import { resolveSkillsPromptForRun } from "../../skills/loading/workspace.js";
import { resolveEmbeddedRunSkillEntries } from "../../skills/runtime/embedded-run-entries.js";
@@ -289,6 +290,19 @@ export async function prepareCliRunContext(
params.extraSystemPromptStatic !== undefined
? hashCliSessionText(params.extraSystemPromptStatic.trim() || undefined)
: hashCliSessionText(extraSystemPrompt);
const requireExplicitMessageTarget =
params.requireExplicitMessageTarget ?? isSubagentSessionKey(params.sessionKey);
const messageToolPolicyHash =
params.sourceReplyDeliveryMode !== undefined ||
params.requireExplicitMessageTarget !== undefined ||
requireExplicitMessageTarget
? hashCliSessionText(
JSON.stringify({
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
requireExplicitMessageTarget,
}),
)
: undefined;
const modelId = (params.model ?? "default").trim() || "default";
const normalizedModel = normalizeCliModel(modelId, backendResolved.config);
@@ -359,6 +373,7 @@ export async function prepareCliRunContext(
}
mcpLoopbackRuntime = prepareDeps.getActiveMcpLoopbackRuntime();
}
const mcpDeliveryCaptureEnabled = bundleMcpEnabled && Boolean(mcpLoopbackRuntime);
const preparedBackend = await prepareCliBundleMcpConfig({
enabled: bundleMcpEnabled,
mode: backendResolved.bundleMcpMode,
@@ -385,8 +400,8 @@ export async function prepareCliRunContext(
OPENCLAW_MCP_CURRENT_INBOUND_AUDIO: params.currentInboundAudio === true ? "true" : "",
OPENCLAW_MCP_INBOUND_EVENT_KIND: params.currentInboundEventKind ?? "",
OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE: params.sourceReplyDeliveryMode ?? "",
OPENCLAW_MCP_REQUIRE_EXPLICIT_MESSAGE_TARGET:
params.requireExplicitMessageTarget === true ? "true" : "",
OPENCLAW_MCP_REQUIRE_EXPLICIT_MESSAGE_TARGET: requireExplicitMessageTarget ? "true" : "",
OPENCLAW_MCP_CLI_CAPTURE_KEY: "",
}
: undefined,
warn: (message) => cliBackendLog.warn(message),
@@ -476,6 +491,7 @@ export async function prepareCliRunContext(
accountId: params.agentAccountId,
inboundEventKind: params.currentInboundEventKind,
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
requireExplicitMessageTarget,
senderIsOwner: params.senderIsOwner,
}).tools
: [];
@@ -492,6 +508,7 @@ export async function prepareCliRunContext(
authEpoch,
authEpochVersion: CLI_AUTH_EPOCH_VERSION,
extraSystemPromptHash,
messageToolPolicyHash,
promptToolNamesHash,
cwdHash,
mcpConfigHash: preparedBackendFinal.mcpConfigHash,
@@ -585,6 +602,7 @@ export async function prepareCliRunContext(
defaultThinkLevel: params.thinkLevel,
extraSystemPrompt,
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
requireExplicitMessageTarget,
silentReplyPromptMode: params.silentReplyPromptMode,
runtimeChannel,
runtimeChatType: params.sessionEntry?.chatType,
@@ -758,6 +776,7 @@ export async function prepareCliRunContext(
...params,
config: contextEngineConfig,
prompt: preparedPrompt,
...(requireExplicitMessageTarget ? { requireExplicitMessageTarget: true } : {}),
};
return {
@@ -781,8 +800,10 @@ export async function prepareCliRunContext(
authEpoch,
authEpochVersion: CLI_AUTH_EPOCH_VERSION,
extraSystemPromptHash,
messageToolPolicyHash,
promptToolNamesHash,
cwdHash,
...(mcpDeliveryCaptureEnabled ? { mcpDeliveryCapture: true } : {}),
};
}
try {
@@ -821,6 +842,7 @@ export async function prepareCliRunContext(
...params,
config: contextEngineConfig,
prompt: preparedPrompt,
...(requireExplicitMessageTarget ? { requireExplicitMessageTarget: true } : {}),
};
return {
@@ -848,8 +870,10 @@ export async function prepareCliRunContext(
authEpoch,
authEpochVersion: CLI_AUTH_EPOCH_VERSION,
extraSystemPromptHash,
messageToolPolicyHash,
promptToolNamesHash,
cwdHash,
...(mcpDeliveryCaptureEnabled ? { mcpDeliveryCapture: true } : {}),
};
} catch (err) {
try {
+2
View File
@@ -188,6 +188,8 @@ export type PreparedCliRunContext = {
authEpoch?: string;
authEpochVersion: number;
extraSystemPromptHash?: string;
messageToolPolicyHash?: string;
promptToolNamesHash?: string;
cwdHash?: string;
mcpDeliveryCapture?: true;
};
+25
View File
@@ -27,6 +27,7 @@ describe("cli-session helpers", () => {
authEpoch: "auth-epoch",
authEpochVersion: 2,
extraSystemPromptHash: "prompt-hash",
messageToolPolicyHash: "message-policy-hash",
promptToolNamesHash: "prompt-tools-hash",
cwdHash: "cwd-hash",
mcpConfigHash: "mcp-hash",
@@ -42,6 +43,7 @@ describe("cli-session helpers", () => {
authEpoch: "auth-epoch",
authEpochVersion: 2,
extraSystemPromptHash: "prompt-hash",
messageToolPolicyHash: "message-policy-hash",
promptToolNamesHash: "prompt-tools-hash",
cwdHash: "cwd-hash",
mcpConfigHash: "mcp-hash",
@@ -186,6 +188,29 @@ describe("cli-session helpers", () => {
).toEqual({ invalidatedReason: "mcp" });
});
it("invalidates reuse when message-tool prompt policy changes", () => {
const binding = {
sessionId: "cli-session-1",
authEpochVersion: 2,
messageToolPolicyHash: "message-policy-a",
};
expect(
resolveCliSessionReuse({
binding,
authEpochVersion: 2,
messageToolPolicyHash: "message-policy-b",
}),
).toEqual({ invalidatedReason: "system-prompt" });
expect(
resolveCliSessionReuse({
binding,
authEpochVersion: 2,
messageToolPolicyHash: "message-policy-a",
}),
).toEqual({ sessionId: "cli-session-1" });
});
it("invalidates reuse when the task cwd changes", () => {
const binding = {
sessionId: "cli-session-1",
+10
View File
@@ -38,6 +38,7 @@ export function getCliSessionBinding(
authEpoch: normalizeOptionalString(fromBindings?.authEpoch),
authEpochVersion: fromBindings?.authEpochVersion,
extraSystemPromptHash: normalizeOptionalString(fromBindings?.extraSystemPromptHash),
messageToolPolicyHash: normalizeOptionalString(fromBindings?.messageToolPolicyHash),
promptToolNamesHash: normalizeOptionalString(fromBindings?.promptToolNamesHash),
cwdHash: normalizeOptionalString(fromBindings?.cwdHash),
mcpConfigHash: normalizeOptionalString(fromBindings?.mcpConfigHash),
@@ -100,6 +101,9 @@ export function setCliSessionBinding(
...(normalizeOptionalString(binding.extraSystemPromptHash)
? { extraSystemPromptHash: normalizeOptionalString(binding.extraSystemPromptHash) }
: {}),
...(normalizeOptionalString(binding.messageToolPolicyHash)
? { messageToolPolicyHash: normalizeOptionalString(binding.messageToolPolicyHash) }
: {}),
...(normalizeOptionalString(binding.promptToolNamesHash)
? { promptToolNamesHash: normalizeOptionalString(binding.promptToolNamesHash) }
: {}),
@@ -157,6 +161,7 @@ export function resolveCliSessionReuse(params: {
authEpoch?: string;
authEpochVersion: number;
extraSystemPromptHash?: string;
messageToolPolicyHash?: string;
promptToolNamesHash?: string;
cwdHash?: string;
mcpConfigHash?: string;
@@ -176,6 +181,7 @@ export function resolveCliSessionReuse(params: {
const currentAuthProfileId = normalizeOptionalString(params.authProfileId);
const currentAuthEpoch = normalizeOptionalString(params.authEpoch);
const currentExtraSystemPromptHash = normalizeOptionalString(params.extraSystemPromptHash);
const currentMessageToolPolicyHash = normalizeOptionalString(params.messageToolPolicyHash);
const currentPromptToolNamesHash = normalizeOptionalString(params.promptToolNamesHash);
const currentCwdHash = normalizeOptionalString(params.cwdHash);
const currentMcpConfigHash = normalizeOptionalString(params.mcpConfigHash);
@@ -202,6 +208,10 @@ export function resolveCliSessionReuse(params: {
if (storedExtraSystemPromptHash !== currentExtraSystemPromptHash) {
return { invalidatedReason: "system-prompt" };
}
const storedMessageToolPolicyHash = normalizeOptionalString(binding?.messageToolPolicyHash);
if (storedMessageToolPolicyHash !== currentMessageToolPolicyHash) {
return { invalidatedReason: "system-prompt" };
}
const storedPromptToolNamesHash = normalizeOptionalString(binding?.promptToolNamesHash);
if (storedPromptToolNamesHash !== currentPromptToolNamesHash) {
return { invalidatedReason: "system-prompt" };
@@ -1165,6 +1165,54 @@ describe("CLI attempt execution", () => {
});
});
it("forwards message-tool-only policy and requires explicit subagent targets", async () => {
const sessionKey = "agent:main:subagent:claude-message-policy";
const sessionEntry: SessionEntry = {
sessionId: "openclaw-session-cli-message-policy",
updatedAt: Date.now(),
};
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
runCliAgentMock.mockResolvedValueOnce(makeCliResult("sent"));
await runAgentAttempt({
providerOverride: "claude-cli",
originalProvider: "claude-cli",
modelOverride: "opus",
cfg: {} as OpenClawConfig,
sessionEntry,
sessionId: sessionEntry.sessionId,
sessionKey,
sessionAgentId: "main",
sessionFile: path.join(tmpDir, "session.jsonl"),
workspaceDir: tmpDir,
body: "route this",
isFallbackRetry: false,
resolvedThinkLevel: "medium",
timeoutMs: 1_000,
runId: "run-cli-message-policy",
opts: {
sourceReplyDeliveryMode: "message_tool_only",
} as Parameters<typeof runAgentAttempt>[0]["opts"],
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
spawnedBy: undefined,
messageChannel: "discord",
skillsSnapshot: undefined,
resolvedVerboseLevel: undefined,
agentDir: tmpDir,
onAgentEvent: vi.fn(),
authProfileProvider: "claude-cli",
sessionStore,
storePath,
sessionHasHistory: false,
});
expectMockArgFields(runCliAgentMock, {
sourceReplyDeliveryMode: "message_tool_only",
requireExplicitMessageTarget: true,
});
});
it("forwards runtime toolsAllow into CLI attempts so the CLI harness can fail closed", async () => {
const sessionKey = "agent:main:direct:claude-tools-allow";
const sessionEntry: SessionEntry = {
+3
View File
@@ -22,6 +22,7 @@ import { readErrorName } from "../../infra/errors.js";
import { redactSensitiveText } from "../../logging/redact.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
import { isSubagentSessionKey } from "../../routing/session-key.js";
import { annotateInterSessionPromptText } from "../../sessions/input-provenance.js";
import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
import {
@@ -626,6 +627,8 @@ export function runAgentAttempt(params: {
lane: params.opts.lane,
extraSystemPrompt: params.opts.extraSystemPrompt,
inputProvenance: params.opts.inputProvenance,
sourceReplyDeliveryMode: params.opts.sourceReplyDeliveryMode,
requireExplicitMessageTarget: isSubagentSessionKey(params.sessionKey),
cliSessionId: nextCliSessionId,
cliSessionBinding:
nextCliSessionId === activeCliSessionBinding?.sessionId
@@ -0,0 +1,298 @@
import { beforeEach, describe, expect, it } from "vitest";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
import {
isDeliveredMessageToolOnlySourceReplyResult,
isDeliveredMessagingToolResult,
} from "./embedded-agent-message-tool-source-reply.js";
import {
isMessagingToolDeliveryAction,
isMessagingToolSendAction,
} from "./embedded-agent-messaging.js";
beforeEach(() => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "native-messaging",
source: "test",
plugin: {
...createChannelTestPluginBase({ id: "native-messaging" }),
actions: {
describeMessageTool: () => null,
isToolDeliveryAction: ({ args }: { args: Record<string, unknown> }) =>
args.action === "editMessage" || args.action === "deleteMessage",
},
},
},
]),
);
});
describe("messaging delivery action classification", () => {
it("keeps visible side effects broader than terminal reply sends", () => {
expect(isMessagingToolSendAction("message", { action: "poll" })).toBe(false);
expect(isMessagingToolDeliveryAction("message", { action: "poll" })).toBe(true);
expect(isMessagingToolDeliveryAction("message", { action: "broadcast" })).toBe(true);
expect(isMessagingToolDeliveryAction("message", { action: "thread-create" })).toBe(true);
expect(isMessagingToolDeliveryAction("message", { action: "topic-create" })).toBe(true);
expect(isMessagingToolDeliveryAction("message", { action: "createForumTopic" })).toBe(true);
expect(isMessagingToolDeliveryAction("message", { action: "channel-create" })).toBe(true);
expect(isMessagingToolDeliveryAction("message", { action: "event-create" })).toBe(true);
expect(isMessagingToolDeliveryAction("message", { action: "react" })).toBe(true);
expect(isMessagingToolDeliveryAction("message", { action: "read" })).toBe(false);
expect(isMessagingToolDeliveryAction("message", { action: "channel-list" })).toBe(false);
});
it("uses provider-native mutation contracts", () => {
expect(isMessagingToolDeliveryAction("native-messaging", { action: "editMessage" })).toBe(true);
expect(isMessagingToolDeliveryAction("native-messaging", { action: "deleteMessage" })).toBe(
true,
);
expect(isMessagingToolDeliveryAction("native-messaging", { action: "readMessages" })).toBe(
false,
);
});
});
describe("isDeliveredMessagingToolResult", () => {
it("accepts confirmed delivery receipts from direct CLI text blocks", () => {
expect(
isDeliveredMessagingToolResult({
result: [{ type: "text", text: JSON.stringify({ result: { messageId: "msg-1" } }) }],
}),
).toBe(true);
expect(isDeliveredMessagingToolResult({ result: { status: "sent" } })).toBe(true);
});
it("rejects bare success markers without delivery evidence", () => {
expect(isDeliveredMessagingToolResult({ result: { ok: true, to: "spaces/AAA" } })).toBe(false);
});
it("accepts action-specific bare success delivery contracts", () => {
expect(
isDeliveredMessagingToolResult({
args: { action: "poll" },
result: { ok: true },
}),
).toBe(true);
expect(
isDeliveredMessagingToolResult({
args: { action: "sticker" },
result: [{ type: "text", text: JSON.stringify({ ok: true }) }],
}),
).toBe(true);
expect(
isDeliveredMessagingToolResult({
args: { action: "channel-create" },
result: { ok: true },
}),
).toBe(true);
});
it("rejects successful no-op mutation results", () => {
for (const result of [
{ ok: true, removed: null },
{ ok: true, removed: 0 },
{ ok: true, removed: [] },
{ ok: true, changed: false },
]) {
expect(
isDeliveredMessagingToolResult({
args: { action: "react" },
result,
}),
).toBe(false);
}
});
it("accepts sessions_send acknowledgement statuses only for sessions_send", () => {
expect(
isDeliveredMessagingToolResult({
toolName: "sessions_send",
result: { status: "accepted" },
}),
).toBe(true);
expect(
isDeliveredMessagingToolResult({
toolName: "sessions_send",
result: { status: "ok" },
}),
).toBe(true);
expect(isDeliveredMessagingToolResult({ toolName: "message", result: { status: "ok" } })).toBe(
false,
);
});
it("accepts post-start sessions_send timeout and error evidence", () => {
expect(
isDeliveredMessagingToolResult({
toolName: "sessions_send",
result: { status: "timeout", sentBeforeError: true },
}),
).toBe(true);
expect(
isDeliveredMessagingToolResult({
toolName: "sessions_send",
result: { status: "error", sentBeforeError: true },
}),
).toBe(true);
expect(
isDeliveredMessagingToolResult({
toolName: "sessions_send",
result: { status: "timeout" },
}),
).toBe(false);
expect(
isDeliveredMessagingToolResult({
toolName: "sessions_send",
result: { status: "error" },
}),
).toBe(false);
});
it("accepts poll delivery identifiers", () => {
expect(isDeliveredMessagingToolResult({ result: { pollId: "poll-1" } })).toBe(true);
});
it("accepts successful thread and topic creation receipts", () => {
expect(
isDeliveredMessagingToolResult({
args: { action: "thread-create" },
result: { ok: true, thread: { id: "thread-1" } },
}),
).toBe(true);
expect(
isDeliveredMessagingToolResult({
args: { action: "topic-create" },
result: { ok: true, topicId: 42 },
}),
).toBe(true);
expect(
isDeliveredMessagingToolResult({
args: { action: "topic-create" },
isError: true,
result: { topicId: 43, error: "post-create metadata update failed" },
}),
).toBe(true);
});
it("accepts only broadcast result entries with concrete delivery evidence", () => {
expect(
isDeliveredMessagingToolResult({
toolName: "message",
result: { results: [{ ok: true, messageId: "message-1" }] },
}),
).toBe(true);
expect(
isDeliveredMessagingToolResult({
args: { action: "broadcast" },
result: {
results: [
{
channel: "telegram",
to: "chat-1",
ok: true,
payload: { ok: true, messageId: "gateway-message-1" },
},
],
},
}),
).toBe(true);
expect(
isDeliveredMessagingToolResult({
args: { action: "broadcast" },
result: { results: [{ channel: "googlechat", to: "space-1", ok: true }] },
}),
).toBe(false);
});
it("rejects successful broadcast wrappers around suppressed sends", () => {
expect(
isDeliveredMessagingToolResult({
toolName: "message",
args: { action: "broadcast" },
result: { results: [{ ok: true, result: { messageId: "suppressed" } }] },
}),
).toBe(false);
expect(
isDeliveredMessagingToolResult({
toolName: "message",
args: { action: "broadcast" },
result: { results: [{ ok: true, result: { deliveryStatus: "suppressed" } }] },
}),
).toBe(false);
});
it("accepts failed broadcast entries with partial-delivery evidence", () => {
expect(
isDeliveredMessagingToolResult({
args: { action: "broadcast" },
result: {
results: [{ channel: "telegram", to: "chat-1", ok: false, sentBeforeError: true }],
},
}),
).toBe(true);
});
it("rejects non-delivery message id sentinels", () => {
expect(isDeliveredMessagingToolResult({ result: { messageId: "skipped" } })).toBe(false);
expect(isDeliveredMessagingToolResult({ result: { messageId: "suppressed" } })).toBe(false);
});
it("accepts successful sends with an unknown message id", () => {
expect(isDeliveredMessagingToolResult({ result: { messageId: "unknown" } })).toBe(true);
});
it("rejects dry-run, suppressed, and errored results", () => {
expect(
isDeliveredMessagingToolResult({
args: { dryRun: true },
result: { result: { messageId: "msg-1" } },
}),
).toBe(false);
expect(isDeliveredMessagingToolResult({ result: { status: "suppressed" } })).toBe(false);
expect(
isDeliveredMessagingToolResult({
isError: true,
result: { result: { messageId: "msg-1" } },
}),
).toBe(false);
});
it("accepts errored results that prove partial visible delivery", () => {
expect(
isDeliveredMessagingToolResult({
isError: true,
result: Object.assign(new Error("second chunk failed"), { sentBeforeError: true }),
}),
).toBe(true);
expect(
isDeliveredMessagingToolResult({
isError: true,
result: { deliveryStatus: "partial_failed" },
}),
).toBe(true);
});
});
describe("isDeliveredMessageToolOnlySourceReplyResult", () => {
it("accepts only confirmed implicit message sends", () => {
expect(
isDeliveredMessageToolOnlySourceReplyResult({
sourceReplyDeliveryMode: "message_tool_only",
toolName: "message",
args: { action: "send", message: "reply" },
result: { deliveryStatus: "sent" },
}),
).toBe(true);
expect(
isDeliveredMessageToolOnlySourceReplyResult({
sourceReplyDeliveryMode: "message_tool_only",
toolName: "message",
args: { action: "send", target: "elsewhere", message: "reply" },
result: { deliveryStatus: "sent" },
}),
).toBe(false);
});
});
@@ -2,14 +2,20 @@
* Detects message-tool sends that delivered a visible reply to the current source.
*/
import type { SourceReplyDeliveryMode } from "../auto-reply/get-reply-options.types.js";
import { isMessageToolSendActionName } from "./embedded-agent-messaging.js";
import {
isMessageToolSendActionName,
isMessagingToolDeliveryAction,
} from "./embedded-agent-messaging.js";
import { isToolResultError } from "./embedded-agent-subscribe.tools.js";
import { normalizeToolName } from "./tool-policy.js";
const MESSAGE_TOOL_NAME = "message";
const SESSIONS_SEND_TOOL_NAME = "sessions_send";
const EXPLICIT_MESSAGE_ROUTE_KEYS = ["channel", "target", "to", "channelId", "provider"];
const DRY_RUN_DELIVERY_STATUS = "dry_run";
const PARTIAL_FAILED_DELIVERY_STATUS = "partial_failed";
const SENT_DELIVERY_STATUS = "sent";
const NON_DELIVERY_MESSAGE_IDS = new Set(["skipped", "suppressed"]);
const RESULT_ENVELOPE_KEYS = [
"details",
"payload",
@@ -18,6 +24,14 @@ const RESULT_ENVELOPE_KEYS = [
"sendResult",
"toolResult",
];
const PARTIAL_DELIVERY_ENVELOPE_KEYS = [...RESULT_ENVELOPE_KEYS, "error", "cause"];
const SESSIONS_SEND_DELIVERY_STATUSES = new Set(["accepted", "ok"]);
const CONVERSATION_CREATE_ACTIONS = new Set([
"thread-create",
"topic-create",
"threadcreate",
"createforumtopic",
]);
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
@@ -29,6 +43,10 @@ function hasStringValue(value: unknown): boolean {
return typeof value === "string" && value.trim().length > 0;
}
function hasConversationIdValue(value: unknown): boolean {
return hasStringValue(value) || (typeof value === "number" && Number.isFinite(value));
}
function hasExplicitMessageRoute(args: Record<string, unknown>): boolean {
if (EXPLICIT_MESSAGE_ROUTE_KEYS.some((key) => hasStringValue(args[key]))) {
return true;
@@ -52,7 +70,11 @@ function parseJsonRecord(value: string): Record<string, unknown> | undefined {
}
function recordHasDeliveredMessageId(record: Record<string, unknown>): boolean {
if (hasStringValue(record.messageId)) {
const hasDeliveredId = (value: unknown) => {
const normalized = normalizeStatus(value);
return Boolean(normalized && !NON_DELIVERY_MESSAGE_IDS.has(normalized));
};
if (hasDeliveredId(record.messageId) || hasDeliveredId(record.pollId)) {
return true;
}
const receipt = record.receipt;
@@ -61,9 +83,191 @@ function recordHasDeliveredMessageId(record: Record<string, unknown>): boolean {
}
const receiptRecord = receipt as Record<string, unknown>;
return (
hasStringValue(receiptRecord.primaryPlatformMessageId) ||
hasDeliveredId(receiptRecord.primaryPlatformMessageId) ||
(Array.isArray(receiptRecord.platformMessageIds) &&
receiptRecord.platformMessageIds.some((value) => hasStringValue(value)))
receiptRecord.platformMessageIds.some((value) => hasDeliveredId(value)))
);
}
function deliveryEnvelopeHasCreatedConversationId(value: unknown, depth = 0): boolean {
if (!value || typeof value !== "object" || depth > 4) {
return false;
}
if (Array.isArray(value)) {
return value.some((item) => deliveryEnvelopeHasCreatedConversationId(item, depth + 1));
}
const record = value as Record<string, unknown>;
if (
hasConversationIdValue(record.topicId) ||
hasConversationIdValue(record.threadId) ||
hasConversationIdValue(record.messageThreadId)
) {
return true;
}
const thread = record.thread;
if (thread && typeof thread === "object" && !Array.isArray(thread)) {
if (hasConversationIdValue((thread as Record<string, unknown>).id)) {
return true;
}
}
if (typeof record.text === "string") {
const parsed = parseJsonRecord(record.text);
if (parsed && deliveryEnvelopeHasCreatedConversationId(parsed, depth + 1)) {
return true;
}
}
const content = record.content;
if (
Array.isArray(content) &&
content.some((item) => deliveryEnvelopeHasCreatedConversationId(item, depth + 1))
) {
return true;
}
return PARTIAL_DELIVERY_ENVELOPE_KEYS.some((key) =>
deliveryEnvelopeHasCreatedConversationId(record[key], depth + 1),
);
}
function deliveryEnvelopeIndicatesOk(value: unknown, depth = 0): boolean {
if (!value || typeof value !== "object" || depth > 4) {
return false;
}
if (Array.isArray(value)) {
return value.some((item) => deliveryEnvelopeIndicatesOk(item, depth + 1));
}
const record = value as Record<string, unknown>;
if (record.ok === true) {
return true;
}
if (typeof record.text === "string") {
const parsed = parseJsonRecord(record.text);
if (parsed && deliveryEnvelopeIndicatesOk(parsed, depth + 1)) {
return true;
}
}
const content = record.content;
if (
Array.isArray(content) &&
content.some((item) => deliveryEnvelopeIndicatesOk(item, depth + 1))
) {
return true;
}
return RESULT_ENVELOPE_KEYS.some((key) => deliveryEnvelopeIndicatesOk(record[key], depth + 1));
}
function deliveryEnvelopeIndicatesNonDelivery(value: unknown, depth = 0): boolean {
if (!value || typeof value !== "object" || depth > 4) {
return false;
}
if (Array.isArray(value)) {
return value.some((item) => deliveryEnvelopeIndicatesNonDelivery(item, depth + 1));
}
const record = value as Record<string, unknown>;
const messageId = normalizeStatus(record.messageId);
if (
(messageId && NON_DELIVERY_MESSAGE_IDS.has(messageId)) ||
normalizeStatus(record.deliveryStatus) === "suppressed" ||
normalizeStatus(record.status) === "suppressed"
) {
return true;
}
if (typeof record.text === "string") {
const parsed = parseJsonRecord(record.text);
if (parsed && deliveryEnvelopeIndicatesNonDelivery(parsed, depth + 1)) {
return true;
}
}
const content = record.content;
if (
Array.isArray(content) &&
content.some((item) => deliveryEnvelopeIndicatesNonDelivery(item, depth + 1))
) {
return true;
}
return RESULT_ENVELOPE_KEYS.some((key) =>
deliveryEnvelopeIndicatesNonDelivery(record[key], depth + 1),
);
}
function deliveryEnvelopeIndicatesNoOp(value: unknown, depth = 0): boolean {
if (!value || typeof value !== "object" || depth > 4) {
return false;
}
if (Array.isArray(value)) {
return value.some((item) => deliveryEnvelopeIndicatesNoOp(item, depth + 1));
}
const record = value as Record<string, unknown>;
const removed = record.removed;
if (
removed === null ||
removed === false ||
removed === 0 ||
(Array.isArray(removed) && removed.length === 0) ||
record.applied === false ||
record.changed === false ||
record.created === false ||
record.deleted === false ||
record.sent === false ||
record.updated === false
) {
return true;
}
const status = normalizeStatus(record.status);
if (status === "noop" || status === "no_op" || status === "not_found") {
return true;
}
if (typeof record.text === "string") {
const parsed = parseJsonRecord(record.text);
if (parsed && deliveryEnvelopeIndicatesNoOp(parsed, depth + 1)) {
return true;
}
}
const content = record.content;
if (
Array.isArray(content) &&
content.some((item) => deliveryEnvelopeIndicatesNoOp(item, depth + 1))
) {
return true;
}
return RESULT_ENVELOPE_KEYS.some((key) => deliveryEnvelopeIndicatesNoOp(record[key], depth + 1));
}
function deliveryEnvelopeIndicatesSuccessfulBroadcast(value: unknown, depth = 0): boolean {
if (!value || typeof value !== "object" || depth > 4) {
return false;
}
if (Array.isArray(value)) {
return value.some(
(item) =>
item !== null &&
typeof item === "object" &&
!Array.isArray(item) &&
(item as Record<string, unknown>).ok === true &&
!deliveryEnvelopeIndicatesNonDelivery(item) &&
!deliveryEnvelopeIndicatesNoOp(item) &&
deliveryEnvelopeIndicatesDelivered(item, depth + 1),
);
}
const record = value as Record<string, unknown>;
if (deliveryEnvelopeIndicatesSuccessfulBroadcast(record.results, depth + 1)) {
return true;
}
if (typeof record.text === "string") {
const parsed = parseJsonRecord(record.text);
if (parsed && deliveryEnvelopeIndicatesSuccessfulBroadcast(parsed, depth + 1)) {
return true;
}
}
const content = record.content;
if (
Array.isArray(content) &&
content.some((item) => deliveryEnvelopeIndicatesSuccessfulBroadcast(item, depth + 1))
) {
return true;
}
return RESULT_ENVELOPE_KEYS.some((key) =>
deliveryEnvelopeIndicatesSuccessfulBroadcast(record[key], depth + 1),
);
}
@@ -78,10 +282,17 @@ function deliveryEnvelopeIndicatesDryRun(value: unknown, depth = 0): boolean {
const record = value as Record<string, unknown>;
if (
record.dryRun === true ||
normalizeStatus(record.deliveryStatus) === DRY_RUN_DELIVERY_STATUS
normalizeStatus(record.deliveryStatus) === DRY_RUN_DELIVERY_STATUS ||
normalizeStatus(record.status) === DRY_RUN_DELIVERY_STATUS
) {
return true;
}
if (typeof record.text === "string") {
const parsed = parseJsonRecord(record.text);
if (parsed && deliveryEnvelopeIndicatesDryRun(parsed, depth + 1)) {
return true;
}
}
const content = record.content;
if (Array.isArray(content)) {
@@ -117,10 +328,17 @@ function deliveryEnvelopeIndicatesDelivered(value: unknown, depth = 0): boolean
const record = value as Record<string, unknown>;
if (
normalizeStatus(record.deliveryStatus) === SENT_DELIVERY_STATUS ||
normalizeStatus(record.status) === SENT_DELIVERY_STATUS ||
recordHasDeliveredMessageId(record)
) {
return true;
}
if (typeof record.text === "string") {
const parsed = parseJsonRecord(record.text);
if (parsed && deliveryEnvelopeIndicatesDelivered(parsed, depth + 1)) {
return true;
}
}
const content = record.content;
if (Array.isArray(content)) {
@@ -145,6 +363,129 @@ function deliveryEnvelopeIndicatesDelivered(value: unknown, depth = 0): boolean
);
}
function deliveryEnvelopeIndicatesSessionsSendAccepted(value: unknown, depth = 0): boolean {
if (!value || typeof value !== "object" || depth > 4) {
return false;
}
if (Array.isArray(value)) {
return value.some((item) => deliveryEnvelopeIndicatesSessionsSendAccepted(item, depth + 1));
}
const record = value as Record<string, unknown>;
if (
SESSIONS_SEND_DELIVERY_STATUSES.has(normalizeStatus(record.deliveryStatus) ?? "") ||
SESSIONS_SEND_DELIVERY_STATUSES.has(normalizeStatus(record.status) ?? "")
) {
return true;
}
if (typeof record.text === "string") {
const parsed = parseJsonRecord(record.text);
if (parsed && deliveryEnvelopeIndicatesSessionsSendAccepted(parsed, depth + 1)) {
return true;
}
}
const content = record.content;
if (
Array.isArray(content) &&
content.some((item) => deliveryEnvelopeIndicatesSessionsSendAccepted(item, depth + 1))
) {
return true;
}
return RESULT_ENVELOPE_KEYS.some((key) =>
deliveryEnvelopeIndicatesSessionsSendAccepted(record[key], depth + 1),
);
}
function deliveryEnvelopeIndicatesPartialDelivery(value: unknown, depth = 0): boolean {
if (!value || typeof value !== "object" || depth > 4) {
return false;
}
if (Array.isArray(value)) {
return value.some((item) => deliveryEnvelopeIndicatesPartialDelivery(item, depth + 1));
}
const record = value as Record<string, unknown>;
if (
record.sentBeforeError === true ||
record.visibleReplySent === true ||
normalizeStatus(record.deliveryStatus) === PARTIAL_FAILED_DELIVERY_STATUS ||
normalizeStatus(record.status) === PARTIAL_FAILED_DELIVERY_STATUS
) {
return true;
}
return PARTIAL_DELIVERY_ENVELOPE_KEYS.some((key) =>
deliveryEnvelopeIndicatesPartialDelivery(record[key], depth + 1),
);
}
/** Return true only when a messaging tool result proves a real visible delivery. */
export function isDeliveredMessagingToolResult(params: {
toolName?: string;
args?: unknown;
result?: unknown;
hookResult?: unknown;
isError?: boolean;
}): boolean {
const args = asRecord(params.args);
const action = normalizeStatus(args.action);
if (
args.dryRun === true ||
deliveryEnvelopeIndicatesDryRun(params.result) ||
deliveryEnvelopeIndicatesDryRun(params.hookResult)
) {
return false;
}
if (
deliveryEnvelopeIndicatesPartialDelivery(params.result) ||
deliveryEnvelopeIndicatesPartialDelivery(params.hookResult)
) {
return true;
}
if (
action &&
CONVERSATION_CREATE_ACTIONS.has(action) &&
(deliveryEnvelopeHasCreatedConversationId(params.result) ||
deliveryEnvelopeHasCreatedConversationId(params.hookResult))
) {
return true;
}
if (
action === "broadcast" &&
(deliveryEnvelopeIndicatesSuccessfulBroadcast(params.result) ||
deliveryEnvelopeIndicatesSuccessfulBroadcast(params.hookResult))
) {
return true;
}
if (params.isError || isToolResultError(params.result) || isToolResultError(params.hookResult)) {
return false;
}
const normalizedToolName = normalizeToolName(params.toolName ?? MESSAGE_TOOL_NAME);
const mutationHasBareOk =
isMessagingToolDeliveryAction(normalizedToolName, args) &&
action !== "broadcast" &&
(deliveryEnvelopeIndicatesOk(params.result) || deliveryEnvelopeIndicatesOk(params.hookResult));
if (
mutationHasBareOk &&
!deliveryEnvelopeIndicatesNonDelivery(params.result) &&
!deliveryEnvelopeIndicatesNonDelivery(params.hookResult) &&
!deliveryEnvelopeIndicatesNoOp(params.result) &&
!deliveryEnvelopeIndicatesNoOp(params.hookResult)
) {
return true;
}
if (normalizedToolName === SESSIONS_SEND_TOOL_NAME) {
return (
deliveryEnvelopeIndicatesSessionsSendAccepted(params.result) ||
deliveryEnvelopeIndicatesSessionsSendAccepted(params.hookResult) ||
deliveryEnvelopeIndicatesDelivered(params.result) ||
deliveryEnvelopeIndicatesDelivered(params.hookResult)
);
}
return (
deliveryEnvelopeIndicatesDelivered(params.result) ||
deliveryEnvelopeIndicatesDelivered(params.hookResult)
);
}
/**
* Only implicit-route, non-dry-run, delivered `message.send` calls qualify.
* Explicit routes and other messaging tools are outbound side effects, not source replies.
@@ -167,18 +508,5 @@ export function isDeliveredMessageToolOnlySourceReplyResult(params: {
if (!isMessageToolSendActionName(args.action) || hasExplicitMessageRoute(args)) {
return false;
}
if (params.isError || isToolResultError(params.result) || isToolResultError(params.hookResult)) {
return false;
}
if (
args.dryRun === true ||
deliveryEnvelopeIndicatesDryRun(params.result) ||
deliveryEnvelopeIndicatesDryRun(params.hookResult)
) {
return false;
}
return (
deliveryEnvelopeIndicatesDelivered(params.result) ||
deliveryEnvelopeIndicatesDelivered(params.hookResult)
);
return isDeliveredMessagingToolResult(params);
}
+41 -8
View File
@@ -3,6 +3,7 @@
*/
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
import { CHANNEL_MESSAGE_ACTION_NAMES } from "../channels/plugins/types.public.js";
const CORE_MESSAGING_TOOLS = new Set(["sessions_send", "message"]);
const MESSAGE_TOOL_SEND_ACTIONS = new Set([
@@ -12,6 +13,27 @@ const MESSAGE_TOOL_SEND_ACTIONS = new Set([
"sendAttachment",
"upload-file",
]);
const MESSAGE_TOOL_READ_ONLY_ACTIONS = new Set([
"read",
"reactions",
"list-pins",
"permissions",
"thread-list",
"search",
"sticker-search",
"member-info",
"role-info",
"emoji-list",
"channel-info",
"channel-list",
"voice-status",
"event-list",
"download-file",
]);
const MESSAGE_TOOL_MUTATION_ACTIONS = new Set<string>(
CHANNEL_MESSAGE_ACTION_NAMES.filter((action) => !MESSAGE_TOOL_READ_ONLY_ACTIONS.has(action)),
);
const MESSAGE_TOOL_MUTATION_ALIASES = new Set(["threadCreate", "createForumTopic"]);
/** Return true when a message action sends or uploads user-visible content. */
export function isMessageToolSendActionName(action: unknown): boolean {
@@ -42,12 +64,23 @@ export function isMessagingToolSendAction(
return isMessageToolSendActionName(action);
}
const providerId = normalizeChannelId(toolName);
if (!providerId) {
return false;
}
const plugin = getChannelPlugin(providerId);
if (!plugin?.actions?.extractToolSend) {
return false;
}
return Boolean(plugin.actions.extractToolSend({ args })?.to);
return Boolean(
providerId && getChannelPlugin(providerId)?.actions?.extractToolSend?.({ args })?.to,
);
}
/** Return true when a messaging invocation can create visible outbound delivery. */
export function isMessagingToolDeliveryAction(
toolName: string,
args: Record<string, unknown>,
): boolean {
if (toolName === "message") {
const action = normalizeOptionalString(args.action) ?? "";
return MESSAGE_TOOL_MUTATION_ACTIONS.has(action) || MESSAGE_TOOL_MUTATION_ALIASES.has(action);
}
const providerId = normalizeChannelId(toolName);
if (providerId && getChannelPlugin(providerId)?.actions?.isToolDeliveryAction?.({ args })) {
return true;
}
return isMessagingToolSendAction(toolName, args);
}
@@ -30,7 +30,6 @@ import {
emitAgentPatchSummaryEvent,
} from "../infra/agent-events.js";
import type { ExecApprovalDecision } from "../infra/exec-approvals.js";
import { normalizeInteractiveReply, normalizeMessagePresentation } from "../interactive/payload.js";
import type { PluginHookAfterToolCallEvent } from "../plugins/types.js";
import { createLazyImportLoader } from "../shared/lazy-promise.js";
import { truncateUtf16Safe } from "../utils.js";
@@ -47,7 +46,6 @@ import { sanitizeForConsole } from "./console-sanitize.js";
import { normalizeTextForComparison } from "./embedded-agent-helpers.js";
import { isDeliveredMessageToolOnlySourceReplyResult } from "./embedded-agent-message-tool-source-reply.js";
import { isMessagingTool, isMessagingToolSendAction } from "./embedded-agent-messaging.js";
import type { MessagingToolSourceReplyPayload } from "./embedded-agent-messaging.types.js";
import { mergeEmbeddedRunReplayState } from "./embedded-agent-runner/replay-state.js";
import type {
ToolCallSummary,
@@ -55,6 +53,9 @@ import type {
} from "./embedded-agent-subscribe.handlers.types.js";
import { isPromiseLike } from "./embedded-agent-subscribe.promise.js";
import {
collectMessagingMediaUrlsFromRecord,
collectMessagingMediaUrlsFromToolResult,
extractMessagingToolSourceReplyPayload,
extractToolResultMediaArtifact,
extractToolErrorCode,
extractMessagingToolSend,
@@ -508,56 +509,6 @@ function extendExecMeta(toolName: string, args: unknown, meta?: string): string
return meta ? `${meta} · ${suffix}` : suffix;
}
function pushUniqueMediaUrl(urls: string[], seen: Set<string>, value: unknown): void {
if (typeof value !== "string") {
return;
}
const normalized = value.trim();
if (!normalized || seen.has(normalized)) {
return;
}
seen.add(normalized);
urls.push(normalized);
}
function collectMessagingMediaUrlsFromRecord(record: Record<string, unknown>): string[] {
const urls: string[] = [];
const seen = new Set<string>();
const pushAttachment = (value: unknown) => {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return;
}
const attachment = value as Record<string, unknown>;
pushUniqueMediaUrl(urls, seen, attachment.media);
pushUniqueMediaUrl(urls, seen, attachment.mediaUrl);
pushUniqueMediaUrl(urls, seen, attachment.path);
pushUniqueMediaUrl(urls, seen, attachment.filePath);
pushUniqueMediaUrl(urls, seen, attachment.fileUrl);
pushUniqueMediaUrl(urls, seen, attachment.url);
};
pushUniqueMediaUrl(urls, seen, record.media);
pushUniqueMediaUrl(urls, seen, record.mediaUrl);
pushUniqueMediaUrl(urls, seen, record.path);
pushUniqueMediaUrl(urls, seen, record.filePath);
pushUniqueMediaUrl(urls, seen, record.fileUrl);
const mediaUrls = record.mediaUrls;
if (Array.isArray(mediaUrls)) {
for (const mediaUrl of mediaUrls) {
pushUniqueMediaUrl(urls, seen, mediaUrl);
}
}
const attachments = record.attachments;
if (Array.isArray(attachments)) {
for (const attachment of attachments) {
pushAttachment(attachment);
}
}
return urls;
}
function readMessagingText(record: Record<string, unknown>): string | undefined {
for (const key of ["content", "message", "text", "body"]) {
const value = readStringValue(record[key]);
@@ -568,115 +519,6 @@ function readMessagingText(record: Record<string, unknown>): string | undefined
return undefined;
}
function collectMessagingMediaUrlsFromToolResult(result: unknown): string[] {
const urls: string[] = [];
const seen = new Set<string>();
const appendFromRecord = (value: unknown) => {
if (!value || typeof value !== "object") {
return;
}
const extracted = collectMessagingMediaUrlsFromRecord(value as Record<string, unknown>);
for (const url of extracted) {
if (seen.has(url)) {
continue;
}
seen.add(url);
urls.push(url);
}
};
appendFromRecord(result);
if (result && typeof result === "object") {
appendFromRecord((result as Record<string, unknown>).details);
}
const outputText = extractToolResultText(result);
if (outputText) {
try {
appendFromRecord(JSON.parse(outputText));
} catch {
// Ignore non-JSON tool output.
}
}
return urls;
}
function readStringField(record: Record<string, unknown>, key: string): string | undefined {
const value = record[key];
return typeof value === "string" && value.trim() ? value : undefined;
}
function readStringArrayField(record: Record<string, unknown>, key: string): string[] | undefined {
const value = record[key];
if (!Array.isArray(value)) {
return undefined;
}
const strings = value.filter(
(item): item is string => typeof item === "string" && item.trim().length > 0,
);
return strings.length ? strings : undefined;
}
function copyRecordField(
record: Record<string, unknown>,
key: string,
): Record<string, unknown> | undefined {
const value = record[key];
return readRecordField(value) ? { ...(value as Record<string, unknown>) } : undefined;
}
function extractMessagingToolSourceReplyPayload(
result: unknown,
): MessagingToolSourceReplyPayload | undefined {
const details = readToolResultDetailsRecord(result);
if (!details || details.sourceReplySink !== "internal-ui") {
return undefined;
}
const status = normalizeOptionalLowercaseString(details.deliveryStatus);
if (status && status !== "sent") {
return undefined;
}
const sourceReply = readRecordField(details.sourceReply) ?? details;
const payload: MessagingToolSourceReplyPayload = {};
const text = readStringField(sourceReply, "text") ?? readStringField(details, "message");
if (text) {
payload.text = text;
}
const mediaUrl = readStringField(sourceReply, "mediaUrl") ?? readStringField(details, "mediaUrl");
if (mediaUrl) {
payload.mediaUrl = mediaUrl;
}
const mediaUrls =
readStringArrayField(sourceReply, "mediaUrls") ?? readStringArrayField(details, "mediaUrls");
if (mediaUrls) {
payload.mediaUrls = mediaUrls;
}
const audioAsVoice =
sourceReply.audioAsVoice === true || details.audioAsVoice === true ? true : undefined;
if (audioAsVoice) {
payload.audioAsVoice = true;
}
const presentation = normalizeMessagePresentation(sourceReply.presentation);
if (presentation) {
payload.presentation = presentation;
}
const interactive = normalizeInteractiveReply(sourceReply.interactive);
if (interactive) {
payload.interactive = interactive;
}
const channelData = copyRecordField(sourceReply, "channelData");
if (channelData) {
payload.channelData = channelData;
}
const idempotencyKey =
readStringField(sourceReply, "idempotencyKey") ?? readStringField(details, "idempotencyKey");
if (idempotencyKey) {
payload.idempotencyKey = idempotencyKey;
}
return Object.keys(payload).length > 0 ? payload : undefined;
}
function queuePendingToolMedia(
ctx: ToolHandlerContext,
mediaReply: { mediaUrls: string[]; audioAsVoice?: boolean; trustedLocalMedia?: boolean },
+147 -1
View File
@@ -12,11 +12,15 @@ import { uniqueStrings } from "@openclaw/normalization-core/string-normalization
import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeTargetForProvider } from "../infra/outbound/target-normalization.js";
import { normalizeInteractiveReply, normalizeMessagePresentation } from "../interactive/payload.js";
import { redactSensitiveFieldValue, redactToolPayloadText } from "../logging/redact.js";
import { truncateUtf16Safe } from "../utils.js";
import { collectTextContentBlocks } from "./content-blocks.js";
import { isMessageToolSendActionName } from "./embedded-agent-messaging.js";
import type { MessagingToolSend } from "./embedded-agent-messaging.types.js";
import type {
MessagingToolSend,
MessagingToolSourceReplyPayload,
} from "./embedded-agent-messaging.types.js";
import { normalizeToolName } from "./tool-policy.js";
import { readToolResultDetails, readToolResultStatus } from "./tool-result-error.js";
@@ -279,6 +283,148 @@ export function extractToolResultText(result: unknown): string | undefined {
return texts.join("\n");
}
function pushUniqueMessagingMediaUrl(urls: string[], seen: Set<string>, value: unknown): void {
if (typeof value !== "string") {
return;
}
const normalized = value.trim();
if (!normalized || seen.has(normalized)) {
return;
}
seen.add(normalized);
urls.push(normalized);
}
/** Collects messaging attachment references from tool-call arguments or result records. */
export function collectMessagingMediaUrlsFromRecord(record: Record<string, unknown>): string[] {
const urls: string[] = [];
const seen = new Set<string>();
const pushAttachment = (value: unknown) => {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return;
}
const attachment = value as Record<string, unknown>;
for (const candidate of [
attachment.media,
attachment.mediaUrl,
attachment.path,
attachment.filePath,
attachment.fileUrl,
attachment.url,
]) {
pushUniqueMessagingMediaUrl(urls, seen, candidate);
}
};
for (const candidate of [
record.media,
record.mediaUrl,
record.path,
record.filePath,
record.fileUrl,
]) {
pushUniqueMessagingMediaUrl(urls, seen, candidate);
}
if (Array.isArray(record.mediaUrls)) {
for (const mediaUrl of record.mediaUrls) {
pushUniqueMessagingMediaUrl(urls, seen, mediaUrl);
}
}
if (Array.isArray(record.attachments)) {
for (const attachment of record.attachments) {
pushAttachment(attachment);
}
}
return urls;
}
/** Collects messaging attachment references from a completed tool result. */
export function collectMessagingMediaUrlsFromToolResult(result: unknown): string[] {
const urls: string[] = [];
const seen = new Set<string>();
const appendFromRecord = (value: unknown) => {
if (!value || typeof value !== "object") {
return;
}
for (const url of collectMessagingMediaUrlsFromRecord(value as Record<string, unknown>)) {
if (!seen.has(url)) {
seen.add(url);
urls.push(url);
}
}
};
appendFromRecord(result);
if (result && typeof result === "object") {
appendFromRecord((result as Record<string, unknown>).details);
}
const outputText = extractToolResultText(result);
if (outputText) {
try {
appendFromRecord(JSON.parse(outputText));
} catch {
// Ignore non-JSON tool output.
}
}
return urls;
}
/** Extract an internal source-reply payload from a completed message tool result. */
export function extractMessagingToolSourceReplyPayload(
result: unknown,
): MessagingToolSourceReplyPayload | undefined {
const details = readToolResultDetails(result);
if (!details || details.sourceReplySink !== "internal-ui") {
return undefined;
}
const status = normalizeOptionalLowercaseString(details.deliveryStatus);
if (status && status !== "sent") {
return undefined;
}
const sourceReply = readRecord(details.sourceReply) ?? details;
const payload: MessagingToolSourceReplyPayload = {};
const text = readStringValue(sourceReply.text) ?? readStringValue(details.message);
if (text) {
payload.text = text;
}
const mediaUrl = readStringValue(sourceReply.mediaUrl) ?? readStringValue(details.mediaUrl);
if (mediaUrl) {
payload.mediaUrl = mediaUrl;
}
const rawMediaUrls = Array.isArray(sourceReply.mediaUrls)
? sourceReply.mediaUrls
: Array.isArray(details.mediaUrls)
? details.mediaUrls
: [];
const mediaUrls = uniqueStrings(
rawMediaUrls.filter((value): value is string => typeof value === "string"),
);
if (mediaUrls.length > 0) {
payload.mediaUrls = mediaUrls;
}
if (sourceReply.audioAsVoice === true || details.audioAsVoice === true) {
payload.audioAsVoice = true;
}
const presentation = normalizeMessagePresentation(sourceReply.presentation);
if (presentation) {
payload.presentation = presentation;
}
const interactive = normalizeInteractiveReply(sourceReply.interactive);
if (interactive) {
payload.interactive = interactive;
}
const channelData = readRecord(sourceReply.channelData);
if (channelData) {
payload.channelData = { ...channelData };
}
const idempotencyKey =
readStringValue(sourceReply.idempotencyKey) ?? readStringValue(details.idempotencyKey);
if (idempotencyKey) {
payload.idempotencyKey = idempotencyKey;
}
return Object.keys(payload).length > 0 ? payload : undefined;
}
// Core tool names that are allowed to emit trusted local media artifacts.
// Plugin tools must be explicitly passed as trusted run-local names by the caller.
const TRUSTED_TOOL_RESULT_MEDIA = new Set([
@@ -198,6 +198,7 @@ type SessionsSendDetails = {
runId?: string;
reply?: string;
error?: string;
sentBeforeError?: boolean;
sessionKey?: string;
delivery?: {
status?: string;
@@ -1223,6 +1224,7 @@ describe("sessions tools", () => {
expect(details.status).toBe("timeout");
expect(details.error).toBe("429 RESOURCE_EXHAUSTED");
expect(details.runId).toBe("run-pending-model-error");
expect(details.sentBeforeError).toBe(true);
expect(details.delivery?.status).toBe("pending");
expect(calls.filter((call) => call.method === "agent")).toHaveLength(1);
await vi.waitFor(() =>
@@ -1949,6 +1951,7 @@ describe("sessions tools", () => {
const details = sessionsSendDetails(result.details);
expect(details.status).toBe("timeout");
expect(details.error).toBe("agent run timed out");
expect(details.sentBeforeError).toBe(true);
expect(details.sessionKey).toBe(targetKey);
await new Promise<void>((resolve) => {
setImmediate(resolve);
@@ -1956,6 +1959,42 @@ describe("sessions tools", () => {
expect(countMatching(calls, (call) => call.method === "agent")).toBe(1);
});
it("sessions_send preserves delivery evidence for post-start agent errors", async () => {
const targetKey = "agent:director1:main";
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string };
if (request.method === "agent") {
return { runId: "run-error", status: "accepted", acceptedAt: 2000 };
}
if (request.method === "agent.wait") {
return { runId: "run-error", status: "error", error: "agent failed" };
}
if (request.method === "chat.history") {
return { messages: [] };
}
return {};
});
const tool = createOpenClawTools({
agentSessionKey: "agent:main:main",
agentChannel: "discord",
}).find((candidate) => candidate.name === "sessions_send");
if (!tool) {
throw new Error("missing sessions_send tool");
}
const result = await tool.execute("call-error", {
sessionKey: targetKey,
message: "ping",
timeoutSeconds: 1,
});
const details = sessionsSendDetails(result.details);
expect(details.status).toBe("error");
expect(details.error).toBe("agent failed");
expect(details.sentBeforeError).toBe(true);
expect(details.sessionKey).toBe(targetKey);
});
it("sessions_send skips duplicate A2A delivery for waited parent-owned native subagents", async () => {
const calls: Array<{ method?: string; params?: unknown }> = [];
const requesterKey = "agent:main:discord:direct:parent";
+16
View File
@@ -1081,6 +1081,22 @@ describe("buildAgentSystemPrompt", () => {
},
);
it("requires an explicit target for message-tool-only turns when requested", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
toolNames: ["message"],
sourceReplyDeliveryMode: "message_tool_only",
requireExplicitMessageTarget: true,
runtimeInfo: {
channel: "telegram",
chatType: "group",
},
});
expect(prompt).toContain("include `target` and `message`; `target` is required for this turn");
expect(prompt).not.toContain("The target defaults to the current source channel");
});
it("tells automatic source delivery to expose generated media as MEDIA directives", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
+6 -1
View File
@@ -492,6 +492,7 @@ function buildMessagingSection(params: {
messageChannelOptions?: string;
messageToolHints?: string[];
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
requireExplicitMessageTarget?: boolean;
silentReplyPromptMode?: SilentReplyPromptMode;
}) {
if (params.isMinimal) {
@@ -539,7 +540,9 @@ function buildMessagingSection(params: {
? "- Discord group/thread etiquette: a mention plus message-tool-only delivery does not require visible output. For stale threads, jokes, lightweight acknowledgements, or low-value chatter, prefer a reaction or no channel message; post only when you have concrete value to add."
: "",
messageToolOnly
? "- For `action=send`, include `message`. The target defaults to the current source channel; include `target` only when sending somewhere else."
? params.requireExplicitMessageTarget
? "- For `action=send`, include `target` and `message`; `target` is required for this turn."
: "- For `action=send`, include `message`. The target defaults to the current source channel; include `target` only when sending somewhere else."
: "- For `action=send`, include `target` and `message`.",
params.messageChannelOptions
? `- No current/default source channel: include \`channel\` for proactive sends; valid ids: ${params.messageChannelOptions}.`
@@ -704,6 +707,7 @@ export function buildAgentSystemPrompt(params: {
/** Controls the generic silent-reply section. Channel-aware prompts can set "none". */
silentReplyPromptMode?: SilentReplyPromptMode;
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
requireExplicitMessageTarget?: boolean;
/** Prompt-only strength for delegating non-trivial work through sub-agents. Defaults to "suggest". */
subagentDelegationMode?: SubagentDelegationMode;
/** Whether ACP-specific routing guidance should be included. Defaults to true. */
@@ -1299,6 +1303,7 @@ export function buildAgentSystemPrompt(params: {
messageChannelOptions,
messageToolHints: params.messageToolHints,
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
requireExplicitMessageTarget: params.requireExplicitMessageTarget,
silentReplyPromptMode,
}),
...buildVoiceSection({ isMinimal, ttsHint: params.ttsHint }),
+12
View File
@@ -1121,6 +1121,18 @@ describe("tool-loop-detection", () => {
expect(hashes?.[0]).not.toBe(hashes?.[1]);
});
it("keeps full result hashing for administrative message mutations", () => {
const state = createState();
const params = { action: "channel-create", name: "support" };
recordSend(state, "message", params, { ok: true, messageId: "m_0", timestamp: 1000 }, 0);
recordSend(state, "message", params, { ok: true, messageId: "m_1", timestamp: 2000 }, 1);
const hashes = state.toolCallHistory
?.filter((call) => call.toolName === "message")
.map((call) => call.resultHash);
expect(hashes?.[0]).toBeTypeOf("string");
expect(hashes?.[0]).not.toBe(hashes?.[1]);
});
it("preserves stable nested route ids so distinct routes stay distinguishable", () => {
const state = createState();
const params = { action: "send", target: "feishu:oc_chat", text: "ping" };
+3
View File
@@ -718,6 +718,7 @@ export function createSessionsSendTool(opts?: {
runId,
status: "timeout",
error: result.error,
sentBeforeError: true,
sessionKey: displayKey,
delivery,
});
@@ -735,6 +736,7 @@ export function createSessionsSendTool(opts?: {
runId,
status: "timeout",
error: result.error,
sentBeforeError: true,
sessionKey: displayKey,
});
}
@@ -743,6 +745,7 @@ export function createSessionsSendTool(opts?: {
runId,
status: "error",
error: result.error ?? "agent error",
sentBeforeError: true,
sessionKey: displayKey,
});
}
@@ -1703,6 +1703,54 @@ describe("runReplyAgent typing (heartbeat)", () => {
}
});
it("does not report silent fallback failure after a did-send-only side effect", async () => {
state.runEmbeddedAgentMock.mockResolvedValueOnce({
payloads: [],
didSendViaMessagingTool: true,
meta: {},
});
const fallbackSpy = vi
.spyOn(modelFallbackModule, "runWithModelFallback")
.mockImplementationOnce(
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
result: await run("openai", "gpt-5.5"),
provider: "openai",
model: "gpt-5.5",
attempts: [
{
provider: "lmstudio",
model: "gemma-4-e4b-it",
error: "Connection error.",
reason: "timeout",
},
],
}),
);
try {
const { run } = createMinimalRun({
runOverrides: {
provider: "lmstudio",
model: "gemma-4-e4b-it",
},
sessionCtx: {
Provider: "discord",
OriginatingChannel: "discord",
MessageSid: "1503645939964055592",
},
});
const res = await run();
const payload = Array.isArray(res) ? res[0] : res;
expect(payload?.isError).not.toBe(true);
expect(payload?.text).toContain("Model Fallback:");
expect(payload?.text).not.toContain("no visible reply");
} finally {
fallbackSpy.mockRestore();
}
});
it("does not treat whitespace-only messaging evidence as fallback delivery", async () => {
state.runEmbeddedAgentMock.mockResolvedValueOnce({
payloads: [{ text: "NO_REPLY" }],
+3
View File
@@ -250,10 +250,12 @@ function hasSuccessfulSideEffectDelivery(params: {
messagingToolSentTexts?: string[];
messagingToolSentMediaUrls?: string[];
messagingToolSentTargets?: unknown[];
didSendViaMessagingTool?: boolean;
successfulCronAdds?: number;
didSendDeterministicApprovalPrompt?: boolean;
}): boolean {
return (
params.didSendViaMessagingTool === true ||
hasSuccessfulSourceReplyDelivery(params) ||
(params.successfulCronAdds ?? 0) > 0 ||
params.didSendDeterministicApprovalPrompt === true
@@ -1953,6 +1955,7 @@ export async function runReplyAgent(params: {
messagingToolSentTexts: runResult.messagingToolSentTexts,
messagingToolSentMediaUrls: runResult.messagingToolSentMediaUrls,
messagingToolSentTargets: runResult.messagingToolSentTargets,
didSendViaMessagingTool: runResult.didSendViaMessagingTool,
successfulCronAdds: runResult.successfulCronAdds,
didSendDeterministicApprovalPrompt: runResult.didSendDeterministicApprovalPrompt,
});
+2
View File
@@ -765,6 +765,8 @@ export type ChannelMessageActionAdapter = {
action: ChannelMessageActionName;
toolContext?: ChannelThreadingToolContext;
}) => boolean;
/** Return true when a provider-native tool invocation has a visible or destructive side effect. */
isToolDeliveryAction?: (params: { args: Record<string, unknown> }) => boolean;
extractToolSend?: (params: { args: Record<string, unknown> }) => ChannelToolSend | null;
/** Recover the actual resolved send route from a successful action result. */
extractToolSendResult?: (params: {
+1
View File
@@ -51,6 +51,7 @@ export type CliSessionBinding = {
authEpoch?: string;
authEpochVersion?: number;
extraSystemPromptHash?: string;
messageToolPolicyHash?: string;
promptToolNamesHash?: string;
cwdHash?: string;
mcpConfigHash?: string;
+28
View File
@@ -47,6 +47,13 @@ export async function handleMcpJsonRpc(params: {
toolSchema: McpToolSchemaEntry[];
hookContext?: HookContext;
signal?: AbortSignal;
onToolCallResult?: (call: {
toolName: string;
args: Record<string, unknown>;
result?: unknown;
isError: boolean;
}) => void;
onToolCallPrepared?: (call: { toolName: string; args: Record<string, unknown> }) => void;
}): Promise<object | null> {
const { id, method, params: methodParams } = params.message;
@@ -97,6 +104,19 @@ export async function handleMcpJsonRpc(params: {
});
}
const toolCallId = `mcp-${crypto.randomUUID()}`;
let executedToolArgs = toolArgs;
const reportToolCallResult = (result: unknown, isError: boolean) => {
try {
params.onToolCallResult?.({
toolName,
args: executedToolArgs,
result,
isError,
});
} catch {
// Observability callbacks must never alter the tool result returned to the MCP client.
}
};
try {
// Gateway before-tool hooks still run for loopback MCP calls so policy
// and audit behavior matches native tool calls from normal chat runs.
@@ -113,12 +133,20 @@ export async function handleMcpJsonRpc(params: {
isError: true,
});
}
executedToolArgs = hookResult.params as Record<string, unknown>;
try {
params.onToolCallPrepared?.({ toolName, args: executedToolArgs });
} catch {
// Observability callbacks must never alter the tool result returned to the MCP client.
}
const result = await tool.execute(toolCallId, hookResult.params, params.signal);
reportToolCallResult(result, false);
return jsonRpcResult(id, {
content: normalizeToolCallContent(result),
isError: false,
});
} catch (error) {
reportToolCallResult(error, true);
const message = formatErrorMessage(error);
return jsonRpcResult(id, {
content: [{ type: "text", text: message || "tool execution failed" }],
+311
View File
@@ -5,7 +5,317 @@ type McpLoopbackRuntime = {
nonOwnerToken: string;
};
export type McpLoopbackToolCallResult = {
toolName: string;
args: Record<string, unknown>;
result?: unknown;
isError: boolean;
};
export type McpLoopbackToolCallStart = Pick<McpLoopbackToolCallResult, "toolName" | "args">;
type McpLoopbackToolCallCapture = {
onRequestStart?: () => void;
onRequestClassified?: () => void;
onRequestFinish?: () => void;
onToolCallStart?: (call: McpLoopbackToolCallStart) => void;
onToolCallUpdate?: (calls: {
previous: McpLoopbackToolCallStart;
current: McpLoopbackToolCallStart;
}) => void;
onToolCallFinish?: (call: McpLoopbackToolCallStart, state: { prepared: boolean }) => void;
onToolCallResult: (call: McpLoopbackToolCallResult) => void;
inFlight: number;
activityVersion: number;
activityWaiters: Set<() => void>;
};
export type McpLoopbackRequestCaptureHandle = {
capture: McpLoopbackToolCallCapture;
classified: boolean;
finished: boolean;
};
export type McpLoopbackToolCallCaptureHandle = {
capture: McpLoopbackToolCallCapture;
call: McpLoopbackToolCallStart;
prepared: boolean;
finished: boolean;
};
let activeRuntime: McpLoopbackRuntime | undefined;
const toolCallCaptures = new Map<string, McpLoopbackToolCallCapture>();
function deleteMcpLoopbackToolCallCapture(captureKey: string): void {
const capture = toolCallCaptures.get(captureKey);
if (!capture) {
return;
}
toolCallCaptures.delete(captureKey);
for (const resolve of capture.activityWaiters) {
resolve();
}
capture.activityWaiters.clear();
}
function notifyMcpLoopbackToolCallCaptureActivity(capture: McpLoopbackToolCallCapture): void {
capture.activityVersion += 1;
for (const resolve of capture.activityWaiters) {
resolve();
}
capture.activityWaiters.clear();
}
/** Start loopback tool-call result capture for one serialized CLI invocation. */
export function beginMcpLoopbackToolCallCapture(params: {
captureKey: string;
onRequestStart?: () => void;
onRequestClassified?: () => void;
onRequestFinish?: () => void;
onToolCallStart?: (call: McpLoopbackToolCallStart) => void;
onToolCallUpdate?: (calls: {
previous: McpLoopbackToolCallStart;
current: McpLoopbackToolCallStart;
}) => void;
onToolCallFinish?: (call: McpLoopbackToolCallStart, state: { prepared: boolean }) => void;
onToolCallResult: (call: McpLoopbackToolCallResult) => void;
}): void {
const captureKey = params.captureKey.trim();
if (!captureKey) {
return;
}
toolCallCaptures.set(captureKey, {
onRequestStart: params.onRequestStart,
onRequestClassified: params.onRequestClassified,
onRequestFinish: params.onRequestFinish,
onToolCallStart: params.onToolCallStart,
onToolCallUpdate: params.onToolCallUpdate,
onToolCallFinish: params.onToolCallFinish,
onToolCallResult: params.onToolCallResult,
inFlight: 0,
activityVersion: 0,
activityWaiters: new Set(),
});
}
/** Bind an authenticated HTTP request to the active capture generation before reading its body. */
export function markMcpLoopbackRequestStarted(
captureKey: string | undefined,
): McpLoopbackRequestCaptureHandle | undefined {
const normalizedKey = captureKey?.trim() ?? "";
if (!normalizedKey) {
return undefined;
}
const capture = toolCallCaptures.get(normalizedKey);
if (!capture) {
return undefined;
}
capture.inFlight += 1;
notifyMcpLoopbackToolCallCaptureActivity(capture);
try {
capture.onRequestStart?.();
} catch {
// Delivery observation is diagnostic state; it must not alter request handling.
}
return { capture, classified: false, finished: false };
}
/** Mark a request body as parsed so it no longer represents an unknown possible send. */
export function markMcpLoopbackRequestClassified(
captureHandle: McpLoopbackRequestCaptureHandle | undefined,
): void {
if (!captureHandle || captureHandle.classified || captureHandle.finished) {
return;
}
captureHandle.classified = true;
try {
captureHandle.capture.onRequestClassified?.();
} catch {
// Delivery observation is diagnostic state; it must not alter request handling.
}
}
/** Mark an authenticated request as settled and wake capture drains. */
export function markMcpLoopbackRequestFinished(
captureHandle: McpLoopbackRequestCaptureHandle | undefined,
): void {
if (!captureHandle || captureHandle.finished) {
return;
}
markMcpLoopbackRequestClassified(captureHandle);
captureHandle.finished = true;
const { capture } = captureHandle;
try {
capture.onRequestFinish?.();
} catch {
// Delivery observation is diagnostic state; it must not alter request handling.
}
capture.inFlight = Math.max(0, capture.inFlight - 1);
notifyMcpLoopbackToolCallCaptureActivity(capture);
}
/** Mark a captured loopback tool call as in flight. */
export function markMcpLoopbackToolCallStarted(params: {
captureKey?: string;
requestCaptureHandle?: McpLoopbackRequestCaptureHandle;
toolName: string;
args: Record<string, unknown>;
}): McpLoopbackToolCallCaptureHandle | undefined {
const toolName = params.toolName.trim();
if (!toolName || params.requestCaptureHandle?.finished) {
return undefined;
}
const captureKey = params.captureKey?.trim() ?? "";
const capture = params.requestCaptureHandle?.capture ?? toolCallCaptures.get(captureKey);
if (!capture) {
return undefined;
}
const call = { toolName, args: params.args };
capture.inFlight += 1;
notifyMcpLoopbackToolCallCaptureActivity(capture);
try {
capture.onToolCallStart?.(call);
} catch {
// Delivery observation is diagnostic state; it must not alter tool execution.
}
return { capture, call, prepared: false, finished: false };
}
/** Update an admitted call with the final arguments produced by gateway hooks. */
export function updateMcpLoopbackToolCallCapture(
captureHandle: McpLoopbackToolCallCaptureHandle | undefined,
call: McpLoopbackToolCallStart,
): void {
if (!captureHandle || captureHandle.finished) {
return;
}
const previous = captureHandle.call;
captureHandle.call = call;
captureHandle.prepared = true;
try {
captureHandle.capture.onToolCallUpdate?.({ previous, current: call });
} catch {
// Delivery observation is diagnostic state; it must not alter tool execution.
}
}
/** Report a completed call without letting observer failures alter tool execution. */
export function recordMcpLoopbackToolCallResult(params: {
captureHandle: McpLoopbackToolCallCaptureHandle;
toolName: string;
args: Record<string, unknown>;
result?: unknown;
isError: boolean;
}): void {
const toolName = params.toolName.trim();
if (!toolName) {
return;
}
try {
params.captureHandle.capture.onToolCallResult({
toolName,
args: params.args,
result: params.result,
isError: params.isError,
});
} catch {
// Delivery observation is diagnostic state; it must not turn a successful tool call into error.
}
}
/** Mark a captured loopback tool call as settled and wake idle drains. */
export function markMcpLoopbackToolCallFinished(
captureHandle: McpLoopbackToolCallCaptureHandle | undefined,
): void {
if (!captureHandle || captureHandle.finished) {
return;
}
captureHandle.finished = true;
const { capture } = captureHandle;
try {
capture.onToolCallFinish?.(captureHandle.call, { prepared: captureHandle.prepared });
} catch {
// Delivery observation is diagnostic state; it must not alter tool execution.
}
capture.inFlight = Math.max(0, capture.inFlight - 1);
notifyMcpLoopbackToolCallCaptureActivity(capture);
}
async function waitForMcpLoopbackToolCallCaptureActivity(
capture: McpLoopbackToolCallCapture,
timeoutMs: number,
): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
let settled = false;
const finish = (active: boolean) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
capture.activityWaiters.delete(resolveActivity);
resolve(active);
};
const resolveActivity = () => finish(true);
const timer = setTimeout(() => finish(false), Math.max(0, timeoutMs));
timer.unref?.();
capture.activityWaiters.add(resolveActivity);
});
}
/** Wait for admitted calls to settle and for a quiet request-admission grace. */
export async function waitForMcpLoopbackToolCallCaptureIdle(
captureKey: string,
options: {
timeoutMs: number;
admissionGraceMs: number;
},
): Promise<boolean> {
const normalizedKey = captureKey.trim();
const capture = toolCallCaptures.get(normalizedKey);
if (!capture) {
return true;
}
const deadline = Date.now() + Math.max(0, options.timeoutMs);
while (toolCallCaptures.get(normalizedKey) === capture) {
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) {
return false;
}
if (capture.inFlight > 0) {
await waitForMcpLoopbackToolCallCaptureActivity(capture, remainingMs);
continue;
}
const admissionGraceMs = Math.max(0, options.admissionGraceMs);
if (admissionGraceMs === 0) {
return true;
}
const activityVersion = capture.activityVersion;
const quietWaitMs = Math.min(admissionGraceMs, remainingMs);
const sawActivity = await waitForMcpLoopbackToolCallCaptureActivity(capture, quietWaitMs);
if (
!sawActivity &&
quietWaitMs === admissionGraceMs &&
capture.inFlight === 0 &&
capture.activityVersion === activityVersion
) {
return true;
}
}
return true;
}
/** Clear an unfinished invocation capture. Attempt keys are unique per CLI execution. */
export function clearMcpLoopbackToolCallCapture(captureKey: string): void {
deleteMcpLoopbackToolCallCapture(captureKey.trim());
}
/** Clear transient capture state between isolated tests. */
export function clearMcpLoopbackToolCallCapturesForTest(): void {
for (const captureKey of toolCallCaptures.keys()) {
deleteMcpLoopbackToolCallCapture(captureKey);
}
}
/** Return a copy of the active loopback runtime, if one has been installed. */
export function getActiveMcpLoopbackRuntime(): McpLoopbackRuntime | undefined {
@@ -53,6 +363,7 @@ export function createMcpLoopbackServerConfig(port: number) {
"x-openclaw-source-reply-delivery-mode": "${OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE}",
"x-openclaw-require-explicit-message-target":
"${OPENCLAW_MCP_REQUIRE_EXPLICIT_MESSAGE_TARGET}",
"x-openclaw-cli-capture-key": "${OPENCLAW_MCP_CLI_CAPTURE_KEY}",
},
},
},
+4
View File
@@ -354,6 +354,10 @@ export function resolveMcpHttpBodyTimeoutMs(): number {
return readPositiveIntEnv("OPENCLAW_MCP_LOOPBACK_BODY_TIMEOUT_MS", DEFAULT_MCP_BODY_TIMEOUT_MS);
}
export function resolveMcpCliCaptureKey(req: IncomingMessage): string | undefined {
return normalizeOptionalString(getHeader(req, "x-openclaw-cli-capture-key"));
}
export function resolveMcpRequestContext(
req: IncomingMessage,
cfg: OpenClawConfig,
+365
View File
@@ -106,6 +106,15 @@ import {
ensureMcpLoopbackServer,
startMcpLoopbackServer,
} from "./mcp-http.js";
import {
beginMcpLoopbackToolCallCapture,
clearMcpLoopbackToolCallCapture,
clearMcpLoopbackToolCallCapturesForTest,
markMcpLoopbackToolCallFinished,
markMcpLoopbackToolCallStarted,
recordMcpLoopbackToolCallResult,
waitForMcpLoopbackToolCallCaptureIdle,
} from "./mcp-http.loopback-runtime.js";
import { McpLoopbackToolCache } from "./mcp-http.runtime.js";
let server: Awaited<ReturnType<typeof startMcpLoopbackServer>> | undefined;
@@ -554,6 +563,7 @@ function buildMockMcpToolSchema(tools: MockGatewayTool[]) {
}
beforeEach(() => {
clearMcpLoopbackToolCallCapturesForTest();
resolveGatewayScopedToolsMock.mockClear();
runBeforeToolCallHookMock.mockClear();
runBeforeToolCallHookMock.mockImplementation(
@@ -938,6 +948,358 @@ describe("mcp loopback server", () => {
expectMcpResultText(payload, "CRON_EXECUTED");
});
it("captures only successful calls with an explicit CLI capture key", async () => {
const captureKey = "google-gemini-cli";
const captured: Array<{ toolName: string; args: Record<string, unknown> }> = [];
const startedTargets: unknown[] = [];
const finishedTargets: unknown[] = [];
beginMcpLoopbackToolCallCapture({
captureKey,
onToolCallStart: ({ args }) => startedTargets.push(args.target),
onToolCallFinish: ({ args }) => finishedTargets.push(args.target),
onToolCallResult: ({ toolName, args }) => {
if (toolName === "message" && args.action === "send") {
captured.push({ toolName, args });
}
},
});
const { runtime } = await startLoopbackServerForTest();
expect(
(
await sendLoopbackToolCall({
token: runtime.ownerToken,
name: "message",
args: { action: "send", target: "chat123", message: "sent" },
headers: { "x-openclaw-cli-capture-key": captureKey },
})
).status,
).toBe(200);
runBeforeToolCallHookMock.mockResolvedValueOnce({
blocked: true,
reason: "blocked for test",
});
expect(
(
await sendLoopbackToolCall({
token: runtime.ownerToken,
name: "message",
args: { action: "send", target: "blocked", message: "not sent" },
headers: { "x-openclaw-cli-capture-key": captureKey },
})
).status,
).toBe(200);
expect(
(
await sendLoopbackToolCall({
token: runtime.ownerToken,
name: "message",
args: { action: "send", target: "implicit-main", message: "not captured" },
})
).status,
).toBe(200);
expect(captured).toEqual([
expect.objectContaining({
toolName: "message",
args: { action: "send", target: "chat123", message: "sent" },
}),
]);
expect(startedTargets).toEqual(["chat123", "blocked"]);
expect(finishedTargets).toEqual(["chat123", "blocked"]);
});
it("updates capture accounting with hook-rewritten tool arguments", async () => {
const captureKey = "hook-rewritten-send";
const updatedCalls = vi.fn();
const finishedCalls = vi.fn();
beginMcpLoopbackToolCallCapture({
captureKey,
onToolCallUpdate: updatedCalls,
onToolCallFinish: finishedCalls,
onToolCallResult: vi.fn(),
});
runBeforeToolCallHookMock.mockResolvedValueOnce({
blocked: false,
params: {
action: "send",
target: "rewritten-target",
message: "rewritten send",
},
});
const { runtime } = await startLoopbackServerForTest();
await sendLoopbackToolCall({
token: runtime.ownerToken,
name: "message",
args: { action: "react", target: "original-target" },
headers: { "x-openclaw-cli-capture-key": captureKey },
});
expect(updatedCalls).toHaveBeenCalledWith({
previous: {
toolName: "message",
args: { action: "react", target: "original-target" },
},
current: {
toolName: "message",
args: {
action: "send",
target: "rewritten-target",
message: "rewritten send",
},
},
});
expect(finishedCalls).toHaveBeenCalledWith(
{
toolName: "message",
args: {
action: "send",
target: "rewritten-target",
message: "rewritten send",
},
},
{ prepared: true },
);
});
it("reports oversized successful calls without retaining their payloads", () => {
const captureKey = "oversized-capture";
const captured = vi.fn();
beginMcpLoopbackToolCallCapture({
captureKey,
onToolCallResult: captured,
});
const captureHandle = markMcpLoopbackToolCallStarted({
captureKey,
toolName: "message",
args: { action: "send", target: "chat123" },
});
if (!captureHandle) {
throw new Error("Expected active MCP capture");
}
recordMcpLoopbackToolCallResult({
captureHandle,
toolName: "message",
args: { action: "send", target: "chat123" },
result: { content: "x".repeat(20 * 1024) },
isError: false,
});
markMcpLoopbackToolCallFinished(captureHandle);
expect(captured).toHaveBeenCalledWith(
expect.objectContaining({
toolName: "message",
args: { action: "send", target: "chat123" },
}),
);
});
it("keeps admitted calls bound to their original capture generation", () => {
const captureKey = "generation-bound-capture";
const firstCapture = vi.fn();
const secondCapture = vi.fn();
beginMcpLoopbackToolCallCapture({
captureKey,
onToolCallResult: firstCapture,
});
const firstHandle = markMcpLoopbackToolCallStarted({
captureKey,
toolName: "message",
args: { action: "send", target: "first-turn" },
});
if (!firstHandle) {
throw new Error("Expected first MCP capture generation");
}
beginMcpLoopbackToolCallCapture({
captureKey,
onToolCallResult: secondCapture,
});
recordMcpLoopbackToolCallResult({
captureHandle: firstHandle,
toolName: "message",
args: { action: "send", target: "first-turn" },
result: { status: "sent" },
isError: false,
});
markMcpLoopbackToolCallFinished(firstHandle);
expect(firstCapture).toHaveBeenCalledOnce();
expect(secondCapture).not.toHaveBeenCalled();
});
it("binds slow request bodies to their capture generation at header acceptance", async () => {
const captureKey = "slow-request-generation";
const requestClassified = vi.fn();
const requestStarted = vi.fn();
const captured = vi.fn();
let resolveRequestStarted: (() => void) | undefined;
const requestStartedPromise = new Promise<void>((resolve) => {
resolveRequestStarted = resolve;
});
beginMcpLoopbackToolCallCapture({
captureKey,
onRequestStart: () => {
requestStarted();
resolveRequestStarted?.();
},
onRequestClassified: requestClassified,
onToolCallResult: captured,
});
const { runtime, port } = await startLoopbackServerForTest();
const responsePromise = new Promise<{ status: number | undefined; body: string }>(
(resolve, reject) => {
const req = request(
{
hostname: "127.0.0.1",
port,
path: "/mcp",
method: "POST",
headers: {
authorization: `Bearer ${runtime.ownerToken}`,
"content-type": "application/json",
"transfer-encoding": "chunked",
"x-openclaw-cli-capture-key": captureKey,
},
},
(res) => {
let body = "";
res.setEncoding("utf8");
res.on("data", (chunk) => {
body += chunk;
});
res.on("end", () => resolve({ status: res.statusCode, body }));
},
);
req.on("error", reject);
req.flushHeaders();
void requestStartedPromise.then(() => {
clearMcpLoopbackToolCallCapture(captureKey);
req.end(mcpToolCallBody("message", { action: "send", target: "late-body" }));
});
},
);
await requestStartedPromise;
expect(requestStarted).toHaveBeenCalledOnce();
expect(requestClassified).not.toHaveBeenCalled();
const response = await responsePromise;
expect(response.status).toBe(200);
expect(requestClassified).toHaveBeenCalledOnce();
expect(captured).toHaveBeenCalledWith(
expect.objectContaining({
toolName: "message",
args: { action: "send", target: "late-body" },
isError: false,
}),
);
});
it("waits through a quiet admission grace before clearing a failed-turn capture", async () => {
const captureKey = "admission-grace";
beginMcpLoopbackToolCallCapture({
captureKey,
onToolCallResult: vi.fn(),
});
const idlePromise = waitForMcpLoopbackToolCallCaptureIdle(captureKey, {
timeoutMs: 500,
admissionGraceMs: 40,
});
await new Promise<void>((resolve) => {
setTimeout(resolve, 10);
});
const captureHandle = markMcpLoopbackToolCallStarted({
captureKey,
toolName: "message",
args: { action: "send", target: "late-admission" },
});
if (!captureHandle) {
throw new Error("Expected late MCP capture admission");
}
setTimeout(() => markMcpLoopbackToolCallFinished(captureHandle), 10);
await expect(idlePromise).resolves.toBe(true);
});
it("keeps capture observer errors from changing tool success", async () => {
const captureKey = "throwing-observer";
beginMcpLoopbackToolCallCapture({
captureKey,
onToolCallResult: () => {
throw new Error("observer failed");
},
});
const { runtime } = await startLoopbackServerForTest();
const response = await sendLoopbackToolCall({
token: runtime.ownerToken,
name: "message",
args: { action: "send", target: "chat123", message: "sent" },
headers: { "x-openclaw-cli-capture-key": captureKey },
});
expect(response.status).toBe(200);
const payload = await readMcpPayload(response);
expect(payload.result?.isError).toBe(false);
});
it("captures partial-delivery errors before returning the tool failure", async () => {
const captureKey = "partial-delivery";
const captured = vi.fn();
beginMcpLoopbackToolCallCapture({
captureKey,
onToolCallResult: captured,
});
mockScopedTools([
makeMessageTool({
execute: async () => {
throw Object.assign(new Error("second chunk failed"), { sentBeforeError: true });
},
}),
]);
const { runtime } = await startLoopbackServerForTest();
const response = await sendLoopbackToolCall({
token: runtime.ownerToken,
name: "message",
args: { action: "send", target: "chat123", message: "sent partly" },
headers: { "x-openclaw-cli-capture-key": captureKey },
});
const payload = await readMcpPayload(response);
expect(payload.result?.isError).toBe(true);
expect(captured).toHaveBeenCalledWith(
expect.objectContaining({
toolName: "message",
isError: true,
result: expect.objectContaining({ sentBeforeError: true }),
}),
);
});
it("ignores calls after a capture is cleared", () => {
const captureKey = "cleared-capture";
const captured = vi.fn();
beginMcpLoopbackToolCallCapture({
captureKey,
onToolCallResult: captured,
});
clearMcpLoopbackToolCallCapturesForTest();
const captureHandle = markMcpLoopbackToolCallStarted({
captureKey,
toolName: "message",
args: { action: "send", target: "old-turn" },
});
expect(captureHandle).toBeUndefined();
expect(captured).not.toHaveBeenCalled();
});
it("calls healthy tools when an earlier loopback tool name is unreadable", async () => {
const messageExecute = vi.fn<MockGatewayTool["execute"]>(async () => ({
content: [{ type: "text", text: "MESSAGE_EXECUTED" }],
@@ -1289,6 +1651,9 @@ describe("createMcpLoopbackServerConfig", () => {
expect(
config.mcpServers?.openclaw?.headers?.["x-openclaw-require-explicit-message-target"],
).toBe("${OPENCLAW_MCP_REQUIRE_EXPLICIT_MESSAGE_TARGET}");
expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-cli-capture-key"]).toBe(
"${OPENCLAW_MCP_CLI_CAPTURE_KEY}",
);
expect(config.mcpServers?.openclaw?.headers).not.toHaveProperty("x-openclaw-sender-is-owner");
});
+79 -15
View File
@@ -14,13 +14,21 @@ import { logDebug, logWarn } from "../logger.js";
import { handleMcpJsonRpc } from "./mcp-http.handlers.js";
import {
clearActiveMcpLoopbackRuntimeByOwnerToken,
markMcpLoopbackRequestClassified,
markMcpLoopbackRequestFinished,
markMcpLoopbackRequestStarted,
markMcpLoopbackToolCallFinished,
markMcpLoopbackToolCallStarted,
recordMcpLoopbackToolCallResult,
setActiveMcpLoopbackRuntime,
updateMcpLoopbackToolCallCapture,
} from "./mcp-http.loopback-runtime.js";
import { jsonRpcError, type JsonRpcRequest } from "./mcp-http.protocol.js";
import {
isMcpHttpBodyTooLargeError,
isMcpHttpBodyTimeoutError,
readMcpHttpBody,
resolveMcpCliCaptureKey,
resolveMcpHttpBodyTimeoutMs,
resolveMcpRequestContext,
validateMcpLoopbackRequest,
@@ -180,12 +188,40 @@ export async function startMcpLoopbackServer(port = 0): Promise<{
return;
}
// Bind the request before body parsing/tool resolution. A CLI may exit while
// an accepted request is still uploading, and retries must not outrun it.
const cliRequestCaptureHandle = markMcpLoopbackRequestStarted(resolveMcpCliCaptureKey(req));
const requestAbort = createRequestAbortSignal(req, res);
void (async () => {
let parsed: JsonRpcRequest | JsonRpcRequest[] | undefined;
let cliCaptureHandles: Array<ReturnType<typeof markMcpLoopbackToolCallStarted>> = [];
try {
const body = await readMcpHttpBody(req, { timeoutMs: resolveMcpHttpBodyTimeoutMs() });
parsed = parseMcpJsonBody(body);
const messages = Array.isArray(parsed) ? parsed : [parsed];
cliCaptureHandles = messages.map((message) => {
if (
!cliRequestCaptureHandle ||
!isJsonRpcRequest(message) ||
message.method !== "tools/call"
) {
return undefined;
}
const admittedToolName =
isRecord(message.params) && typeof message.params.name === "string"
? message.params.name
: "";
const toolArgs =
isRecord(message.params) && isRecord(message.params.arguments)
? message.params.arguments
: {};
return markMcpLoopbackToolCallStarted({
requestCaptureHandle: cliRequestCaptureHandle,
toolName: admittedToolName,
args: toolArgs,
});
});
markMcpLoopbackRequestClassified(cliRequestCaptureHandle);
const cfg = getRuntimeConfig();
const requestContext = resolveMcpRequestContext(req, cfg, auth);
const scopedTools = toolCache.resolve({
@@ -203,7 +239,6 @@ export async function startMcpLoopbackServer(port = 0): Promise<{
senderIsOwner: requestContext.senderIsOwner,
});
const messages = Array.isArray(parsed) ? parsed : [parsed];
logMcpLoopbackTraffic("request", {
batchSize: messages.length,
methods: messages.map((message) =>
@@ -216,24 +251,49 @@ export async function startMcpLoopbackServer(port = 0): Promise<{
cronVisible: scopedTools.toolSchema.some((tool) => tool.name === "cron"),
});
const responses: object[] = [];
for (const message of messages) {
for (const [messageIndex, message] of messages.entries()) {
if (!isJsonRpcRequest(message)) {
responses.push(jsonRpcError(readJsonRpcRequestId(message), -32600, "Invalid Request"));
continue;
}
const response = await handleMcpJsonRpc({
message,
tools: scopedTools.tools,
toolSchema: scopedTools.toolSchema,
hookContext: {
agentId: scopedTools.agentId,
config: cfg,
sessionKey: requestContext.sessionKey,
},
signal: requestAbort.signal,
});
const cliCaptureHandle = cliCaptureHandles[messageIndex];
let response: object | null;
try {
response = await handleMcpJsonRpc({
message,
tools: scopedTools.tools,
toolSchema: scopedTools.toolSchema,
hookContext: {
agentId: scopedTools.agentId,
config: cfg,
sessionKey: requestContext.sessionKey,
},
signal: requestAbort.signal,
onToolCallPrepared: cliCaptureHandle
? ({ toolName: preparedToolName, args }) => {
updateMcpLoopbackToolCallCapture(cliCaptureHandle, {
toolName: preparedToolName,
args,
});
}
: undefined,
onToolCallResult: cliCaptureHandle
? ({ toolName: resultToolName, args, result, isError }) => {
recordMcpLoopbackToolCallResult({
captureHandle: cliCaptureHandle,
toolName: resultToolName,
args,
result,
isError,
});
}
: undefined,
});
} finally {
markMcpLoopbackToolCallFinished(cliCaptureHandle);
}
if (response !== null) {
const toolName =
const responseToolName =
message.method === "tools/call" && isRecord(message.params)
? message.params.name
: undefined;
@@ -241,7 +301,7 @@ export async function startMcpLoopbackServer(port = 0): Promise<{
isRecord(response) && isRecord(response.result) && response.result.isError === true;
logMcpLoopbackTraffic("response", {
method: message.method,
toolName: typeof toolName === "string" ? toolName : undefined,
toolName: typeof responseToolName === "string" ? responseToolName : undefined,
isError,
});
responses.push(response);
@@ -285,6 +345,10 @@ export async function startMcpLoopbackServer(port = 0): Promise<{
}
} finally {
requestAbort.cleanup();
for (const captureHandle of cliCaptureHandles) {
markMcpLoopbackToolCallFinished(captureHandle);
}
markMcpLoopbackRequestFinished(cliRequestCaptureHandle);
}
})();
});
+120
View File
@@ -0,0 +1,120 @@
// Internal source-reply policy is shared by message execution and CLI delivery capture.
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options.types.js";
import type { ChannelThreadingToolContext } from "../../channels/plugins/types.public.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { parseAgentSessionKey, parseThreadSessionSuffix } from "../../routing/session-key.js";
import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js";
import { resolveOutboundChannelPlugin } from "./channel-resolution.js";
import { isConfiguredChannel, listConfiguredMessageChannels } from "./channel-selection.js";
type InternalSourceReplySinkInput = {
cfg: OpenClawConfig;
action: string;
toolContext?: ChannelThreadingToolContext;
sessionKey?: string;
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
};
const SESSION_DELIVERY_PEER_KINDS = new Set(["channel", "direct", "dm", "group"]);
function hasExternalSessionDeliveryRoute(sessionKey: string | undefined): boolean {
const parsedThread = parseThreadSessionSuffix(sessionKey);
const baseSessionKey = parsedThread.baseSessionKey ?? sessionKey;
const parsed = parseAgentSessionKey(baseSessionKey);
if (!parsed) {
return false;
}
const parts = parsed.rest.split(":").filter(Boolean);
if (parts.length < 3) {
return false;
}
const channel = normalizeMessageChannel(parts[0]);
if (!channel || channel === INTERNAL_MESSAGE_CHANNEL) {
return false;
}
if (parts.length >= 4 && (parts[2] === "direct" || parts[2] === "dm")) {
return Boolean(parts.slice(3).join(":").trim());
}
return (
SESSION_DELIVERY_PEER_KINDS.has(parts[1] ?? "") && Boolean(parts.slice(2).join(":").trim())
);
}
function hasExplicitRouteParam(params: Record<string, unknown>): boolean {
for (const key of ["channel", "target", "to", "channelId"]) {
if (normalizeOptionalString(params[key])) {
return true;
}
}
return (
Array.isArray(params.targets) && params.targets.some((value) => normalizeOptionalString(value))
);
}
function hasCurrentSourceReplyContext(input: InternalSourceReplySinkInput): boolean {
const provider = normalizeOptionalLowercaseString(input.toolContext?.currentChannelProvider);
if (!provider) {
return false;
}
if (provider === INTERNAL_MESSAGE_CHANNEL) {
// The message tool replaces ambient webchat context with an external route
// encoded in the session key. Do not classify that route as a private sink.
return !hasExternalSessionDeliveryRoute(input.sessionKey);
}
const currentMessageId = input.toolContext?.currentMessageId;
return Boolean(
normalizeOptionalString(input.toolContext?.currentChannelId) ||
normalizeOptionalString(input.toolContext?.currentMessagingTarget) ||
normalizeOptionalString(input.toolContext?.currentThreadTs) ||
(typeof currentMessageId === "number" && Number.isFinite(currentMessageId)) ||
normalizeOptionalString(currentMessageId),
);
}
async function hasConfiguredCurrentSourceChannel(
input: InternalSourceReplySinkInput,
): Promise<boolean> {
const provider =
normalizeMessageChannel(input.toolContext?.currentChannelProvider) ??
normalizeOptionalLowercaseString(input.toolContext?.currentChannelProvider);
if (!provider || provider === INTERNAL_MESSAGE_CHANNEL) {
return false;
}
if (!isConfiguredChannel(input.cfg, provider)) {
return false;
}
if (!resolveOutboundChannelPlugin({ channel: provider, cfg: input.cfg, allowBootstrap: true })) {
return false;
}
const configuredChannels = await listConfiguredMessageChannels(input.cfg);
return configuredChannels.some((channel) => channel === provider);
}
/** Return whether this send resolves to the private current-run source-reply sink. */
export async function shouldUseInternalSourceReplySink(
input: InternalSourceReplySinkInput,
params: Record<string, unknown>,
): Promise<boolean> {
const hasImplicitCurrentSourceRoute =
input.action === "send" &&
input.sourceReplyDeliveryMode === "message_tool_only" &&
hasCurrentSourceReplyContext(input) &&
Boolean(input.sessionKey?.trim()) &&
!hasExplicitRouteParam(params);
if (!hasImplicitCurrentSourceRoute) {
return false;
}
if (
!normalizeOptionalString(input.toolContext?.currentChannelId) &&
!normalizeOptionalString(input.toolContext?.currentMessagingTarget)
) {
return true;
}
// Configured current-source channels can infer the target and deliver through
// the normal plugin path; the sink is only the private fallback.
return !(await hasConfiguredCurrentSourceChannel(input));
}
@@ -710,6 +710,134 @@ describe("runMessageAction plugin dispatch", () => {
);
});
it("preserves gateway send receipts in broadcast results", async () => {
const gatewayPlugin = createGatewayActionPlugin({
pluginId: "gatewaychat",
label: "Gateway Chat",
blurb: "Gateway Chat broadcast test plugin.",
actions: ["send"],
messaging: {
targetResolver: {
looksLikeId: () => true,
},
},
handleAction: vi.fn(async () => jsonResult({ ok: true })),
});
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "gatewaychat",
source: "test",
plugin: gatewayPlugin,
},
]),
);
mocks.callGatewayLeastPrivilege.mockResolvedValue({
ok: true,
messageId: "gw-broadcast-1",
});
const result = await runMessageAction({
cfg: {
channels: {
gatewaychat: {
enabled: true,
},
},
} as OpenClawConfig,
action: "broadcast",
params: {
channel: "gatewaychat",
targets: ["user-123"],
message: "hello from broadcast",
},
gateway: {
clientName: "cli",
mode: "cli",
},
});
expect(result).toMatchObject({
kind: "broadcast",
payload: {
results: [
{
channel: "gatewaychat",
to: "user-123",
ok: true,
payload: {
ok: true,
messageId: "gw-broadcast-1",
},
},
],
},
});
expect(mocks.executeSendAction).not.toHaveBeenCalled();
});
it("preserves partial-delivery evidence from failed broadcast sends", async () => {
const gatewayPlugin = createGatewayActionPlugin({
pluginId: "gatewaychat",
label: "Gateway Chat",
blurb: "Gateway Chat partial broadcast test plugin.",
actions: ["send"],
messaging: {
targetResolver: {
looksLikeId: () => true,
},
},
handleAction: vi.fn(async () => jsonResult({ ok: true })),
});
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "gatewaychat",
source: "test",
plugin: gatewayPlugin,
},
]),
);
mocks.callGatewayLeastPrivilege.mockRejectedValue(
Object.assign(new Error("second payload failed"), { sentBeforeError: true }),
);
const result = await runMessageAction({
cfg: {
channels: {
gatewaychat: {
enabled: true,
},
},
} as OpenClawConfig,
action: "broadcast",
params: {
channel: "gatewaychat",
targets: ["user-123"],
message: "hello from broadcast",
},
gateway: {
clientName: "cli",
mode: "cli",
},
});
expect(result).toMatchObject({
kind: "broadcast",
payload: {
results: [
{
channel: "gatewaychat",
to: "user-123",
ok: false,
sentBeforeError: true,
error: "second payload failed",
},
],
},
});
});
it("preserves buffer-only send bytes for gateway-side materialization", async () => {
const gatewayPlugin = createGatewayActionPlugin({
pluginId: "gatewaychat",
+11 -72
View File
@@ -44,7 +44,6 @@ import { stripFormattedReasoningMessage } from "../../shared/text/formatted-reas
import { parseInlineDirectives } from "../../utils/directive-tags.js";
import {
INTERNAL_MESSAGE_CHANNEL,
normalizeMessageChannel,
type GatewayClientMode,
type GatewayClientName,
} from "../../utils/message-channel.js";
@@ -52,11 +51,11 @@ import { formatErrorMessage } from "../errors.js";
import { throwIfAborted } from "./abort.js";
import { resolveOutboundChannelPlugin } from "./channel-resolution.js";
import {
isConfiguredChannel,
listConfiguredMessageChannels,
resolveMessageChannelSelection,
} from "./channel-selection.js";
import type { OutboundSendDeps } from "./deliver.js";
import { shouldUseInternalSourceReplySink } from "./internal-source-reply.js";
import { normalizeMessageActionInput } from "./message-action-normalization.js";
import {
collectActionMediaSourceHints,
@@ -160,6 +159,8 @@ export type MessageActionRunResult =
to: string;
ok: boolean;
error?: string;
sentBeforeError?: true;
payload?: unknown;
result?: MessageSendResult;
}>;
};
@@ -524,17 +525,6 @@ function collectMessageAttachmentMediaHints(value: unknown): string[] {
return mediaUrls;
}
function hasExplicitRouteParam(params: Record<string, unknown>): boolean {
for (const key of ["channel", "target", "to", "channelId"]) {
if (normalizeOptionalString(params[key])) {
return true;
}
}
return (
Array.isArray(params.targets) && params.targets.some((value) => normalizeOptionalString(value))
);
}
function hasExplicitTargetParam(params: Record<string, unknown>): boolean {
for (const key of ["target", "to", "channelId"]) {
if (normalizeOptionalString(params[key])) {
@@ -627,65 +617,6 @@ function applyImplicitSourceReplySendPolicy(
params.bestEffort = true;
}
function hasCurrentSourceReplyContext(input: RunMessageActionParams): boolean {
const provider = normalizeOptionalLowercaseString(input.toolContext?.currentChannelProvider);
if (!provider) {
return false;
}
if (provider === INTERNAL_MESSAGE_CHANNEL) {
return true;
}
const currentMessageId = input.toolContext?.currentMessageId;
return Boolean(
normalizeOptionalString(input.toolContext?.currentChannelId) ||
normalizeOptionalString(input.toolContext?.currentMessagingTarget) ||
normalizeOptionalString(input.toolContext?.currentThreadTs) ||
(typeof currentMessageId === "number" && Number.isFinite(currentMessageId)) ||
normalizeOptionalString(currentMessageId),
);
}
async function hasConfiguredCurrentSourceChannel(input: RunMessageActionParams): Promise<boolean> {
const provider =
normalizeMessageChannel(input.toolContext?.currentChannelProvider) ??
normalizeOptionalLowercaseString(input.toolContext?.currentChannelProvider);
if (!provider || provider === INTERNAL_MESSAGE_CHANNEL) {
return false;
}
if (!isConfiguredChannel(input.cfg, provider)) {
return false;
}
if (!resolveOutboundChannelPlugin({ channel: provider, cfg: input.cfg, allowBootstrap: true })) {
return false;
}
const configuredChannels = await listConfiguredMessageChannels(input.cfg);
return configuredChannels.some((channel) => channel === provider);
}
async function shouldUseInternalSourceReplySink(
input: RunMessageActionParams,
params: Record<string, unknown>,
) {
const hasImplicitCurrentSourceRoute =
input.action === "send" &&
input.sourceReplyDeliveryMode === "message_tool_only" &&
hasCurrentSourceReplyContext(input) &&
Boolean(input.sessionKey?.trim()) &&
!hasExplicitRouteParam(params);
if (!hasImplicitCurrentSourceRoute) {
return false;
}
if (
!normalizeOptionalString(input.toolContext?.currentChannelId) &&
!normalizeOptionalString(input.toolContext?.currentMessagingTarget)
) {
return true;
}
// Configured current-source channels can infer the target and deliver through
// the normal plugin path; the sink is only the private fallback.
return !(await hasConfiguredCurrentSourceChannel(input));
}
async function runGatewayPluginMessageActionOrNull(params: {
cfg: OpenClawConfig;
params: Record<string, unknown>;
@@ -776,6 +707,8 @@ async function handleBroadcastAction(
to: string;
ok: boolean;
error?: string;
sentBeforeError?: true;
payload?: unknown;
result?: MessageSendResult;
}> = [];
const isAbortError = (err: unknown): boolean => err instanceof Error && err.name === "AbortError";
@@ -802,6 +735,7 @@ async function handleBroadcastAction(
channel: targetChannel,
to: resolved.to,
ok: true,
payload: sendResult.kind === "send" ? sendResult.payload : undefined,
result: sendResult.kind === "send" ? sendResult.sendResult : undefined,
});
} catch (err) {
@@ -813,6 +747,11 @@ async function handleBroadcastAction(
to: target,
ok: false,
error: formatErrorMessage(err),
...(err &&
typeof err === "object" &&
(err as { sentBeforeError?: unknown }).sentBeforeError === true
? { sentBeforeError: true as const }
: {}),
});
}
}
+13
View File
@@ -486,6 +486,19 @@ describe("sendMessage", () => {
expect(mocks.resolveRuntimePluginRegistry).not.toHaveBeenCalled();
});
it("preserves suppressed direct-send status", async () => {
mocks.deliverOutboundPayloads.mockResolvedValueOnce([]);
const result = await sendMessage({
cfg: {},
channel: "forum",
to: "123456",
content: "hidden",
});
expect(result.deliveryStatus).toBe("suppressed");
});
it("does not throw best-effort direct send failures", async () => {
mocks.deliverOutboundPayloads.mockImplementationOnce(async (params: unknown) => {
(
+2
View File
@@ -100,6 +100,7 @@ export type MessageSendResult = {
mediaUrl: string | null;
mediaUrls?: string[];
result?: OutboundDeliveryResult | { messageId: string };
deliveryStatus?: "suppressed";
dryRun?: boolean;
};
@@ -413,6 +414,7 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
mediaUrl: primaryMediaUrl,
mediaUrls: mirrorMediaUrls.length ? mirrorMediaUrls : undefined,
result: results.at(-1),
...(send.status === "suppressed" ? { deliveryStatus: "suppressed" as const } : {}),
};
}