mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
fix(agents): preserve CLI message delivery evidence
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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?.();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
+826
-303
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -188,6 +188,8 @@ export type PreparedCliRunContext = {
|
||||
authEpoch?: string;
|
||||
authEpochVersion: number;
|
||||
extraSystemPromptHash?: string;
|
||||
messageToolPolicyHash?: string;
|
||||
promptToolNamesHash?: string;
|
||||
cwdHash?: string;
|
||||
mcpDeliveryCapture?: true;
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -51,6 +51,7 @@ export type CliSessionBinding = {
|
||||
authEpoch?: string;
|
||||
authEpochVersion?: number;
|
||||
extraSystemPromptHash?: string;
|
||||
messageToolPolicyHash?: string;
|
||||
promptToolNamesHash?: string;
|
||||
cwdHash?: string;
|
||||
mcpConfigHash?: string;
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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}",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
(
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user