mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(agents): split subscribe leaf ownership (#122249)
* refactor(agents): split embedded tool result ownership * refactor(agents): split embedded message ownership
This commit is contained in:
committed by
GitHub
parent
01804a7531
commit
87b3c0e5df
@@ -385,11 +385,8 @@ src/agents/embedded-agent-runner/tool-result-truncation.test.ts
|
||||
src/agents/embedded-agent-runner/tool-result-truncation.ts
|
||||
src/agents/embedded-agent-runner/transcript-file-state.test.ts
|
||||
src/agents/embedded-agent-subscribe.handlers.lifecycle.test.ts
|
||||
src/agents/embedded-agent-subscribe.handlers.messages.test.ts
|
||||
src/agents/embedded-agent-subscribe.handlers.messages.ts
|
||||
src/agents/embedded-agent-subscribe.handlers.tools.test.ts
|
||||
src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.subscribeembeddedagentsession.test.ts
|
||||
src/agents/embedded-agent-subscribe.tools.ts
|
||||
src/agents/embedded-agent-subscribe.ts
|
||||
src/agents/failover-error.test.ts
|
||||
src/agents/failover-error.ts
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
CliToolUseStartDelta,
|
||||
} from "../cli-output-contracts.js";
|
||||
import type { ToolSummaryTrace } from "../embedded-agent-runner/types.js";
|
||||
import { sanitizeToolArgs, sanitizeToolResult } from "../embedded-agent-subscribe.tools.js";
|
||||
import { sanitizeToolArgs, sanitizeToolResult } from "../embedded-agent-tool-results.js";
|
||||
import { applyPluginTextReplacements } from "../plugin-text-transforms.js";
|
||||
import { resolveCliToolTerminalReason } from "../run-termination.js";
|
||||
import type { CliToolTracking } from "./execute-tool-tracking.js";
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import crypto from "node:crypto";
|
||||
import { extractMessagingToolSend } from "../embedded-agent-messaging-extraction.js";
|
||||
import { isMessagingToolTargetEvidenceAction } from "../embedded-agent-messaging.js";
|
||||
import type { MessagingToolSend } from "../embedded-agent-messaging.types.js";
|
||||
import {
|
||||
collectMessagingMediaUrlsFromRecord,
|
||||
collectMessagingMediaUrlsFromToolResult,
|
||||
extractMessagingToolSend,
|
||||
} from "../embedded-agent-subscribe.tools.js";
|
||||
} from "../embedded-agent-tool-media.js";
|
||||
import { stripOpenClawMcpToolPrefix } from "./tool-policy.js";
|
||||
import type { PreparedCliRunContext } from "./types.js";
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
isDeliveredMessagingToolResult,
|
||||
resolveMessageToolSourceReplyFinal,
|
||||
} from "../embedded-agent-message-tool-source-reply.js";
|
||||
import {
|
||||
extractMessagingToolSendResult,
|
||||
extractMessagingToolSourceReplyPayload,
|
||||
} from "../embedded-agent-messaging-extraction.js";
|
||||
import {
|
||||
isMessagingTool,
|
||||
isMessagingToolDeliveryAction,
|
||||
@@ -22,10 +26,6 @@ import type {
|
||||
MessagingToolSend,
|
||||
MessagingToolSourceReplyPayload,
|
||||
} from "../embedded-agent-messaging.types.js";
|
||||
import {
|
||||
extractMessagingToolSendResult,
|
||||
extractMessagingToolSourceReplyPayload,
|
||||
} from "../embedded-agent-subscribe.tools.js";
|
||||
import { closeClaudeSession } from "./claude-live-registry.js";
|
||||
import { attachCliMessagingDeliveryEvidence } from "./delivery-evidence.js";
|
||||
import {
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
isMessageToolSendActionName,
|
||||
isMessagingToolDeliveryAction,
|
||||
} from "./embedded-agent-messaging.js";
|
||||
import { isToolResultError } from "./embedded-agent-subscribe.tools.js";
|
||||
import { normalizeToolPolicyName } from "./tool-policy.js";
|
||||
import { isToolResultError } from "./tool-result-error.js";
|
||||
|
||||
const MESSAGE_TOOL_NAME = "message";
|
||||
const SESSIONS_SEND_TOOL_NAME = "sessions_send";
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
/** Extracts message delivery evidence from embedded-agent tool calls and results. */
|
||||
import { asOptionalRecord as readRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import {
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
normalizeOptionalStringifiedId,
|
||||
readStringValue,
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
||||
import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
|
||||
import type { ChannelMessageActionName } from "../channels/plugins/types.public.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { normalizeTargetForProvider } from "../infra/outbound/target-normalization.js";
|
||||
import {
|
||||
normalizeLegacyInteractiveReply,
|
||||
normalizeMessagePresentation,
|
||||
} from "../interactive/payload.js";
|
||||
import { isMessagingToolTargetEvidenceAction } from "./embedded-agent-messaging.js";
|
||||
import type {
|
||||
MessagingToolSend,
|
||||
MessagingToolSourceReplyPayload,
|
||||
} from "./embedded-agent-messaging.types.js";
|
||||
import { readToolResultDetails } from "./tool-result-error.js";
|
||||
|
||||
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 = normalizeLegacyInteractiveReply(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.
|
||||
|
||||
function resolveMessageToolTarget(params: {
|
||||
action: string;
|
||||
args: Record<string, unknown>;
|
||||
providerId: string | null;
|
||||
currentChannelId?: string;
|
||||
currentMessagingTarget?: string;
|
||||
}): string | undefined {
|
||||
const directTarget =
|
||||
normalizeOptionalString(params.args.target) ??
|
||||
normalizeOptionalString(params.args.to) ??
|
||||
normalizeOptionalString(params.args.channelId);
|
||||
if (directTarget) {
|
||||
return directTarget;
|
||||
}
|
||||
const aliases = params.providerId
|
||||
? getChannelPlugin(params.providerId)?.actions?.messageActionTargetAliases?.[
|
||||
params.action as ChannelMessageActionName
|
||||
]?.deliveryTargetAliases
|
||||
: undefined;
|
||||
for (const alias of aliases ?? []) {
|
||||
const aliasTarget = normalizeOptionalStringifiedId(params.args[alias]);
|
||||
if (aliasTarget) {
|
||||
return aliasTarget;
|
||||
}
|
||||
}
|
||||
return params.currentMessagingTarget ?? params.currentChannelId;
|
||||
}
|
||||
|
||||
function resolveMessagingToolThreadEvidence(params: {
|
||||
providerId: string;
|
||||
to: string;
|
||||
accountId?: string;
|
||||
threadId?: string;
|
||||
replyToId?: string;
|
||||
allowImplicitThread: boolean;
|
||||
threadSuppressed: boolean;
|
||||
options?: {
|
||||
config?: OpenClawConfig;
|
||||
currentChannelId?: string;
|
||||
currentMessagingTarget?: string;
|
||||
currentThreadId?: string;
|
||||
currentMessageId?: string | number;
|
||||
replyToMode?: "off" | "first" | "all" | "batched";
|
||||
hasRepliedRef?: { value: boolean };
|
||||
};
|
||||
}): Pick<MessagingToolSend, "threadId" | "threadImplicit" | "threadSuppressed"> {
|
||||
const threading = getChannelPlugin(params.providerId)?.threading;
|
||||
const autoThreadResolver = params.allowImplicitThread
|
||||
? threading?.resolveAutoThreadId
|
||||
: undefined;
|
||||
const replyTransport = params.replyToId
|
||||
? threading?.resolveReplyTransport?.({
|
||||
cfg: params.options?.config ?? {},
|
||||
accountId: params.accountId,
|
||||
threadId: params.threadId,
|
||||
replyToId: params.replyToId,
|
||||
})
|
||||
: undefined;
|
||||
const transportThreadId = normalizeOptionalStringifiedId(replyTransport?.threadId);
|
||||
const replyToThreadId =
|
||||
replyTransport?.threadId === null
|
||||
? normalizeOptionalString(replyTransport.replyToId)
|
||||
: undefined;
|
||||
const explicitThreadId = transportThreadId ?? replyToThreadId ?? params.threadId;
|
||||
const currentChannelId = normalizeOptionalString(params.options?.currentChannelId);
|
||||
const currentMessagingTarget = normalizeOptionalString(params.options?.currentMessagingTarget);
|
||||
const currentThreadId = normalizeOptionalString(params.options?.currentThreadId);
|
||||
const replyToMode = params.options?.replyToMode ?? (currentThreadId ? "all" : undefined);
|
||||
const canResolveCurrentThread = Boolean(
|
||||
(currentChannelId || currentMessagingTarget) && currentThreadId,
|
||||
);
|
||||
const resolvedCurrentThreadId =
|
||||
!explicitThreadId && !params.threadSuppressed && autoThreadResolver && canResolveCurrentThread
|
||||
? autoThreadResolver({
|
||||
cfg: params.options?.config ?? {},
|
||||
accountId: params.accountId,
|
||||
to: params.to,
|
||||
replyToId: params.replyToId,
|
||||
toolContext: {
|
||||
currentChannelId,
|
||||
currentMessagingTarget,
|
||||
currentThreadTs: currentThreadId,
|
||||
currentMessageId: params.options?.currentMessageId,
|
||||
replyToMode,
|
||||
hasRepliedRef: params.options?.hasRepliedRef,
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
const threadImplicit =
|
||||
!explicitThreadId &&
|
||||
!params.threadSuppressed &&
|
||||
Boolean(autoThreadResolver) &&
|
||||
(!canResolveCurrentThread || Boolean(resolvedCurrentThreadId));
|
||||
return {
|
||||
...((explicitThreadId ?? resolvedCurrentThreadId)
|
||||
? { threadId: explicitThreadId ?? resolvedCurrentThreadId }
|
||||
: {}),
|
||||
...(threadImplicit ? { threadImplicit: true } : {}),
|
||||
...(params.threadSuppressed ? { threadSuppressed: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function extractMessagingToolSend(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
options?: {
|
||||
config?: OpenClawConfig;
|
||||
currentChannelId?: string;
|
||||
currentMessagingTarget?: string;
|
||||
currentThreadId?: string;
|
||||
currentMessageId?: string | number;
|
||||
replyToMode?: "off" | "first" | "all" | "batched";
|
||||
hasRepliedRef?: { value: boolean };
|
||||
},
|
||||
): MessagingToolSend | undefined {
|
||||
// Provider docking: new provider tools must implement plugin.actions.extractToolSend.
|
||||
const action = normalizeOptionalString(args.action) ?? "";
|
||||
const accountId = normalizeOptionalString(args.accountId);
|
||||
if (toolName === "conversations_send" || toolName === "conversations_turn") {
|
||||
const conversationRef = normalizeOptionalString(args.conversationRef);
|
||||
return conversationRef
|
||||
? {
|
||||
tool: toolName,
|
||||
provider: "conversation",
|
||||
to: conversationRef,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
if (toolName === "message") {
|
||||
if (!isMessagingToolTargetEvidenceAction(toolName, args)) {
|
||||
return undefined;
|
||||
}
|
||||
const providerRaw = normalizeOptionalString(args.provider) ?? "";
|
||||
const channelRaw = normalizeOptionalString(args.channel) ?? "";
|
||||
const providerHint = providerRaw || channelRaw;
|
||||
const providerId = providerHint ? normalizeChannelId(providerHint) : null;
|
||||
const toRaw = resolveMessageToolTarget({
|
||||
action,
|
||||
args,
|
||||
providerId,
|
||||
currentChannelId: options?.currentChannelId,
|
||||
currentMessagingTarget: options?.currentMessagingTarget,
|
||||
});
|
||||
if (!toRaw) {
|
||||
return undefined;
|
||||
}
|
||||
const provider = providerId ?? normalizeOptionalLowercaseString(providerHint) ?? "message";
|
||||
const to = normalizeTargetForProvider(provider, toRaw);
|
||||
const pluginExtractionArgs = { ...args, to: toRaw };
|
||||
const pluginExtracted = providerId
|
||||
? getChannelPlugin(providerId)?.actions?.extractToolSend?.({ args: pluginExtractionArgs })
|
||||
: null;
|
||||
const resolvedAccountId = normalizeOptionalString(pluginExtracted?.accountId) ?? accountId;
|
||||
const threadId =
|
||||
normalizeOptionalString(pluginExtracted?.threadId) ?? normalizeOptionalString(args.threadId);
|
||||
const replyToId = normalizeOptionalString(args.replyTo);
|
||||
// Normal sends use prepared core delivery, where provider transport owns
|
||||
// reply/thread precedence. Other send-like actions use plugin dispatch.
|
||||
const outboundReplyToId = action === "send" ? replyToId : undefined;
|
||||
const threadSuppressed =
|
||||
pluginExtracted?.threadSuppressed === true ||
|
||||
args.topLevel === true ||
|
||||
args.threadId === null;
|
||||
return to
|
||||
? {
|
||||
tool: toolName,
|
||||
provider,
|
||||
accountId: resolvedAccountId,
|
||||
to,
|
||||
...(providerId
|
||||
? resolveMessagingToolThreadEvidence({
|
||||
providerId,
|
||||
to,
|
||||
accountId: resolvedAccountId,
|
||||
threadId,
|
||||
replyToId: outboundReplyToId,
|
||||
allowImplicitThread: pluginExtracted
|
||||
? pluginExtracted.threadImplicit === true
|
||||
: true,
|
||||
threadSuppressed,
|
||||
options,
|
||||
})
|
||||
: {
|
||||
...(threadId ? { threadId } : {}),
|
||||
...(threadSuppressed ? { threadSuppressed: true } : {}),
|
||||
}),
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const providerId = normalizeChannelId(toolName);
|
||||
if (!providerId) {
|
||||
return undefined;
|
||||
}
|
||||
const plugin = getChannelPlugin(providerId);
|
||||
const extracted = plugin?.actions?.extractToolSend?.({ args });
|
||||
if (!extracted?.to) {
|
||||
return undefined;
|
||||
}
|
||||
const to = normalizeTargetForProvider(providerId, extracted.to);
|
||||
const threadId = normalizeOptionalString(extracted.threadId);
|
||||
const threadSuppressed = extracted.threadSuppressed === true;
|
||||
const extractedAccountId = normalizeOptionalString(extracted.accountId) ?? accountId;
|
||||
const nativeReplyToMode = options?.replyToMode;
|
||||
const nativeSingleUseMode = nativeReplyToMode === "first" || nativeReplyToMode === "batched";
|
||||
const canResolveNativeImplicitThread =
|
||||
extracted.threadImplicit === true &&
|
||||
nativeReplyToMode !== undefined &&
|
||||
(!nativeSingleUseMode || options?.hasRepliedRef !== undefined);
|
||||
return to
|
||||
? {
|
||||
tool: toolName,
|
||||
provider: providerId,
|
||||
accountId: extractedAccountId,
|
||||
to,
|
||||
...resolveMessagingToolThreadEvidence({
|
||||
providerId,
|
||||
to,
|
||||
accountId: extractedAccountId,
|
||||
threadId,
|
||||
allowImplicitThread: canResolveNativeImplicitThread,
|
||||
threadSuppressed,
|
||||
options,
|
||||
}),
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Reconciles pending send evidence with the provider's successful action result. */
|
||||
export function extractMessagingToolSendResult(
|
||||
pending: MessagingToolSend,
|
||||
result: unknown,
|
||||
): MessagingToolSend {
|
||||
const providerId = normalizeChannelId(pending.provider);
|
||||
const extracted = providerId
|
||||
? getChannelPlugin(providerId)?.actions?.extractToolSendResult?.({
|
||||
result,
|
||||
send: {
|
||||
to: pending.to ?? "",
|
||||
accountId: pending.accountId,
|
||||
threadId: pending.threadId,
|
||||
threadImplicit: pending.threadImplicit,
|
||||
threadSuppressed: pending.threadSuppressed,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
if (!extracted?.to) {
|
||||
return pending;
|
||||
}
|
||||
const extractedThreadId = normalizeOptionalString(extracted.threadId);
|
||||
const providerReportedThread =
|
||||
extractedThreadId != null ||
|
||||
extracted.threadImplicit === true ||
|
||||
extracted.threadSuppressed === true;
|
||||
// Thread route fields are one state. Mixing provider and pending values can
|
||||
// create contradictory implicit and suppressed evidence.
|
||||
const threadEvidence = providerReportedThread ? extracted : pending;
|
||||
return {
|
||||
...pending,
|
||||
...extracted,
|
||||
accountId: normalizeOptionalString(extracted.accountId) ?? pending.accountId,
|
||||
to: normalizeTargetForProvider(providerId ?? pending.provider, extracted.to),
|
||||
threadId: normalizeOptionalString(threadEvidence.threadId),
|
||||
threadImplicit: threadEvidence.threadImplicit === true ? true : undefined,
|
||||
threadSuppressed: threadEvidence.threadSuppressed === true ? true : undefined,
|
||||
};
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import { runBestEffortCallback } from "./embedded-agent-subscribe.callback.js";
|
||||
import {
|
||||
consumePendingToolMediaReply,
|
||||
hasAssistantVisibleReply,
|
||||
} from "./embedded-agent-subscribe.handlers.messages.js";
|
||||
} from "./embedded-agent-subscribe.handlers.messages.replies.js";
|
||||
import type { EmbeddedAgentSubscribeContext } from "./embedded-agent-subscribe.handlers.types.js";
|
||||
import { isAssistantMessage } from "./embedded-agent-utils.js";
|
||||
import type { AgentSessionEvent } from "./sessions/index.js";
|
||||
|
||||
@@ -0,0 +1,699 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createStreamingDirectiveAccumulator } from "../auto-reply/reply/streaming-directives.js";
|
||||
import {
|
||||
createMessageEndContext,
|
||||
createMessageToolEnvelope,
|
||||
endMessage,
|
||||
firstMockCall,
|
||||
firstMockArg,
|
||||
} from "./embedded-agent-subscribe.handlers.messages.test-helpers.js";
|
||||
import { createOpenAiResponsesTextBlock } from "./embedded-agent-subscribe.openai-responses.test-helpers.js";
|
||||
|
||||
describe("handleMessageEnd", () => {
|
||||
it.each(["answer part A msg [[E1008]timeout] answer part B", "answer ending ["])(
|
||||
"keeps malformed directive-looking final text identical across delivery paths: %s",
|
||||
(text) => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const emitBlockReply = vi.fn();
|
||||
const flushBlockReplyBuffer = vi.fn();
|
||||
const accumulator = createStreamingDirectiveAccumulator();
|
||||
const streamed = accumulator.consume(text)?.text ?? "";
|
||||
const ctx = createMessageEndContext({
|
||||
onAgentEvent,
|
||||
emitBlockReply,
|
||||
flushBlockReplyBuffer,
|
||||
consumeReplyDirectives: vi.fn((chunk: string, options?: { final?: boolean }) =>
|
||||
accumulator.consume(chunk, options),
|
||||
),
|
||||
blockChunker: {
|
||||
hasBuffered: () => true,
|
||||
reset: vi.fn(),
|
||||
},
|
||||
state: {
|
||||
blockBuffer: streamed,
|
||||
deltaBuffer: streamed,
|
||||
},
|
||||
});
|
||||
|
||||
void endMessage(ctx, {
|
||||
message: { role: "assistant", content: [{ type: "text", text }] },
|
||||
});
|
||||
|
||||
expect(firstMockArg(onAgentEvent, "agent event")).toMatchObject({
|
||||
stream: "assistant",
|
||||
data: { text, delta: text },
|
||||
});
|
||||
const finalBlockText = (firstMockArg(emitBlockReply, "block reply") as { text?: string })
|
||||
.text;
|
||||
expect(`${streamed}${finalBlockText ?? ""}`).toBe(text);
|
||||
expect(ctx.finalizeAssistantTexts).toHaveBeenCalledWith(expect.objectContaining({ text }));
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps exact NO_REPLY silent after a user-facing message send followed by sessions_send (#119383)", () => {
|
||||
const emitBlockReply = vi.fn();
|
||||
const finalizeAssistantTexts = vi.fn();
|
||||
const ctx = createMessageEndContext({
|
||||
emitBlockReply,
|
||||
finalizeAssistantTexts,
|
||||
consumeReplyDirectives: vi.fn((text: string) => ({ text })),
|
||||
state: {
|
||||
blockBuffer: "",
|
||||
deltaBuffer: "",
|
||||
messagingToolSentTexts: ["<user-facing reply>", "<internal escalation note>"],
|
||||
messagingToolSentTextsNormalized: ["<user-facing reply>", "<internal escalation note>"],
|
||||
messagingToolSentTargets: [
|
||||
{
|
||||
tool: "message",
|
||||
provider: "whatsapp",
|
||||
to: "user:123",
|
||||
text: "<user-facing reply>",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
void endMessage(ctx, {
|
||||
message: { role: "assistant", content: [{ type: "text", text: "NO_REPLY" }] },
|
||||
});
|
||||
|
||||
// The exact silent token must never be rewritten to the sessions_send body:
|
||||
// the final assistant text keeps NO_REPLY and no block reply carries the note.
|
||||
expect(finalizeAssistantTexts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: "NO_REPLY" }),
|
||||
);
|
||||
for (const call of emitBlockReply.mock.calls) {
|
||||
expect(JSON.stringify(call)).not.toContain("<internal escalation note>");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps exact NO_REPLY silent when only sessions_send delivered (#119383)", () => {
|
||||
const emitBlockReply = vi.fn();
|
||||
const finalizeAssistantTexts = vi.fn();
|
||||
const ctx = createMessageEndContext({
|
||||
emitBlockReply,
|
||||
finalizeAssistantTexts,
|
||||
consumeReplyDirectives: vi.fn((text: string) => ({ text })),
|
||||
state: {
|
||||
blockBuffer: "",
|
||||
deltaBuffer: "",
|
||||
messagingToolSentTexts: ["<internal escalation note>"],
|
||||
messagingToolSentTextsNormalized: ["<internal escalation note>"],
|
||||
messagingToolSentTargets: [],
|
||||
},
|
||||
});
|
||||
|
||||
void endMessage(ctx, {
|
||||
message: { role: "assistant", content: [{ type: "text", text: "NO_REPLY" }] },
|
||||
});
|
||||
|
||||
expect(finalizeAssistantTexts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: "NO_REPLY" }),
|
||||
);
|
||||
for (const call of emitBlockReply.mock.calls) {
|
||||
expect(JSON.stringify(call)).not.toContain("<internal escalation note>");
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "counts a completed provider assistant message",
|
||||
message: { role: "assistant", content: [{ type: "text", text: "Done." }] },
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
name: "ignores transcript-only mirrored assistant messages",
|
||||
message: {
|
||||
role: "assistant",
|
||||
provider: "openclaw",
|
||||
model: "delivery-mirror",
|
||||
content: [{ type: "text", text: "Done." }],
|
||||
},
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "ignores non-assistant messages",
|
||||
message: { role: "user", content: [{ type: "text", text: "hi" }] },
|
||||
expected: 0,
|
||||
},
|
||||
])("$name for assistantTurnCount", ({ message, expected }) => {
|
||||
const ctx = createMessageEndContext({ state: { assistantTurnCount: 0 } });
|
||||
|
||||
void endMessage(ctx, { message });
|
||||
|
||||
expect(ctx.state.assistantTurnCount).toBe(expected);
|
||||
});
|
||||
|
||||
it("keeps duplicate-reply diagnostics free of lone surrogates", () => {
|
||||
const text = `${"a".repeat(49)}😀tail`;
|
||||
const ctx = createMessageEndContext({
|
||||
consumeReplyDirectives: vi.fn((value: string) => ({ text: value })),
|
||||
state: { messagingToolSentTextsNormalized: [`${"a".repeat(49)}tail`] },
|
||||
});
|
||||
|
||||
void endMessage(ctx, {
|
||||
message: { role: "assistant", content: [{ type: "text", text }] },
|
||||
});
|
||||
|
||||
const diagnostic = (ctx.log.debug as ReturnType<typeof vi.fn>).mock.calls
|
||||
.flat()
|
||||
.find((value) => String(value).startsWith("Skipping message_end block reply"));
|
||||
expect(diagnostic).toEqual(expect.any(String));
|
||||
expect(Buffer.from(String(diagnostic)).toString()).toBe(diagnostic);
|
||||
});
|
||||
|
||||
it("persists streamed usage when the final assistant snapshot is zeroed", () => {
|
||||
const ctx = createMessageEndContext({
|
||||
state: {
|
||||
pendingAssistantUsage: { input: 7, output: 5, reasoningTokens: 2, total: 12 },
|
||||
},
|
||||
});
|
||||
const message = {
|
||||
role: "assistant",
|
||||
api: "openai-completions",
|
||||
content: [{ type: "text", text: "Done." }],
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
},
|
||||
};
|
||||
|
||||
void endMessage(ctx, {
|
||||
message,
|
||||
});
|
||||
|
||||
expect(firstMockArg(ctx.noteLastAssistant as never, "last assistant")).toMatchObject({
|
||||
usage: {
|
||||
input: 7,
|
||||
output: 5,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
reasoningTokens: 2,
|
||||
totalTokens: 12,
|
||||
},
|
||||
});
|
||||
expect(ctx.recordAssistantUsage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: 7,
|
||||
output: 5,
|
||||
reasoningTokens: 2,
|
||||
totalTokens: 12,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps authoritative final usage instead of pending stream usage", () => {
|
||||
const ctx = createMessageEndContext({
|
||||
state: {
|
||||
pendingAssistantUsage: { input: 7, output: 5, total: 12 },
|
||||
},
|
||||
});
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Done." }],
|
||||
usage: {
|
||||
input: 11,
|
||||
output: 3,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 14,
|
||||
},
|
||||
};
|
||||
|
||||
void endMessage(ctx, {
|
||||
message,
|
||||
});
|
||||
|
||||
expect(firstMockArg(ctx.noteLastAssistant as never, "last assistant")).toBe(message);
|
||||
expect(ctx.recordAssistantUsage).toHaveBeenCalledWith(message.usage);
|
||||
});
|
||||
|
||||
it("warns when assistant text only pretends to call a registered tool", () => {
|
||||
const warn = vi.fn();
|
||||
const ctx = createMessageEndContext({
|
||||
warn,
|
||||
builtinToolNames: new Set(["read"]),
|
||||
});
|
||||
|
||||
void endMessage(ctx, {
|
||||
message: {
|
||||
role: "assistant",
|
||||
provider: "ollama",
|
||||
model: "qwen-local",
|
||||
content: [{ type: "text", text: '{"name":"read","arguments":{"path":"README.md"}}' }],
|
||||
stopReason: "stop",
|
||||
},
|
||||
});
|
||||
|
||||
const warnCall = firstMockCall(warn, "warning log");
|
||||
expect(warnCall?.[0]).toBe(
|
||||
"Assistant reply looks like a tool call, but no structured tool invocation was emitted; treating it as text.",
|
||||
);
|
||||
const metadata = warnCall?.[1] as
|
||||
| {
|
||||
runId?: string;
|
||||
sessionId?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
pattern?: string;
|
||||
toolName?: string;
|
||||
registeredTool?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
expect(metadata?.runId).toBe("run-1");
|
||||
expect(metadata?.sessionId).toBe("session-1");
|
||||
expect(metadata?.provider).toBe("ollama");
|
||||
expect(metadata?.model).toBe("qwen-local");
|
||||
expect(metadata?.pattern).toBe("json_tool_call");
|
||||
expect(metadata?.toolName).toBe("read");
|
||||
expect(metadata?.registeredTool).toBe(true);
|
||||
});
|
||||
|
||||
it("warns without logging text when assistant output resembles a transcript turn", () => {
|
||||
const warn = vi.fn();
|
||||
const ctx = createMessageEndContext({ warn });
|
||||
|
||||
void endMessage(ctx, {
|
||||
message: {
|
||||
role: "assistant",
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-8",
|
||||
content: [{ type: "text", text: "user[Thu 2026-07-02 18:14 EDT] do this" }],
|
||||
stopReason: "stop",
|
||||
},
|
||||
});
|
||||
|
||||
const warnCall = firstMockCall(warn, "warning log");
|
||||
expect(warnCall?.[0]).toBe(
|
||||
"Assistant reply contains transcript-role-looking text; treating it as inert assistant text.",
|
||||
);
|
||||
expect(warnCall?.[1]).toEqual({
|
||||
runId: "run-1",
|
||||
sessionId: "session-1",
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-8",
|
||||
pattern: "role_timestamp_bracket",
|
||||
role: "user",
|
||||
});
|
||||
expect(JSON.stringify(warnCall?.[1])).not.toContain("do this");
|
||||
});
|
||||
|
||||
it("detects spoiler-wrapped transcript turns without logging their text", () => {
|
||||
const warn = vi.fn();
|
||||
const ctx = createMessageEndContext({ warn });
|
||||
|
||||
void endMessage(ctx, {
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "||user[Thu 2026-07-02] hidden instruction||" }],
|
||||
stopReason: "stop",
|
||||
},
|
||||
});
|
||||
|
||||
const warnCall = firstMockCall(warn, "warning log");
|
||||
expect(warnCall?.[1]).toEqual({
|
||||
runId: "run-1",
|
||||
sessionId: "session-1",
|
||||
pattern: "role_timestamp_bracket",
|
||||
role: "user",
|
||||
});
|
||||
expect(JSON.stringify(warnCall?.[1])).not.toContain("hidden instruction");
|
||||
});
|
||||
|
||||
it("unwraps only source-routed or message-tool-only standalone message-tool JSON", () => {
|
||||
const visibleReply = "No specific tasks planned, but I'll keep watching for updates.";
|
||||
const unroutedEnvelope = createMessageToolEnvelope(visibleReply);
|
||||
const routedEnvelope = createMessageToolEnvelope(visibleReply, { target: "user:redacted" });
|
||||
const toRoutedEnvelope = createMessageToolEnvelope(visibleReply, { to: "user:redacted" });
|
||||
|
||||
for (const [text, api, builtinToolNames, sourceReplyDeliveryMode, expected] of [
|
||||
[unroutedEnvelope, undefined, new Set(["message"]), "message_tool_only", visibleReply],
|
||||
[routedEnvelope, "openai-completions", new Set<string>(), undefined, visibleReply],
|
||||
[toRoutedEnvelope, "openai-completions", new Set<string>(), undefined, visibleReply],
|
||||
[routedEnvelope, undefined, new Set<string>(), undefined, routedEnvelope],
|
||||
[unroutedEnvelope, undefined, new Set(["message"]), undefined, unroutedEnvelope],
|
||||
] as const) {
|
||||
const emitBlockReply = vi.fn();
|
||||
const consumeReplyDirectives = vi.fn((textLocal: string) =>
|
||||
textLocal ? { text: textLocal } : null,
|
||||
);
|
||||
const ctx = createMessageEndContext({
|
||||
emitBlockReply,
|
||||
consumeReplyDirectives,
|
||||
builtinToolNames,
|
||||
sourceReplyDeliveryMode,
|
||||
});
|
||||
|
||||
void endMessage(ctx, {
|
||||
message: {
|
||||
role: "assistant",
|
||||
...(api ? { api } : {}),
|
||||
content: [{ type: "text", text }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(consumeReplyDirectives).toHaveBeenCalledWith(expected, { final: true });
|
||||
expect(firstMockArg(emitBlockReply, "block reply")).toMatchObject({ text: expected });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not warn when the assistant emitted a structured tool call", () => {
|
||||
const warn = vi.fn();
|
||||
const ctx = createMessageEndContext({
|
||||
warn,
|
||||
builtinToolNames: new Set(["read"]),
|
||||
});
|
||||
|
||||
void endMessage(ctx, {
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
|
||||
stopReason: "toolUse",
|
||||
},
|
||||
});
|
||||
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses commentary-phase replies from user-visible output", () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const emitBlockReply = vi.fn();
|
||||
const finalizeAssistantTexts = vi.fn();
|
||||
const ctx = createMessageEndContext({
|
||||
onAgentEvent,
|
||||
finalizeAssistantTexts,
|
||||
emitBlockReply,
|
||||
});
|
||||
|
||||
void endMessage(ctx, {
|
||||
message: {
|
||||
role: "assistant",
|
||||
phase: "commentary",
|
||||
content: [{ type: "text", text: "Need send." }],
|
||||
usage: { input: 1, output: 1, total: 2 },
|
||||
},
|
||||
});
|
||||
|
||||
// Archive-always: commentary reaches the bus/archive but not the visible reply.
|
||||
expect(onAgentEvent).toHaveBeenCalled();
|
||||
expect(emitBlockReply).not.toHaveBeenCalled();
|
||||
expect(finalizeAssistantTexts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses commentary message_end when phase exists only in textSignature metadata", () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const emitBlockReply = vi.fn();
|
||||
const finalizeAssistantTexts = vi.fn();
|
||||
const ctx = createMessageEndContext({
|
||||
onAgentEvent,
|
||||
finalizeAssistantTexts,
|
||||
emitBlockReply,
|
||||
});
|
||||
|
||||
void endMessage(ctx, {
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
createOpenAiResponsesTextBlock({
|
||||
text: "Need send.",
|
||||
id: "msg_sig",
|
||||
phase: "commentary",
|
||||
}),
|
||||
],
|
||||
usage: { input: 1, output: 1, total: 2 },
|
||||
},
|
||||
});
|
||||
|
||||
// Archive-always: commentary (textSignature-only phase) reaches the
|
||||
// bus/archive but not the visible reply.
|
||||
expect(onAgentEvent).toHaveBeenCalled();
|
||||
expect(emitBlockReply).not.toHaveBeenCalled();
|
||||
expect(finalizeAssistantTexts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not duplicate block reply for text_end channels when text was already delivered", () => {
|
||||
const onBlockReply = vi.fn();
|
||||
const emitBlockReply = vi.fn();
|
||||
// In real usage, the directive accumulator returns null for empty/consumed
|
||||
// input. The non-empty call shouldn't happen for text_end channels (that's
|
||||
// the safety send we're guarding against).
|
||||
const consumeReplyDirectives = vi.fn((text: string) => (text ? { text } : null));
|
||||
const ctx = createMessageEndContext({
|
||||
onBlockReply,
|
||||
emitBlockReply,
|
||||
consumeReplyDirectives,
|
||||
state: {
|
||||
emittedAssistantUpdate: true,
|
||||
lastStreamedAssistantCleaned: "Hello world",
|
||||
blockReplyBreak: "text_end",
|
||||
// Simulate text_end already delivered this text through emitBlockChunk
|
||||
lastBlockReplyText: "Hello world",
|
||||
deltaBuffer: "",
|
||||
blockBuffer: "",
|
||||
},
|
||||
});
|
||||
|
||||
void endMessage(ctx, {
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Hello world" }],
|
||||
usage: { input: 10, output: 5, total: 15 },
|
||||
},
|
||||
});
|
||||
|
||||
// The block reply should NOT fire again since text_end already delivered it.
|
||||
// consumeReplyDirectives is called once with "" (the final flush for
|
||||
// text_end channels) but returns null, so emitBlockReply is never called.
|
||||
expect(emitBlockReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tags message-end safety replies with the current assistant message", () => {
|
||||
const emitBlockReply = vi.fn();
|
||||
const ctx = createMessageEndContext({
|
||||
onBlockReply: vi.fn(),
|
||||
emitBlockReply,
|
||||
consumeReplyDirectives: vi.fn((text: string) => (text ? { text } : null)),
|
||||
state: {
|
||||
assistantMessageIndex: 7,
|
||||
blockReplyBreak: "text_end",
|
||||
lastBlockReplyText: null,
|
||||
},
|
||||
});
|
||||
|
||||
void endMessage(ctx, {
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Final answer" }],
|
||||
usage: { input: 10, output: 5, total: 15 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(emitBlockReply).toHaveBeenCalledWith(
|
||||
{ text: "Final answer" },
|
||||
{ assistantMessageIndex: 7 },
|
||||
);
|
||||
});
|
||||
|
||||
it("does not duplicate block reply for text_end channels even when stripping differs", () => {
|
||||
const onBlockReply = vi.fn();
|
||||
const emitBlockReply = vi.fn();
|
||||
// Same pattern: directive accumulator returns null for empty final flush
|
||||
const consumeReplyDirectives = vi.fn((text: string) => (text ? { text } : null));
|
||||
const ctx = createMessageEndContext({
|
||||
onBlockReply,
|
||||
emitBlockReply,
|
||||
consumeReplyDirectives,
|
||||
state: {
|
||||
emittedAssistantUpdate: true,
|
||||
lastStreamedAssistantCleaned: "Hello world",
|
||||
blockReplyBreak: "text_end",
|
||||
// text_end delivered via emitBlockChunk which uses different stripping
|
||||
lastBlockReplyText: "Hello world.",
|
||||
deltaBuffer: "",
|
||||
blockBuffer: "",
|
||||
},
|
||||
});
|
||||
|
||||
void endMessage(ctx, {
|
||||
message: {
|
||||
role: "assistant",
|
||||
// The raw text differs slightly from lastBlockReplyText due to stripping
|
||||
content: [{ type: "text", text: "Hello world" }],
|
||||
usage: { input: 10, output: 5, total: 15 },
|
||||
},
|
||||
});
|
||||
|
||||
// Even though text !== lastBlockReplyText (different stripping), the safety
|
||||
// send should NOT fire for text_end channels. The only consumeReplyDirectives
|
||||
// call is the final empty flush which returns null.
|
||||
expect(emitBlockReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits final media and malformed pending text after flushing buffered message_end text", () => {
|
||||
const emitBlockReply = vi.fn();
|
||||
const flushBlockReplyBuffer = vi.fn();
|
||||
const accumulator = createStreamingDirectiveAccumulator();
|
||||
const text = "Caption [[oops\nMEDIA:/tmp/final.png";
|
||||
const streamed = accumulator.consume(text)?.text ?? "";
|
||||
const consumeReplyDirectives = vi.fn((chunk: string, options?: { final?: boolean }) =>
|
||||
accumulator.consume(chunk, options),
|
||||
);
|
||||
const ctx = createMessageEndContext({
|
||||
emitBlockReply,
|
||||
flushBlockReplyBuffer,
|
||||
consumeReplyDirectives,
|
||||
blockChunker: {
|
||||
hasBuffered: () => true,
|
||||
reset: vi.fn(),
|
||||
},
|
||||
state: {
|
||||
emittedAssistantUpdate: true,
|
||||
lastStreamedAssistantCleaned: "Caption [[oops",
|
||||
blockReplyBreak: "message_end",
|
||||
deltaBuffer: streamed,
|
||||
blockBuffer: streamed,
|
||||
},
|
||||
});
|
||||
|
||||
void endMessage(ctx, {
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
usage: { input: 10, output: 5, total: 15 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(flushBlockReplyBuffer).toHaveBeenCalledWith({
|
||||
assistantMessageIndex: undefined,
|
||||
final: true,
|
||||
});
|
||||
expect(consumeReplyDirectives).toHaveBeenCalledWith("", { final: true });
|
||||
const finalReply = firstMockArg(emitBlockReply, "block reply") as {
|
||||
text?: string;
|
||||
mediaUrls?: string[];
|
||||
};
|
||||
expect(finalReply).toMatchObject({
|
||||
text: " [[oops",
|
||||
mediaUrls: ["/tmp/final.png"],
|
||||
});
|
||||
expect(`${streamed}${finalReply.text ?? ""}`).toBe("Caption [[oops");
|
||||
});
|
||||
|
||||
it("preserves literal reasoning-looking tags in unphased final visible text", () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const stripBlockTags = vi.fn(() => "Before");
|
||||
const ctx = createMessageEndContext({
|
||||
onAgentEvent,
|
||||
stripBlockTags,
|
||||
consumeReplyDirectives: vi.fn((text: string) => ({ text })),
|
||||
state: {
|
||||
blockBuffer: "",
|
||||
deltaBuffer: "",
|
||||
},
|
||||
});
|
||||
|
||||
void endMessage(ctx, {
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Before <think>literal tag text after",
|
||||
textSignature: JSON.stringify({ v: 1, id: "item_unphased" }),
|
||||
},
|
||||
],
|
||||
usage: { input: 10, output: 5, total: 15 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(stripBlockTags).not.toHaveBeenCalled();
|
||||
expect(firstMockArg(ctx.emitAssistantStreamData as never, "assistant stream")).toMatchObject({
|
||||
text: "Before <think>literal tag text after",
|
||||
delta: "Before <think>literal tag text after",
|
||||
});
|
||||
expect(ctx.finalizeAssistantTexts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: "Before <think>literal tag text after" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps final-tag enforcement in message_end fallback", () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const stripBlockTags = vi.fn(() => "");
|
||||
const ctx = createMessageEndContext({
|
||||
enforceFinalTag: true,
|
||||
onAgentEvent,
|
||||
stripBlockTags,
|
||||
consumeReplyDirectives: vi.fn((text: string) => ({ text })),
|
||||
state: {
|
||||
blockBuffer: "",
|
||||
deltaBuffer: "",
|
||||
},
|
||||
});
|
||||
|
||||
void endMessage(ctx, {
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "Hello world",
|
||||
usage: { input: 10, output: 5, total: 15 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(stripBlockTags).toHaveBeenCalledWith(
|
||||
"Hello world",
|
||||
{ thinking: false, final: false },
|
||||
{ final: true },
|
||||
);
|
||||
expect(ctx.emitAssistantStreamData).not.toHaveBeenCalled();
|
||||
expect(ctx.finalizeAssistantTexts).toHaveBeenCalledWith(expect.objectContaining({ text: "" }));
|
||||
});
|
||||
|
||||
it("emits a replacement final assistant event when final_answer appears only at message_end", () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const ctx = createMessageEndContext({
|
||||
onAgentEvent,
|
||||
state: {
|
||||
emittedAssistantUpdate: true,
|
||||
lastStreamedAssistantCleaned: "Working...",
|
||||
blockReplyBreak: "text_end",
|
||||
deltaBuffer: "",
|
||||
blockBuffer: "",
|
||||
},
|
||||
});
|
||||
|
||||
void endMessage(ctx, {
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
createOpenAiResponsesTextBlock({
|
||||
text: "Working...",
|
||||
id: "item_commentary",
|
||||
phase: "commentary",
|
||||
}),
|
||||
createOpenAiResponsesTextBlock({
|
||||
text: "Done.",
|
||||
id: "item_final",
|
||||
phase: "final_answer",
|
||||
}),
|
||||
],
|
||||
stopReason: "stop",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
usage: {},
|
||||
timestamp: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(onAgentEvent).toHaveBeenCalledTimes(1);
|
||||
const event = firstMockArg(onAgentEvent, "agent event") as
|
||||
| { stream?: string; data?: { text?: string; delta?: string; replace?: boolean } }
|
||||
| undefined;
|
||||
expect(event?.stream).toBe("assistant");
|
||||
expect(event?.data?.text).toBe("Done.");
|
||||
expect(event?.data?.delta).toBe("");
|
||||
expect(event?.data?.replace).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,525 @@
|
||||
import { isPromiseLike } from "@openclaw/normalization-core/promise-like";
|
||||
/**
|
||||
* Handles assistant message lifecycle boundaries, final reconciliation, and usage.
|
||||
*/
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
|
||||
import { createInlineCodeState } from "../../packages/markdown-core/src/code-spans.js";
|
||||
import { parseReplyDirectives } from "../auto-reply/reply/reply-directives.js";
|
||||
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||
import type { AssistantMessage } from "../llm/types.js";
|
||||
import { splitMediaFromOutput } from "../media/parse.js";
|
||||
import { coerceChatContentText } from "../shared/chat-content.js";
|
||||
import { resolveAssistantMessagePhase } from "../shared/chat-message-content.js";
|
||||
import {
|
||||
isMessagingToolDuplicateNormalized,
|
||||
normalizeTextForComparison,
|
||||
} from "./embedded-agent-helpers.js";
|
||||
import { hasAssistantVisibleReply } from "./embedded-agent-subscribe.handlers.messages.replies.js";
|
||||
import {
|
||||
buildAssistantStreamData,
|
||||
emitAssistantMessageStart,
|
||||
extractStandaloneMessageToolText,
|
||||
hasMessageToolOnlySourceDelivery,
|
||||
isOpenAiCompletionsAssistantMessage,
|
||||
isResponsesApiAssistantMessage,
|
||||
isSubscribeTranscriptOnlyOpenClawAssistantMessage,
|
||||
scopeAssistantMessageToStreamBlock,
|
||||
shouldSuppressAssistantVisibleOutput,
|
||||
shouldSuppressDeterministicApprovalOutput,
|
||||
} from "./embedded-agent-subscribe.handlers.messages.stream.js";
|
||||
import type { EmbeddedAgentSubscribeContext } from "./embedded-agent-subscribe.handlers.types.js";
|
||||
import { appendRawStream } from "./embedded-agent-subscribe.raw-stream.js";
|
||||
import { warnIfAssistantEmittedSuspiciousText } from "./embedded-agent-subscribe.tool-text-diagnostics.js";
|
||||
import {
|
||||
createThinkingTagStreamState,
|
||||
extractAssistantCommentaryText,
|
||||
extractAssistantThinking,
|
||||
extractAssistantVisibleText,
|
||||
extractEmbeddedAssistantText,
|
||||
extractThinkingFromTaggedText,
|
||||
promoteThinkingTagsToBlocks,
|
||||
} from "./embedded-agent-utils.js";
|
||||
import type { AgentEvent, AgentMessage } from "./runtime/index.js";
|
||||
import {
|
||||
hasNonzeroUsage,
|
||||
makeZeroUsageSnapshot,
|
||||
normalizeUsage,
|
||||
type NormalizedUsage,
|
||||
type UsageLike,
|
||||
} from "./usage.js";
|
||||
|
||||
export function preservePendingAssistantUsage(
|
||||
message: AssistantMessage,
|
||||
pendingUsage: NormalizedUsage | undefined,
|
||||
): AssistantMessage {
|
||||
if (
|
||||
isSubscribeTranscriptOnlyOpenClawAssistantMessage(message) ||
|
||||
!hasNonzeroUsage(pendingUsage)
|
||||
) {
|
||||
return message;
|
||||
}
|
||||
const messageUsage = normalizeUsage((message as { usage?: UsageLike }).usage);
|
||||
if (hasNonzeroUsage(messageUsage)) {
|
||||
return message;
|
||||
}
|
||||
|
||||
// Pending usage resets at each assistant-message boundary, so it belongs to
|
||||
// this final snapshot. Only replace missing/zero usage; provider totals win.
|
||||
const input = pendingUsage.input ?? 0;
|
||||
const output = pendingUsage.output ?? 0;
|
||||
const cacheRead = pendingUsage.cacheRead ?? 0;
|
||||
const cacheWrite = pendingUsage.cacheWrite ?? 0;
|
||||
message.usage = {
|
||||
...makeZeroUsageSnapshot(),
|
||||
input,
|
||||
output,
|
||||
cacheRead,
|
||||
cacheWrite,
|
||||
...(pendingUsage.contextUsage ? { contextUsage: { ...pendingUsage.contextUsage } } : {}),
|
||||
totalTokens: pendingUsage.total ?? input + output + cacheRead + cacheWrite,
|
||||
...(pendingUsage.reasoningTokens !== undefined
|
||||
? { reasoningTokens: pendingUsage.reasoningTokens }
|
||||
: {}),
|
||||
};
|
||||
return message;
|
||||
}
|
||||
|
||||
export function capturePendingAssistantUsage(
|
||||
ctx: EmbeddedAgentSubscribeContext,
|
||||
evt: AgentEvent & { message: AgentMessage; assistantMessageEvent?: unknown },
|
||||
): void {
|
||||
const msg = evt.message;
|
||||
if (msg?.role !== "assistant" || isSubscribeTranscriptOnlyOpenClawAssistantMessage(msg)) {
|
||||
return;
|
||||
}
|
||||
const assistantRecord =
|
||||
evt.assistantMessageEvent && typeof evt.assistantMessageEvent === "object"
|
||||
? (evt.assistantMessageEvent as Record<string, unknown>)
|
||||
: undefined;
|
||||
const evtType = typeof assistantRecord?.type === "string" ? assistantRecord.type : "";
|
||||
if (evtType === "text_end" || evtType === "done" || evtType === "error") {
|
||||
ctx.recordAssistantUsage(assistantRecord);
|
||||
}
|
||||
}
|
||||
|
||||
export function resetPendingAssistantUsage(
|
||||
ctx: EmbeddedAgentSubscribeContext,
|
||||
message: AgentMessage,
|
||||
): void {
|
||||
if (message?.role !== "assistant" || isSubscribeTranscriptOnlyOpenClawAssistantMessage(message)) {
|
||||
return;
|
||||
}
|
||||
ctx.state.pendingAssistantUsage = undefined;
|
||||
ctx.state.assistantUsageCommitted = false;
|
||||
}
|
||||
|
||||
export function handleMessageStart(
|
||||
ctx: EmbeddedAgentSubscribeContext,
|
||||
evt: AgentEvent & { message: AgentMessage },
|
||||
) {
|
||||
const msg = evt.message;
|
||||
if (msg?.role !== "assistant" || isSubscribeTranscriptOnlyOpenClawAssistantMessage(msg)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// KNOWN: Resetting at `text_end` is unsafe (late/duplicate end events).
|
||||
// ASSUME: `message_start` is the only reliable boundary for “new assistant message begins”.
|
||||
// Start-of-message is a safer reset point than message_end: some providers
|
||||
// may deliver late text_end updates after message_end, which would otherwise
|
||||
// re-trigger block replies.
|
||||
ctx.resetAssistantMessageState(ctx.state.assistantTexts.length);
|
||||
// Use assistant message_start as the earliest "writing" signal for typing.
|
||||
emitAssistantMessageStart(ctx);
|
||||
}
|
||||
|
||||
/** Handles assistant message deltas, reasoning, directives, and block replies. */
|
||||
|
||||
export function handleMessageEnd(
|
||||
ctx: EmbeddedAgentSubscribeContext,
|
||||
evt: AgentEvent & { message: AgentMessage },
|
||||
): void | Promise<void> {
|
||||
const msg = evt.message;
|
||||
if (msg?.role !== "assistant" || isSubscribeTranscriptOnlyOpenClawAssistantMessage(msg)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Transcript-only messages never reach the provider, so this counts exactly
|
||||
// the completed model round trips consumers see as `assistantTurns`.
|
||||
ctx.state.assistantTurnCount += 1;
|
||||
const assistantMessage = preservePendingAssistantUsage(msg, ctx.state.pendingAssistantUsage);
|
||||
const assistantPhase = resolveAssistantMessagePhase(assistantMessage);
|
||||
const suppressVisibleAssistantOutput = shouldSuppressAssistantVisibleOutput(assistantMessage);
|
||||
const suppressDeterministicApprovalOutput = shouldSuppressDeterministicApprovalOutput(ctx.state);
|
||||
const suppressMessageToolOnlySourceReplyOutput = hasMessageToolOnlySourceDelivery(ctx);
|
||||
ctx.noteLastAssistant(assistantMessage);
|
||||
ctx.noteCompletedAssistant(assistantMessage);
|
||||
ctx.recordAssistantUsage((assistantMessage as { usage?: unknown }).usage);
|
||||
ctx.commitAssistantUsage();
|
||||
if (suppressVisibleAssistantOutput) {
|
||||
const isResponsesCommentary = isResponsesApiAssistantMessage(assistantMessage);
|
||||
const commentaryMessage = isResponsesCommentary
|
||||
? scopeAssistantMessageToStreamBlock(
|
||||
assistantMessage as AssistantMessage,
|
||||
ctx.state.lastAssistantStreamContentIndex,
|
||||
ctx.state.lastAssistantStreamItemId,
|
||||
)
|
||||
: assistantMessage;
|
||||
const commentaryText = coerceChatContentText(extractAssistantCommentaryText(commentaryMessage));
|
||||
appendRawStream({
|
||||
ts: Date.now(),
|
||||
event: "assistant_message_end",
|
||||
runId: ctx.params.runId,
|
||||
sessionId: (ctx.params.session as { id?: string }).id,
|
||||
rawText: coerceChatContentText(extractEmbeddedAssistantText(assistantMessage)),
|
||||
rawThinking: extractAssistantThinking(assistantMessage),
|
||||
});
|
||||
const commentaryAlreadyStreamed =
|
||||
isResponsesCommentary &&
|
||||
Boolean(ctx.state.deltaBuffer) &&
|
||||
ctx.state.deltaBuffer === commentaryText;
|
||||
if (commentaryText && !commentaryAlreadyStreamed) {
|
||||
ctx.emitAssistantStreamData(
|
||||
buildAssistantStreamData({
|
||||
text: commentaryText,
|
||||
replace: true,
|
||||
phase: "commentary",
|
||||
itemId: isResponsesCommentary ? ctx.state.lastAssistantStreamItemId : undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// Commentary-tagged tool turns can still carry durable reasoning under /reasoning on.
|
||||
const suppressedTrimmedReasoning = ctx.state.includeReasoning
|
||||
? extractAssistantThinking(assistantMessage).trim()
|
||||
: "";
|
||||
if (
|
||||
!ctx.params.silentExpected &&
|
||||
!suppressDeterministicApprovalOutput &&
|
||||
!suppressMessageToolOnlySourceReplyOutput &&
|
||||
ctx.state.includeReasoning &&
|
||||
suppressedTrimmedReasoning &&
|
||||
ctx.params.onBlockReply &&
|
||||
suppressedTrimmedReasoning !== ctx.state.lastReasoningSent
|
||||
) {
|
||||
ctx.state.lastReasoningSent = suppressedTrimmedReasoning;
|
||||
ctx.emitBlockReply({ text: suppressedTrimmedReasoning, isReasoning: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
promoteThinkingTagsToBlocks(assistantMessage);
|
||||
|
||||
const rawText = coerceChatContentText(extractEmbeddedAssistantText(assistantMessage));
|
||||
const rawVisibleText = coerceChatContentText(extractAssistantVisibleText(assistantMessage));
|
||||
appendRawStream({
|
||||
ts: Date.now(),
|
||||
event: "assistant_message_end",
|
||||
runId: ctx.params.runId,
|
||||
sessionId: (ctx.params.session as { id?: string }).id,
|
||||
rawText,
|
||||
rawThinking: extractAssistantThinking(assistantMessage),
|
||||
});
|
||||
warnIfAssistantEmittedSuspiciousText(ctx, assistantMessage);
|
||||
const visibleText =
|
||||
extractStandaloneMessageToolText(rawVisibleText, {
|
||||
allowRoutedReply: isOpenAiCompletionsAssistantMessage(assistantMessage),
|
||||
allowCurrentSourceReply:
|
||||
ctx.params.sourceReplyDeliveryMode === "message_tool_only" &&
|
||||
ctx.builtinToolNames?.has("message") === true,
|
||||
}) ?? rawVisibleText;
|
||||
const finalVisibleText = ctx.params.enforceFinalTag
|
||||
? ctx.stripBlockTags(visibleText, { thinking: false, final: false }, { final: true })
|
||||
: visibleText;
|
||||
|
||||
// Exact NO_REPLY stays silent. The legacy rewrite (silentReplyRewrite) was
|
||||
// removed by contract; global messaging-tool send evidence is not a
|
||||
// user-route reply and must never be mirrored into the final payload.
|
||||
const text = finalVisibleText;
|
||||
const rawThinking =
|
||||
ctx.state.includeReasoning || ctx.state.streamReasoning
|
||||
? extractAssistantThinking(assistantMessage) || extractThinkingFromTaggedText(rawText)
|
||||
: "";
|
||||
const trimmedReasoning = rawThinking ? rawThinking.trim() : "";
|
||||
const trimmedText = text.trim();
|
||||
const parsedText = trimmedText ? parseReplyDirectives(trimmedText) : null;
|
||||
const cleanedText = parsedText?.text ?? "";
|
||||
const { mediaUrls, hasMedia } = resolveSendableOutboundReplyParts(parsedText ?? {});
|
||||
|
||||
const finalizeMessageEnd = () => {
|
||||
ctx.state.deltaBuffer = "";
|
||||
ctx.state.thinkingTagStream = createThinkingTagStreamState();
|
||||
ctx.state.blockBuffer = "";
|
||||
ctx.blockChunker?.reset();
|
||||
ctx.state.blockState.thinking = false;
|
||||
ctx.state.blockState.final = false;
|
||||
ctx.state.blockState.inlineCode = createInlineCodeState();
|
||||
ctx.state.blockState.fence = undefined;
|
||||
ctx.state.blockState.reasoningInlineCode = undefined;
|
||||
ctx.state.blockState.reasoningFence = undefined;
|
||||
ctx.state.blockState.reasoningPendingFenceFragment = undefined;
|
||||
ctx.state.blockState.finalInlineCode = undefined;
|
||||
ctx.state.blockState.finalFence = undefined;
|
||||
ctx.state.blockState.pendingFenceFragment = undefined;
|
||||
ctx.state.blockState.pendingTagFragment = undefined;
|
||||
ctx.state.partialBlockState.fence = undefined;
|
||||
ctx.state.partialBlockState.reasoningInlineCode = undefined;
|
||||
ctx.state.partialBlockState.reasoningFence = undefined;
|
||||
ctx.state.partialBlockState.reasoningPendingFenceFragment = undefined;
|
||||
ctx.state.partialBlockState.finalInlineCode = undefined;
|
||||
ctx.state.partialBlockState.finalFence = undefined;
|
||||
ctx.state.partialBlockState.pendingFenceFragment = undefined;
|
||||
ctx.state.partialBlockState.pendingTagFragment = undefined;
|
||||
ctx.state.lastStreamedAssistant = undefined;
|
||||
ctx.state.lastStreamedAssistantCleaned = undefined;
|
||||
ctx.state.reasoningStreamOpen = false;
|
||||
};
|
||||
|
||||
const previousStreamedText = ctx.state.lastStreamedAssistantCleaned ?? "";
|
||||
const shouldReplaceFinalStream = Boolean(
|
||||
previousStreamedText && cleanedText && !cleanedText.startsWith(previousStreamedText),
|
||||
);
|
||||
const didTextChangeWithinCurrentMessage = Boolean(
|
||||
previousStreamedText && cleanedText !== previousStreamedText,
|
||||
);
|
||||
const finalStreamDelta = shouldReplaceFinalStream
|
||||
? ""
|
||||
: cleanedText.slice(previousStreamedText.length);
|
||||
|
||||
if (
|
||||
!ctx.params.silentExpected &&
|
||||
!suppressDeterministicApprovalOutput &&
|
||||
!suppressMessageToolOnlySourceReplyOutput &&
|
||||
(cleanedText || hasMedia) &&
|
||||
(!ctx.state.emittedAssistantUpdate ||
|
||||
shouldReplaceFinalStream ||
|
||||
didTextChangeWithinCurrentMessage ||
|
||||
hasMedia)
|
||||
) {
|
||||
const data = buildAssistantStreamData({
|
||||
text: cleanedText,
|
||||
delta: finalStreamDelta,
|
||||
replace: shouldReplaceFinalStream,
|
||||
mediaUrls,
|
||||
phase: assistantPhase,
|
||||
});
|
||||
ctx.emitAssistantStreamData(data);
|
||||
ctx.state.emittedAssistantUpdate = true;
|
||||
ctx.state.lastStreamedAssistantCleaned = cleanedText;
|
||||
}
|
||||
|
||||
const silentExpectedWithoutSentinel =
|
||||
ctx.params.silentExpected && !isSilentReplyText(trimmedText, SILENT_REPLY_TOKEN);
|
||||
const finalAssistantText = silentExpectedWithoutSentinel ? "" : text;
|
||||
const addedDuringMessage = ctx.state.assistantTexts.length > ctx.state.assistantTextBaseline;
|
||||
const chunkerHasBuffered = ctx.blockChunker?.hasBuffered() ?? false;
|
||||
ctx.finalizeAssistantTexts({
|
||||
text: finalAssistantText,
|
||||
addedDuringMessage,
|
||||
chunkerHasBuffered,
|
||||
});
|
||||
|
||||
const onBlockReply = ctx.params.onBlockReply;
|
||||
const shouldEmitReasoning = Boolean(
|
||||
!ctx.params.silentExpected &&
|
||||
!suppressDeterministicApprovalOutput &&
|
||||
!suppressMessageToolOnlySourceReplyOutput &&
|
||||
ctx.state.includeReasoning &&
|
||||
trimmedReasoning &&
|
||||
onBlockReply &&
|
||||
trimmedReasoning !== ctx.state.lastReasoningSent,
|
||||
);
|
||||
const shouldEmitReasoningBeforeAnswer =
|
||||
shouldEmitReasoning && ctx.state.blockReplyBreak === "message_end" && !addedDuringMessage;
|
||||
const maybeEmitReasoning = () => {
|
||||
if (!shouldEmitReasoning || !trimmedReasoning) {
|
||||
return;
|
||||
}
|
||||
ctx.state.lastReasoningSent = trimmedReasoning;
|
||||
// Lane purity: the payload carries raw thinking only. Tool persistence is
|
||||
// the verbose lane's job; interleaving comes from arrival order.
|
||||
ctx.emitBlockReply({ text: trimmedReasoning, isReasoning: true });
|
||||
};
|
||||
|
||||
if (shouldEmitReasoningBeforeAnswer) {
|
||||
maybeEmitReasoning();
|
||||
}
|
||||
|
||||
const emitSplitResultAsBlockReply = (
|
||||
splitResult: ReturnType<typeof ctx.consumeReplyDirectives> | null | undefined,
|
||||
) => {
|
||||
if (!splitResult || !onBlockReply) {
|
||||
return;
|
||||
}
|
||||
const {
|
||||
text: cleanedTextLocal,
|
||||
mediaUrls: mediaUrlsLocal,
|
||||
audioAsVoice,
|
||||
replyToId,
|
||||
replyToTag,
|
||||
replyToCurrent,
|
||||
} = splitResult;
|
||||
// Emit if there's content OR audioAsVoice flag (to propagate the flag).
|
||||
if (
|
||||
hasAssistantVisibleReply({ text: cleanedTextLocal, mediaUrls: mediaUrlsLocal, audioAsVoice })
|
||||
) {
|
||||
ctx.emitBlockReply(
|
||||
{
|
||||
text: cleanedTextLocal,
|
||||
mediaUrls: mediaUrlsLocal?.length ? mediaUrlsLocal : undefined,
|
||||
audioAsVoice,
|
||||
replyToId,
|
||||
replyToTag,
|
||||
replyToCurrent,
|
||||
},
|
||||
{ assistantMessageIndex: ctx.state.assistantMessageIndex },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const consumeFinalReplyDirectives = () => {
|
||||
const bufferedResult = ctx.consumeReplyDirectives("", { final: true });
|
||||
if (!hasMedia || !parsedText) {
|
||||
return bufferedResult;
|
||||
}
|
||||
const bufferedRawText = bufferedResult?.text ?? "";
|
||||
const leadingWhitespace = bufferedRawText.match(/^\s+/u)?.[0] ?? "";
|
||||
const strippedBufferedText = bufferedRawText ? splitMediaFromOutput(bufferedRawText).text : "";
|
||||
const bufferedText =
|
||||
leadingWhitespace &&
|
||||
strippedBufferedText &&
|
||||
!strippedBufferedText.startsWith(leadingWhitespace)
|
||||
? `${leadingWhitespace}${strippedBufferedText}`
|
||||
: strippedBufferedText;
|
||||
return {
|
||||
...bufferedResult,
|
||||
...parsedText,
|
||||
text: bufferedText,
|
||||
};
|
||||
};
|
||||
|
||||
const hasBufferedBlockReply = ctx.blockChunker
|
||||
? ctx.blockChunker.hasBuffered()
|
||||
: ctx.state.blockBuffer.length > 0;
|
||||
|
||||
if (
|
||||
!ctx.params.silentExpected &&
|
||||
!suppressDeterministicApprovalOutput &&
|
||||
!suppressMessageToolOnlySourceReplyOutput &&
|
||||
text &&
|
||||
onBlockReply &&
|
||||
(ctx.state.blockReplyBreak === "message_end" ||
|
||||
hasBufferedBlockReply ||
|
||||
text !== ctx.state.lastBlockReplyText ||
|
||||
hasMedia)
|
||||
) {
|
||||
if (hasBufferedBlockReply && ctx.blockChunker?.hasBuffered()) {
|
||||
const flushBlockReplyBufferResult = ctx.flushBlockReplyBuffer({
|
||||
assistantMessageIndex: ctx.state.assistantMessageIndex,
|
||||
final: true,
|
||||
});
|
||||
if (isPromiseLike<void>(flushBlockReplyBufferResult)) {
|
||||
void flushBlockReplyBufferResult.catch((err: unknown) => {
|
||||
ctx.log.debug(`message_end block reply flush failed: ${String(err)}`);
|
||||
});
|
||||
}
|
||||
// Final-flush the streaming directive accumulator so any partial
|
||||
// inline reply/audio tag held back by splitTrailingDirective gets
|
||||
// emitted on the message_end / blockReplyChunking path.
|
||||
emitSplitResultAsBlockReply(consumeFinalReplyDirectives());
|
||||
} else if (text !== ctx.state.lastBlockReplyText || hasMedia) {
|
||||
// Guard: for text_end channels, if text_end already delivered content
|
||||
// (lastBlockReplyText is set), skip this safety send. The text comparison
|
||||
// here uses a different stripping pipeline (stripBlockTags with reset state)
|
||||
// than emitBlockChunk (stripBlockTags with running blockState +
|
||||
// stripDowngradedToolCallText), which can false-positive. When text_end
|
||||
// didn't deliver (e.g. commentary suppressed, provider skipped text_end),
|
||||
// lastBlockReplyText is still null and message_end must deliver.
|
||||
if (
|
||||
ctx.state.blockReplyBreak === "text_end" &&
|
||||
ctx.state.lastBlockReplyText != null &&
|
||||
!hasMedia
|
||||
) {
|
||||
ctx.log.debug(
|
||||
`Skipping message_end safety send for text_end channel - content already delivered via text_end`,
|
||||
);
|
||||
} else {
|
||||
// Check for duplicates before emitting (same logic as emitBlockChunk).
|
||||
const normalizedText = normalizeTextForComparison(hasMedia ? cleanedText : text);
|
||||
if (
|
||||
isMessagingToolDuplicateNormalized(
|
||||
normalizedText,
|
||||
ctx.state.messagingToolSentTextsNormalized,
|
||||
)
|
||||
) {
|
||||
ctx.log.debug(
|
||||
`Skipping message_end block reply - already sent via messaging tool: ${truncateUtf16Safe(text, 50)}...`,
|
||||
);
|
||||
} else {
|
||||
const alreadyDeliveredFinalText = Boolean(
|
||||
hasMedia && cleanedText && cleanedText === ctx.state.lastBlockReplyText,
|
||||
);
|
||||
ctx.state.lastBlockReplyText = hasMedia ? cleanedText || text : text;
|
||||
ctx.state.lastDeliveredBlockReplyText = hasMedia ? cleanedText || text : text;
|
||||
ctx.state.toolExecutionSinceLastBlockReply = false;
|
||||
emitSplitResultAsBlockReply(
|
||||
hasMedia && parsedText
|
||||
? {
|
||||
...parsedText,
|
||||
text: alreadyDeliveredFinalText ? "" : cleanedText,
|
||||
}
|
||||
: ctx.consumeReplyDirectives(text, { final: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldEmitReasoningBeforeAnswer) {
|
||||
maybeEmitReasoning();
|
||||
}
|
||||
if (!ctx.params.silentExpected && rawThinking) {
|
||||
// Emit-always: bus/archive get message-end thinking regardless of the
|
||||
// streamReasoning rendering setting (gated inside emitReasoningStream).
|
||||
ctx.emitReasoningStream(rawThinking);
|
||||
}
|
||||
|
||||
if (
|
||||
!ctx.params.silentExpected &&
|
||||
!suppressMessageToolOnlySourceReplyOutput &&
|
||||
ctx.state.blockReplyBreak === "text_end" &&
|
||||
onBlockReply
|
||||
) {
|
||||
emitSplitResultAsBlockReply(ctx.consumeReplyDirectives("", { final: true }));
|
||||
}
|
||||
|
||||
if (
|
||||
!ctx.params.silentExpected &&
|
||||
ctx.state.blockReplyBreak === "message_end" &&
|
||||
ctx.params.onBlockReplyFlush
|
||||
) {
|
||||
const flushBlockReplyBufferResult = ctx.flushBlockReplyBuffer();
|
||||
if (isPromiseLike<void>(flushBlockReplyBufferResult)) {
|
||||
return flushBlockReplyBufferResult
|
||||
.then(() => {
|
||||
const onBlockReplyFlushResult = ctx.params.onBlockReplyFlush?.({
|
||||
reason: "message_end",
|
||||
});
|
||||
if (isPromiseLike<void>(onBlockReplyFlushResult)) {
|
||||
return onBlockReplyFlushResult;
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
.finally(() => {
|
||||
finalizeMessageEnd();
|
||||
});
|
||||
}
|
||||
const onBlockReplyFlushResult = ctx.params.onBlockReplyFlush({ reason: "message_end" });
|
||||
if (isPromiseLike<void>(onBlockReplyFlushResult)) {
|
||||
return onBlockReplyFlushResult.finally(() => {
|
||||
finalizeMessageEnd();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
finalizeMessageEnd();
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
consumePendingToolMediaIntoReply,
|
||||
consumePendingToolMediaReply,
|
||||
readPendingToolMediaReply,
|
||||
} from "./embedded-agent-subscribe.handlers.messages.replies.js";
|
||||
|
||||
describe("consumePendingToolMediaIntoReply", () => {
|
||||
it("attaches queued tool media to the next assistant reply", () => {
|
||||
const state = {
|
||||
pendingToolMediaUrls: ["/tmp/a.png", "/tmp/a.png", "/tmp/b.png"],
|
||||
pendingToolMediaAttachments: [
|
||||
{ type: "image" as const, path: "/tmp/a.png", width: 640, height: 480 },
|
||||
{ type: "image" as const, path: "/tmp/a.png", width: 1, height: 1 },
|
||||
{ type: "image" as const, path: "/tmp/b.png", width: 800, height: 600 },
|
||||
],
|
||||
pendingToolMediaTrustByUrl: new Map([
|
||||
["/tmp/a.png", true],
|
||||
["/tmp/b.png", false],
|
||||
]),
|
||||
pendingToolAudioAsVoice: false,
|
||||
};
|
||||
|
||||
expect(
|
||||
consumePendingToolMediaIntoReply(state, {
|
||||
text: "done",
|
||||
}),
|
||||
).toEqual({
|
||||
text: "done",
|
||||
mediaUrls: ["/tmp/a.png", "/tmp/b.png"],
|
||||
attachments: [
|
||||
{
|
||||
type: "image",
|
||||
path: "/tmp/a.png",
|
||||
width: 640,
|
||||
height: 480,
|
||||
trustedLocalMedia: true,
|
||||
},
|
||||
{ type: "image", path: "/tmp/b.png", width: 800, height: 600 },
|
||||
],
|
||||
audioAsVoice: undefined,
|
||||
});
|
||||
expect(state.pendingToolMediaUrls).toStrictEqual([]);
|
||||
expect(state.pendingToolMediaAttachments).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("does not append queued image tool media when the reply already names media", () => {
|
||||
const state = {
|
||||
pendingToolMediaUrls: ["/tmp/generated.png"],
|
||||
pendingToolMediaTrustByUrl: new Map([["/tmp/generated.png", true]]),
|
||||
pendingToolAudioAsVoice: false,
|
||||
};
|
||||
|
||||
expect(
|
||||
consumePendingToolMediaIntoReply(state, {
|
||||
text: "done",
|
||||
mediaUrls: ["./selected.png"],
|
||||
}),
|
||||
).toEqual({
|
||||
text: "done",
|
||||
mediaUrls: ["./selected.png"],
|
||||
});
|
||||
expect(state.pendingToolMediaUrls).toStrictEqual([]);
|
||||
expect(state.pendingToolAudioAsVoice).toBe(false);
|
||||
expect(state.pendingToolMediaTrustByUrl.size).toBe(0);
|
||||
});
|
||||
|
||||
it("retains queued metadata for explicitly selected media", () => {
|
||||
const state = {
|
||||
pendingToolMediaUrls: ["/tmp/generated.mp3", "/tmp/generated.mp3", "/tmp/unselected.mp3"],
|
||||
pendingToolMediaAttachments: [
|
||||
{ type: "audio" as const, path: "/tmp/generated.mp3", durationMs: 2_000 },
|
||||
{ type: "audio" as const, path: "/tmp/generated.mp3", durationMs: 9_999 },
|
||||
{ type: "audio" as const, path: "/tmp/unselected.mp3", durationMs: 3_000 },
|
||||
],
|
||||
pendingToolMediaTrustByUrl: new Map([
|
||||
["/tmp/generated.mp3", true],
|
||||
["/tmp/unselected.mp3", false],
|
||||
]),
|
||||
pendingToolAudioAsVoice: false,
|
||||
};
|
||||
|
||||
expect(
|
||||
consumePendingToolMediaIntoReply(state, {
|
||||
text: "done",
|
||||
mediaUrls: [" /tmp/generated.mp3 "],
|
||||
}),
|
||||
).toEqual({
|
||||
text: "done",
|
||||
mediaUrls: [" /tmp/generated.mp3 "],
|
||||
attachments: [
|
||||
{
|
||||
type: "audio",
|
||||
path: "/tmp/generated.mp3",
|
||||
durationMs: 2_000,
|
||||
trustedLocalMedia: true,
|
||||
},
|
||||
],
|
||||
trustedLocalMedia: true,
|
||||
});
|
||||
expect(state.pendingToolMediaAttachments).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("does not trust an explicitly selected untrusted pending URL", () => {
|
||||
const state = {
|
||||
pendingToolMediaUrls: ["/tmp/generated.mp3", "/tmp/untrusted.mp3"],
|
||||
pendingToolMediaAttachments: [
|
||||
{ type: "audio" as const, path: "/tmp/generated.mp3" },
|
||||
{
|
||||
type: "audio" as const,
|
||||
path: "/tmp/untrusted.mp3",
|
||||
trustedLocalMedia: true,
|
||||
},
|
||||
],
|
||||
pendingToolMediaTrustByUrl: new Map([
|
||||
["/tmp/generated.mp3", true],
|
||||
["/tmp/untrusted.mp3", false],
|
||||
]),
|
||||
pendingToolAudioAsVoice: false,
|
||||
};
|
||||
|
||||
expect(
|
||||
consumePendingToolMediaIntoReply(state, {
|
||||
text: "done",
|
||||
mediaUrls: ["/tmp/untrusted.mp3"],
|
||||
}),
|
||||
).toEqual({
|
||||
text: "done",
|
||||
mediaUrls: ["/tmp/untrusted.mp3"],
|
||||
attachments: [{ type: "audio", path: "/tmp/untrusted.mp3" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not append queued voice media when the reply already names media", () => {
|
||||
const state = {
|
||||
pendingToolMediaUrls: ["/tmp/reply.opus"],
|
||||
pendingToolMediaTrustByUrl: new Map([["/tmp/reply.opus", true]]),
|
||||
pendingToolAudioAsVoice: true,
|
||||
};
|
||||
|
||||
expect(
|
||||
consumePendingToolMediaIntoReply(state, {
|
||||
text: "done",
|
||||
mediaUrls: ["/tmp/assistant-provided.opus"],
|
||||
}),
|
||||
).toEqual({
|
||||
text: "done",
|
||||
mediaUrls: ["/tmp/assistant-provided.opus"],
|
||||
});
|
||||
expect(state.pendingToolMediaUrls).toStrictEqual([]);
|
||||
expect(state.pendingToolAudioAsVoice).toBe(false);
|
||||
expect(state.pendingToolMediaTrustByUrl.size).toBe(0);
|
||||
});
|
||||
|
||||
it("preserves reasoning replies without consuming queued media", () => {
|
||||
const state = {
|
||||
pendingToolMediaUrls: ["/tmp/a.png"],
|
||||
pendingToolMediaTrustByUrl: new Map([["/tmp/a.png", false]]),
|
||||
pendingToolAudioAsVoice: true,
|
||||
};
|
||||
|
||||
expect(
|
||||
consumePendingToolMediaIntoReply(state, {
|
||||
text: "thinking",
|
||||
isReasoning: true,
|
||||
}),
|
||||
).toEqual({
|
||||
text: "thinking",
|
||||
isReasoning: true,
|
||||
});
|
||||
expect(state.pendingToolMediaUrls).toEqual(["/tmp/a.png"]);
|
||||
expect(state.pendingToolAudioAsVoice).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("consumePendingToolMediaReply", () => {
|
||||
it("reads a media-only reply without consuming queued tool media", () => {
|
||||
const state = {
|
||||
pendingToolMediaUrls: ["/tmp/reply.opus"],
|
||||
pendingToolMediaTrustByUrl: new Map([["/tmp/reply.opus", false]]),
|
||||
pendingToolAudioAsVoice: true,
|
||||
};
|
||||
|
||||
expect(readPendingToolMediaReply(state)).toEqual({
|
||||
mediaUrls: ["/tmp/reply.opus"],
|
||||
audioAsVoice: true,
|
||||
});
|
||||
expect(state.pendingToolMediaUrls).toEqual(["/tmp/reply.opus"]);
|
||||
expect(state.pendingToolAudioAsVoice).toBe(true);
|
||||
});
|
||||
|
||||
it("builds a media-only reply for orphaned tool media", () => {
|
||||
const state = {
|
||||
pendingToolMediaUrls: ["/tmp/reply.opus"],
|
||||
pendingToolMediaTrustByUrl: new Map([["/tmp/reply.opus", false]]),
|
||||
pendingToolAudioAsVoice: true,
|
||||
};
|
||||
|
||||
expect(consumePendingToolMediaReply(state)).toEqual({
|
||||
mediaUrls: ["/tmp/reply.opus"],
|
||||
audioAsVoice: true,
|
||||
});
|
||||
expect(state.pendingToolMediaUrls).toStrictEqual([]);
|
||||
expect(state.pendingToolAudioAsVoice).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Owns pending assistant reply directives and tool-media handoff.
|
||||
*/
|
||||
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
||||
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
|
||||
import type { ReplyDirectiveParseResult } from "../auto-reply/reply/reply-directives.js";
|
||||
import type { BlockReplyPayload } from "./embedded-agent-payloads.js";
|
||||
import type { EmbeddedAgentSubscribeState } from "./embedded-agent-subscribe.handlers.types.js";
|
||||
|
||||
export function hasReplyDirectiveMetadata(
|
||||
parsed: ReplyDirectiveParseResult | null | undefined,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
parsed &&
|
||||
((parsed.mediaUrls?.length ?? 0) > 0 ||
|
||||
parsed.audioAsVoice ||
|
||||
parsed.replyToId ||
|
||||
parsed.replyToTag ||
|
||||
parsed.replyToCurrent),
|
||||
);
|
||||
}
|
||||
|
||||
function hasReplyDirectiveMetadataResult(
|
||||
parsed: ReplyDirectiveParseResult | null | undefined,
|
||||
): parsed is ReplyDirectiveParseResult {
|
||||
return hasReplyDirectiveMetadata(parsed);
|
||||
}
|
||||
|
||||
export function mergeReplyDirectiveResults(
|
||||
first: ReplyDirectiveParseResult | null | undefined,
|
||||
second: ReplyDirectiveParseResult | null | undefined,
|
||||
): ReplyDirectiveParseResult | null {
|
||||
if (!first) {
|
||||
return second ?? null;
|
||||
}
|
||||
if (!second) {
|
||||
return first;
|
||||
}
|
||||
const mediaUrls = uniqueStrings([...(first.mediaUrls ?? []), ...(second.mediaUrls ?? [])]);
|
||||
return {
|
||||
text: `${first.text ?? ""}${second.text ?? ""}`,
|
||||
mediaUrls: mediaUrls.length ? mediaUrls : undefined,
|
||||
replyToId: second.replyToId ?? first.replyToId,
|
||||
replyToCurrent: first.replyToCurrent || second.replyToCurrent,
|
||||
replyToTag: first.replyToTag || second.replyToTag,
|
||||
audioAsVoice: first.audioAsVoice || second.audioAsVoice || undefined,
|
||||
isSilent: first.isSilent || second.isSilent,
|
||||
};
|
||||
}
|
||||
|
||||
function clearPendingToolMedia(
|
||||
state: Pick<
|
||||
EmbeddedAgentSubscribeState,
|
||||
| "pendingToolMediaUrls"
|
||||
| "pendingToolMediaAttachments"
|
||||
| "pendingToolMediaTrustByUrl"
|
||||
| "pendingToolAudioAsVoice"
|
||||
>,
|
||||
) {
|
||||
state.pendingToolMediaUrls = [];
|
||||
state.pendingToolMediaAttachments = [];
|
||||
state.pendingToolMediaTrustByUrl.clear();
|
||||
state.pendingToolAudioAsVoice = false;
|
||||
}
|
||||
|
||||
function hasReplyMedia(payload: BlockReplyPayload): boolean {
|
||||
return (payload.mediaUrls ?? []).some((url) => url.trim().length > 0);
|
||||
}
|
||||
|
||||
function readAlignedPendingToolMedia(
|
||||
state: Pick<
|
||||
EmbeddedAgentSubscribeState,
|
||||
"pendingToolMediaUrls" | "pendingToolMediaAttachments" | "pendingToolMediaTrustByUrl"
|
||||
>,
|
||||
) {
|
||||
const seen = new Set<string>();
|
||||
const mediaUrls: string[] = [];
|
||||
const attachments: NonNullable<BlockReplyPayload["attachments"]> = [];
|
||||
for (const [index, url] of state.pendingToolMediaUrls.entries()) {
|
||||
if (seen.has(url)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(url);
|
||||
mediaUrls.push(url);
|
||||
const { trustedLocalMedia: _untrustedInput, ...attachment } =
|
||||
state.pendingToolMediaAttachments?.[index] ?? {};
|
||||
attachments.push({
|
||||
...attachment,
|
||||
...(state.pendingToolMediaTrustByUrl.get(url) === true ? { trustedLocalMedia: true } : {}),
|
||||
});
|
||||
}
|
||||
return {
|
||||
mediaUrls,
|
||||
attachments: attachments.some((entry) => Object.keys(entry).length > 0)
|
||||
? attachments
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Moves queued tool media into a non-reasoning assistant reply payload. */
|
||||
export function consumePendingToolMediaIntoReply(
|
||||
state: Pick<
|
||||
EmbeddedAgentSubscribeState,
|
||||
| "pendingToolMediaUrls"
|
||||
| "pendingToolMediaAttachments"
|
||||
| "pendingToolMediaTrustByUrl"
|
||||
| "pendingToolAudioAsVoice"
|
||||
>,
|
||||
payload: BlockReplyPayload,
|
||||
): BlockReplyPayload {
|
||||
if (payload.isReasoning) {
|
||||
return payload;
|
||||
}
|
||||
if (state.pendingToolMediaUrls.length === 0 && !state.pendingToolAudioAsVoice) {
|
||||
return payload;
|
||||
}
|
||||
if (hasReplyMedia(payload)) {
|
||||
// Pending tool media is a fallback delivery queue; explicit final media is
|
||||
// the assistant's user-visible selection, while tool output remains in the transcript.
|
||||
const alignedPendingMedia = readAlignedPendingToolMedia(state);
|
||||
const metadataByUrl = new Map(
|
||||
alignedPendingMedia.mediaUrls.map((url, index) => [
|
||||
url,
|
||||
alignedPendingMedia.attachments?.[index] ?? {},
|
||||
]),
|
||||
);
|
||||
const selectedAttachments = (payload.mediaUrls ?? []).map(
|
||||
(url) => metadataByUrl.get(url.trim()) ?? {},
|
||||
);
|
||||
const allSelectedMediaIsPending =
|
||||
(payload.mediaUrls?.length ?? 0) > 0 &&
|
||||
(payload.mediaUrls ?? []).every((url) => metadataByUrl.has(url.trim()));
|
||||
const payloadWithMetadata =
|
||||
payload.attachments?.length ||
|
||||
selectedAttachments.every((entry) => Object.keys(entry).length === 0)
|
||||
? payload
|
||||
: { ...payload, attachments: selectedAttachments };
|
||||
const selectedPayload =
|
||||
allSelectedMediaIsPending &&
|
||||
(payload.mediaUrls ?? []).every(
|
||||
(url) => state.pendingToolMediaTrustByUrl.get(url.trim()) === true,
|
||||
)
|
||||
? { ...payloadWithMetadata, trustedLocalMedia: true }
|
||||
: payloadWithMetadata;
|
||||
clearPendingToolMedia(state);
|
||||
return selectedPayload;
|
||||
}
|
||||
const pendingMedia = readAlignedPendingToolMedia(state);
|
||||
const allPendingMediaTrusted =
|
||||
pendingMedia.mediaUrls.length > 0 &&
|
||||
pendingMedia.mediaUrls.every((url) => state.pendingToolMediaTrustByUrl.get(url) === true);
|
||||
const mergedPayload: BlockReplyPayload = {
|
||||
...payload,
|
||||
mediaUrls: pendingMedia.mediaUrls.length ? pendingMedia.mediaUrls : undefined,
|
||||
attachments: pendingMedia.attachments,
|
||||
audioAsVoice: payload.audioAsVoice || state.pendingToolAudioAsVoice || undefined,
|
||||
...(payload.trustedLocalMedia || allPendingMediaTrusted ? { trustedLocalMedia: true } : {}),
|
||||
};
|
||||
clearPendingToolMedia(state);
|
||||
return mergedPayload;
|
||||
}
|
||||
|
||||
/** Consumes queued tool media as a standalone reply payload. */
|
||||
export function consumePendingToolMediaReply(
|
||||
state: Pick<
|
||||
EmbeddedAgentSubscribeState,
|
||||
| "pendingToolMediaUrls"
|
||||
| "pendingToolMediaAttachments"
|
||||
| "pendingToolMediaTrustByUrl"
|
||||
| "pendingToolAudioAsVoice"
|
||||
>,
|
||||
): BlockReplyPayload | null {
|
||||
const payload = readPendingToolMediaReply(state);
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
clearPendingToolMedia(state);
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** Reads queued tool media without clearing it. */
|
||||
export function readPendingToolMediaReply(
|
||||
state: Pick<
|
||||
EmbeddedAgentSubscribeState,
|
||||
| "pendingToolMediaUrls"
|
||||
| "pendingToolMediaAttachments"
|
||||
| "pendingToolMediaTrustByUrl"
|
||||
| "pendingToolAudioAsVoice"
|
||||
>,
|
||||
): BlockReplyPayload | null {
|
||||
if (state.pendingToolMediaUrls.length === 0 && !state.pendingToolAudioAsVoice) {
|
||||
return null;
|
||||
}
|
||||
const pendingMedia = readAlignedPendingToolMedia(state);
|
||||
const allPendingMediaTrusted =
|
||||
pendingMedia.mediaUrls.length > 0 &&
|
||||
pendingMedia.mediaUrls.every((url) => state.pendingToolMediaTrustByUrl.get(url) === true);
|
||||
return {
|
||||
mediaUrls: pendingMedia.mediaUrls.length ? pendingMedia.mediaUrls : undefined,
|
||||
attachments: pendingMedia.attachments,
|
||||
audioAsVoice: state.pendingToolAudioAsVoice || undefined,
|
||||
...(allPendingMediaTrusted ? { trustedLocalMedia: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function recordPendingAssistantReplyDirectives(
|
||||
state: Pick<EmbeddedAgentSubscribeState, "pendingAssistantReplyDirectives">,
|
||||
parsed: ReplyDirectiveParseResult | null | undefined,
|
||||
) {
|
||||
if (!hasReplyDirectiveMetadataResult(parsed)) {
|
||||
return;
|
||||
}
|
||||
const current = state.pendingAssistantReplyDirectives;
|
||||
const mediaUrls = Array.from(
|
||||
new Set([...(current?.mediaUrls ?? []), ...(parsed.mediaUrls ?? [])]),
|
||||
);
|
||||
state.pendingAssistantReplyDirectives = {
|
||||
mediaUrls: mediaUrls.length ? mediaUrls : undefined,
|
||||
audioAsVoice: current?.audioAsVoice || parsed?.audioAsVoice || undefined,
|
||||
replyToId: parsed?.replyToId ?? current?.replyToId,
|
||||
replyToTag: current?.replyToTag || parsed.replyToTag || undefined,
|
||||
replyToCurrent: current?.replyToCurrent || parsed.replyToCurrent || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Merges pending reply directives into one reply payload and clears them. */
|
||||
export function consumePendingAssistantReplyDirectivesIntoReply(
|
||||
state: Pick<EmbeddedAgentSubscribeState, "pendingAssistantReplyDirectives">,
|
||||
payload: BlockReplyPayload,
|
||||
): BlockReplyPayload {
|
||||
if (payload.isReasoning || !state.pendingAssistantReplyDirectives) {
|
||||
return payload;
|
||||
}
|
||||
const pending = state.pendingAssistantReplyDirectives;
|
||||
const mediaUrls = Array.from(
|
||||
new Set([...(payload.mediaUrls ?? []), ...(pending.mediaUrls ?? [])]),
|
||||
);
|
||||
state.pendingAssistantReplyDirectives = undefined;
|
||||
return {
|
||||
...payload,
|
||||
mediaUrls: mediaUrls.length ? mediaUrls : undefined,
|
||||
audioAsVoice: payload.audioAsVoice || pending.audioAsVoice || undefined,
|
||||
replyToId: payload.replyToId ?? pending.replyToId,
|
||||
replyToTag: Boolean(payload.replyToTag || pending.replyToTag) || undefined,
|
||||
replyToCurrent: Boolean(payload.replyToCurrent || pending.replyToCurrent) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** True when a reply payload has text, media, or voice content worth sending. */
|
||||
export function hasAssistantVisibleReply(params: {
|
||||
text?: string;
|
||||
mediaUrls?: string[];
|
||||
mediaUrl?: string;
|
||||
audioAsVoice?: boolean;
|
||||
}): boolean {
|
||||
return resolveSendableOutboundReplyParts(params).hasContent || Boolean(params.audioAsVoice);
|
||||
}
|
||||
|
||||
/** Builds normalized stream payload data for assistant visible output. */
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
consumePendingAssistantReplyDirectivesIntoReply,
|
||||
hasAssistantVisibleReply,
|
||||
} from "./embedded-agent-subscribe.handlers.messages.replies.js";
|
||||
import {
|
||||
buildAssistantStreamData,
|
||||
recordPendingAssistantReplyDirectives,
|
||||
} from "./embedded-agent-subscribe.handlers.messages.test-support.js";
|
||||
|
||||
describe("hasAssistantVisibleReply", () => {
|
||||
it("treats audio-only payloads as visible", () => {
|
||||
expect(hasAssistantVisibleReply({ audioAsVoice: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("detects text or media visibility", () => {
|
||||
expect(hasAssistantVisibleReply({ text: "hello" })).toBe(true);
|
||||
expect(hasAssistantVisibleReply({ mediaUrls: ["https://example.com/a.png"] })).toBe(true);
|
||||
expect(hasAssistantVisibleReply({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAssistantStreamData", () => {
|
||||
it("normalizes media payloads for assistant stream events", () => {
|
||||
expect(
|
||||
buildAssistantStreamData({
|
||||
text: "hello",
|
||||
delta: "he",
|
||||
replace: true,
|
||||
mediaUrl: "https://example.com/a.png",
|
||||
phase: "final_answer",
|
||||
}),
|
||||
).toEqual({
|
||||
text: "hello",
|
||||
delta: "he",
|
||||
replace: true,
|
||||
mediaUrls: ["https://example.com/a.png"],
|
||||
phase: "final_answer",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("pending assistant reply directives", () => {
|
||||
it("merges directive metadata into the next non-reasoning block reply", () => {
|
||||
const state = { pendingAssistantReplyDirectives: undefined };
|
||||
|
||||
recordPendingAssistantReplyDirectives(state, {
|
||||
text: "",
|
||||
mediaUrls: ["/tmp/reply.ogg"],
|
||||
replyToCurrent: true,
|
||||
replyToTag: true,
|
||||
audioAsVoice: true,
|
||||
isSilent: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
consumePendingAssistantReplyDirectivesIntoReply(state, {
|
||||
text: "Done.",
|
||||
}),
|
||||
).toEqual({
|
||||
text: "Done.",
|
||||
mediaUrls: ["/tmp/reply.ogg"],
|
||||
audioAsVoice: true,
|
||||
replyToId: undefined,
|
||||
replyToTag: true,
|
||||
replyToCurrent: true,
|
||||
});
|
||||
expect(state.pendingAssistantReplyDirectives).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not consume pending directive metadata on reasoning replies", () => {
|
||||
const state = {
|
||||
pendingAssistantReplyDirectives: {
|
||||
mediaUrls: ["/tmp/reply.png"],
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
consumePendingAssistantReplyDirectivesIntoReply(state, {
|
||||
text: "Thinking...",
|
||||
isReasoning: true,
|
||||
}),
|
||||
).toEqual({
|
||||
text: "Thinking...",
|
||||
isReasoning: true,
|
||||
});
|
||||
expect(state.pendingAssistantReplyDirectives?.mediaUrls).toEqual(["/tmp/reply.png"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* Projects provider assistant messages into ordered visible stream state.
|
||||
*/
|
||||
import { asOptionalRecord as asRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
|
||||
import {
|
||||
parseReplyDirectives,
|
||||
type ReplyDirectiveParseResult,
|
||||
} from "../auto-reply/reply/reply-directives.js";
|
||||
import { splitTrailingDirective } from "../auto-reply/reply/streaming-directives.js";
|
||||
import type { AssistantMessage } from "../llm/types.js";
|
||||
import {
|
||||
parseAssistantTextSignature,
|
||||
resolveAssistantMessagePhase,
|
||||
type AssistantPhase,
|
||||
} from "../shared/chat-message-content.js";
|
||||
import { normalizeTextForComparison } from "./embedded-agent-helpers.js";
|
||||
import { runBestEffortCallback } from "./embedded-agent-subscribe.callback.js";
|
||||
import { hasReplyDirectiveMetadata } from "./embedded-agent-subscribe.handlers.messages.replies.js";
|
||||
import type {
|
||||
EmbeddedAgentSubscribeContext,
|
||||
EmbeddedAgentSubscribeState,
|
||||
} from "./embedded-agent-subscribe.handlers.types.js";
|
||||
import type { AgentMessage } from "./runtime/index.js";
|
||||
|
||||
export function shouldSuppressAssistantVisibleOutput(message: AgentMessage | undefined): boolean {
|
||||
return resolveAssistantMessagePhase(message) === "commentary";
|
||||
}
|
||||
|
||||
export function isSubscribeTranscriptOnlyOpenClawAssistantMessage(
|
||||
message: AgentMessage | undefined,
|
||||
): boolean {
|
||||
if (!message || message.role !== "assistant") {
|
||||
return false;
|
||||
}
|
||||
const provider = normalizeOptionalString(message.provider) ?? "";
|
||||
const model = normalizeOptionalString(message.model) ?? "";
|
||||
return provider === "openclaw" && (model === "delivery-mirror" || model === "gateway-injected");
|
||||
}
|
||||
|
||||
const RESPONSES_API_IDS = new Set([
|
||||
"openai-responses",
|
||||
"openai-chatgpt-responses",
|
||||
"azure-openai-responses",
|
||||
"openclaw-openai-responses-transport",
|
||||
"openclaw-openai-chatgpt-responses-transport",
|
||||
"openclaw-azure-openai-responses-transport",
|
||||
]);
|
||||
|
||||
export function isResponsesApiAssistantMessage(message: AgentMessage | undefined): boolean {
|
||||
if (!message || message.role !== "assistant") {
|
||||
return false;
|
||||
}
|
||||
const api = normalizeOptionalString((message as { api?: unknown }).api) ?? "";
|
||||
return RESPONSES_API_IDS.has(api);
|
||||
}
|
||||
|
||||
export function isAnthropicAssistantMessage(message: AgentMessage | undefined): boolean {
|
||||
if (!message || message.role !== "assistant") {
|
||||
return false;
|
||||
}
|
||||
const api = normalizeOptionalString((message as { api?: unknown }).api) ?? "";
|
||||
return api === "anthropic-messages";
|
||||
}
|
||||
|
||||
export function isOpenAiCompletionsAssistantMessage(message: AgentMessage | undefined): boolean {
|
||||
if (!message || message.role !== "assistant") {
|
||||
return false;
|
||||
}
|
||||
const api = normalizeOptionalString((message as { api?: unknown }).api) ?? "";
|
||||
return api === "openai-completions" || api === "openclaw-openai-completions-transport";
|
||||
}
|
||||
|
||||
export function extractStandaloneMessageToolText(
|
||||
text: string,
|
||||
params: { allowCurrentSourceReply?: boolean; allowRoutedReply?: boolean } = {},
|
||||
): string | undefined {
|
||||
try {
|
||||
const record = asRecord(JSON.parse(text.trim()) as unknown);
|
||||
const args = asRecord(record?.arguments);
|
||||
const hasRoute = Boolean(
|
||||
normalizeOptionalString(args?.target) ||
|
||||
normalizeOptionalString(args?.to) ||
|
||||
normalizeOptionalString(args?.channel) ||
|
||||
normalizeOptionalString(args?.accountId) ||
|
||||
Array.isArray(args?.targets),
|
||||
);
|
||||
if (
|
||||
normalizeOptionalString(record?.name) !== "message" ||
|
||||
normalizeOptionalString(args?.action) !== "send" ||
|
||||
(hasRoute ? !params.allowRoutedReply : !params.allowCurrentSourceReply)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeOptionalString(args?.message);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAssistantStreamItemId(params: {
|
||||
contentIndex?: unknown;
|
||||
message: AgentMessage | undefined;
|
||||
}): string | undefined {
|
||||
const content = (params.message as { content?: unknown } | undefined)?.content;
|
||||
if (!Array.isArray(content)) {
|
||||
return undefined;
|
||||
}
|
||||
const contentIndex =
|
||||
typeof params.contentIndex === "number" &&
|
||||
Number.isInteger(params.contentIndex) &&
|
||||
params.contentIndex >= 0
|
||||
? params.contentIndex
|
||||
: undefined;
|
||||
const indexedBlock = contentIndex !== undefined ? content[contentIndex] : undefined;
|
||||
const indexedRecord =
|
||||
indexedBlock && typeof indexedBlock === "object"
|
||||
? (indexedBlock as { type?: unknown })
|
||||
: undefined;
|
||||
const hasIndexedTextBlock = indexedRecord?.type === "text";
|
||||
const candidateStart =
|
||||
hasIndexedTextBlock && contentIndex !== undefined ? contentIndex : content.length - 1;
|
||||
const candidateEnd = hasIndexedTextBlock ? candidateStart : 0;
|
||||
for (let index = candidateStart; index >= candidateEnd; index -= 1) {
|
||||
const block = content[index];
|
||||
if (!block || typeof block !== "object") {
|
||||
continue;
|
||||
}
|
||||
const record = block as { type?: unknown; textSignature?: unknown };
|
||||
if (record.type !== "text") {
|
||||
continue;
|
||||
}
|
||||
const signature = parseAssistantTextSignature(record);
|
||||
if (signature?.id) {
|
||||
return signature.id;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveAssistantStreamContentIndex(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined;
|
||||
}
|
||||
|
||||
export function scopeAssistantMessageToStreamBlock(
|
||||
message: AssistantMessage,
|
||||
contentIndex: number | undefined,
|
||||
itemId: string | undefined,
|
||||
): AssistantMessage {
|
||||
if (!Array.isArray(message.content)) {
|
||||
return message;
|
||||
}
|
||||
const indexedBlock = contentIndex === undefined ? undefined : message.content[contentIndex];
|
||||
let block =
|
||||
indexedBlock && typeof indexedBlock === "object" && indexedBlock.type === "text"
|
||||
? indexedBlock
|
||||
: undefined;
|
||||
if (!block && itemId) {
|
||||
for (let index = message.content.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = message.content[index];
|
||||
if (
|
||||
candidate &&
|
||||
typeof candidate === "object" &&
|
||||
candidate.type === "text" &&
|
||||
parseAssistantTextSignature(candidate)?.id === itemId
|
||||
) {
|
||||
block = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!block) {
|
||||
return message;
|
||||
}
|
||||
// Provider partials are cumulative across content blocks. Once a content
|
||||
// index becomes a logical reply boundary, downstream snapshots must be
|
||||
// cumulative only within that block or earlier text is replayed.
|
||||
return { ...message, content: [block] };
|
||||
}
|
||||
|
||||
export function emitReasoningEnd(ctx: EmbeddedAgentSubscribeContext) {
|
||||
if (!ctx.state.reasoningStreamOpen) {
|
||||
return;
|
||||
}
|
||||
ctx.state.reasoningStreamOpen = false;
|
||||
runBestEffortCallback({
|
||||
label: "reasoning end",
|
||||
log: ctx.log,
|
||||
callback: () => ctx.params.onReasoningEnd?.(),
|
||||
});
|
||||
}
|
||||
|
||||
export function emitAssistantMessageStart(ctx: EmbeddedAgentSubscribeContext) {
|
||||
runBestEffortCallback({
|
||||
label: "assistant message start",
|
||||
log: ctx.log,
|
||||
callback: () => ctx.params.onAssistantMessageStart?.(),
|
||||
});
|
||||
}
|
||||
|
||||
export function openReasoningStream(ctx: EmbeddedAgentSubscribeContext) {
|
||||
ctx.state.reasoningStreamOpen = true;
|
||||
}
|
||||
|
||||
export function shouldSuppressDeterministicApprovalOutput(
|
||||
state: Pick<
|
||||
EmbeddedAgentSubscribeState,
|
||||
"deterministicApprovalPromptPending" | "deterministicApprovalPromptSent"
|
||||
>,
|
||||
): boolean {
|
||||
return state.deterministicApprovalPromptPending || state.deterministicApprovalPromptSent;
|
||||
}
|
||||
|
||||
export function hasMessageToolOnlySourceDelivery(ctx: EmbeddedAgentSubscribeContext): boolean {
|
||||
return (
|
||||
ctx.params.sourceReplyDeliveryMode === "message_tool_only" &&
|
||||
(ctx.state.messageToolOnlySourceReplyDelivered ||
|
||||
ctx.params.hasDeliveredMessageToolOnlySourceReply?.() === true ||
|
||||
(ctx.state.messagingToolSourceReplyPayloads?.length ?? 0) > 0)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveCurrentSourceMessagingToolPartial(
|
||||
state: Pick<
|
||||
EmbeddedAgentSubscribeState,
|
||||
"currentSourceMessagingToolHeldPartial" | "currentSourceMessagingToolSentTextsNormalized"
|
||||
>,
|
||||
params: {
|
||||
evtType: "text_delta" | "text_start" | "text_end";
|
||||
text: string;
|
||||
visibleDelta: string;
|
||||
},
|
||||
): { hold: boolean; text: string } {
|
||||
const held = state.currentSourceMessagingToolHeldPartial;
|
||||
const text =
|
||||
held && params.evtType === "text_delta" && !params.text.startsWith(held)
|
||||
? `${held}${params.visibleDelta || params.text}`
|
||||
: params.text;
|
||||
const normalized = normalizeTextForComparison(text);
|
||||
if (!normalized) {
|
||||
state.currentSourceMessagingToolHeldPartial = undefined;
|
||||
return { hold: false, text };
|
||||
}
|
||||
// A confirmed current-source tool send already made this prefix visible.
|
||||
// Hold it until the assistant either repeats the sent text or diverges with new content.
|
||||
const hold = state.currentSourceMessagingToolSentTextsNormalized.some(
|
||||
(sentText) => sentText === normalized || sentText.startsWith(normalized),
|
||||
);
|
||||
state.currentSourceMessagingToolHeldPartial = hold ? text : undefined;
|
||||
return { hold, text };
|
||||
}
|
||||
|
||||
export function appendBlockReplyChunk(ctx: EmbeddedAgentSubscribeContext, chunk: string) {
|
||||
if (ctx.blockChunker) {
|
||||
ctx.blockChunker.append(chunk);
|
||||
return;
|
||||
}
|
||||
ctx.state.blockBuffer += chunk;
|
||||
}
|
||||
|
||||
export function replaceBlockReplyBuffer(ctx: EmbeddedAgentSubscribeContext, text: string) {
|
||||
if (ctx.blockChunker) {
|
||||
ctx.blockChunker.reset();
|
||||
ctx.blockChunker.append(text);
|
||||
return;
|
||||
}
|
||||
ctx.state.blockBuffer = text;
|
||||
}
|
||||
|
||||
export function resolveAssistantTextChunk(params: {
|
||||
evtType: "text_delta" | "text_start" | "text_end";
|
||||
delta: string;
|
||||
content: string;
|
||||
accumulatedText: string;
|
||||
}): string {
|
||||
const { evtType, delta, content, accumulatedText } = params;
|
||||
if (evtType === "text_delta") {
|
||||
return delta;
|
||||
}
|
||||
if (delta) {
|
||||
return delta;
|
||||
}
|
||||
if (!content) {
|
||||
return "";
|
||||
}
|
||||
// KNOWN: Some providers resend full content on `text_end`.
|
||||
// We only append a suffix (or nothing) to keep output monotonic.
|
||||
if (content.startsWith(accumulatedText)) {
|
||||
return content.slice(accumulatedText.length);
|
||||
}
|
||||
if (accumulatedText.startsWith(content)) {
|
||||
return "";
|
||||
}
|
||||
if (!accumulatedText.includes(content)) {
|
||||
return content;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function resolveStreamVisibleText(params: {
|
||||
previousRawText: string;
|
||||
visibleDelta: string;
|
||||
finalText?: string;
|
||||
}): { rawText: string; visibleText: string } {
|
||||
if (params.finalText !== undefined) {
|
||||
const rawText = params.finalText;
|
||||
return { rawText, visibleText: rawText.trim() };
|
||||
}
|
||||
const rawText = `${params.previousRawText}${params.visibleDelta}`;
|
||||
return { rawText, visibleText: rawText.trim() };
|
||||
}
|
||||
|
||||
export function resolveTextAppendDelta(previousText: string, nextText: string): string {
|
||||
if (!nextText) {
|
||||
return "";
|
||||
}
|
||||
if (!previousText) {
|
||||
return nextText;
|
||||
}
|
||||
if (nextText.startsWith(previousText)) {
|
||||
return nextText.slice(previousText.length);
|
||||
}
|
||||
if (previousText.startsWith(nextText)) {
|
||||
return "";
|
||||
}
|
||||
return nextText;
|
||||
}
|
||||
|
||||
export function copyPartialBlockState(
|
||||
target: EmbeddedAgentSubscribeState["partialBlockState"],
|
||||
source: EmbeddedAgentSubscribeState["partialBlockState"],
|
||||
) {
|
||||
const copyFenceState = (fence?: typeof source.fence) =>
|
||||
fence
|
||||
? {
|
||||
atLineStart: fence.atLineStart,
|
||||
...(fence.open ? { open: { ...fence.open } } : {}),
|
||||
}
|
||||
: undefined;
|
||||
target.thinking = source.thinking;
|
||||
target.final = source.final;
|
||||
target.inlineCode = { ...source.inlineCode };
|
||||
target.fence = copyFenceState(source.fence);
|
||||
target.reasoningInlineCode = source.reasoningInlineCode
|
||||
? { ...source.reasoningInlineCode }
|
||||
: undefined;
|
||||
target.reasoningFence = copyFenceState(source.reasoningFence);
|
||||
target.reasoningPendingFenceFragment = source.reasoningPendingFenceFragment;
|
||||
target.finalInlineCode = source.finalInlineCode ? { ...source.finalInlineCode } : undefined;
|
||||
target.finalFence = copyFenceState(source.finalFence);
|
||||
target.pendingFenceFragment = source.pendingFenceFragment;
|
||||
target.pendingTagFragment = source.pendingTagFragment;
|
||||
}
|
||||
|
||||
function containsCompleteMediaDirectiveLine(text: string): boolean {
|
||||
return /(?:^|\n)\s*MEDIA:\s*\S[^\n]*(?:\n|$)/i.test(text);
|
||||
}
|
||||
|
||||
function resolveIncrementalStreamingReplyText(params: {
|
||||
evtType: "text_delta" | "text_start" | "text_end";
|
||||
next: string;
|
||||
previousRawText: string;
|
||||
previousCleaned: string;
|
||||
visibleDelta: string;
|
||||
parsedStreamDirectives: ReplyDirectiveParseResult | null;
|
||||
shouldUsePhaseAwareBlockReply: boolean;
|
||||
}): string | undefined {
|
||||
if (
|
||||
params.evtType === "text_end" ||
|
||||
!params.parsedStreamDirectives ||
|
||||
params.parsedStreamDirectives.isSilent ||
|
||||
hasReplyDirectiveMetadata(params.parsedStreamDirectives) ||
|
||||
containsCompleteMediaDirectiveLine(params.visibleDelta) ||
|
||||
params.parsedStreamDirectives.text !== params.visibleDelta
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
!params.shouldUsePhaseAwareBlockReply &&
|
||||
params.previousCleaned === params.previousRawText.trim()
|
||||
) {
|
||||
return params.next;
|
||||
}
|
||||
|
||||
const cleanedCandidate = `${params.previousCleaned}${params.parsedStreamDirectives.text}`.trim();
|
||||
return cleanedCandidate === params.next ? cleanedCandidate : undefined;
|
||||
}
|
||||
|
||||
export function resolveStreamingReplyText(params: {
|
||||
evtType: "text_delta" | "text_start" | "text_end";
|
||||
next: string;
|
||||
previousRawText: string;
|
||||
previousCleaned: string;
|
||||
visibleDelta: string;
|
||||
parsedStreamDirectives: ReplyDirectiveParseResult | null;
|
||||
shouldUsePhaseAwareBlockReply: boolean;
|
||||
}): string {
|
||||
if (!params.parsedStreamDirectives && params.evtType === "text_delta") {
|
||||
return params.previousCleaned;
|
||||
}
|
||||
|
||||
return (
|
||||
resolveIncrementalStreamingReplyText(params) ??
|
||||
parseReplyDirectives(
|
||||
params.evtType === "text_end" ? params.next : splitTrailingDirective(params.next).text,
|
||||
).text
|
||||
);
|
||||
}
|
||||
|
||||
/** Records parsed reply directives until a sendable reply payload is built. */
|
||||
|
||||
export function buildAssistantStreamData(params: {
|
||||
text?: string;
|
||||
delta?: string;
|
||||
replace?: boolean;
|
||||
mediaUrls?: string[];
|
||||
mediaUrl?: string;
|
||||
phase?: AssistantPhase;
|
||||
itemId?: string;
|
||||
}): {
|
||||
text: string;
|
||||
delta: string;
|
||||
replace?: true;
|
||||
mediaUrls?: string[];
|
||||
phase?: AssistantPhase;
|
||||
itemId?: string;
|
||||
} {
|
||||
const mediaUrls = resolveSendableOutboundReplyParts(params).mediaUrls;
|
||||
return {
|
||||
text: params.text ?? "",
|
||||
delta: params.delta ?? "",
|
||||
replace: params.replace ? true : undefined,
|
||||
mediaUrls: mediaUrls.length ? mediaUrls : undefined,
|
||||
phase: params.phase,
|
||||
itemId: params.itemId,
|
||||
};
|
||||
}
|
||||
|
||||
/** Handles assistant message-start boundaries for streaming state. */
|
||||
@@ -0,0 +1,221 @@
|
||||
import { vi } from "vitest";
|
||||
import { createInlineCodeState } from "../../packages/markdown-core/src/code-spans.js";
|
||||
import { createStreamingDirectiveAccumulator } from "../auto-reply/reply/streaming-directives.js";
|
||||
import { handleMessageEnd } from "./embedded-agent-subscribe.handlers.messages.lifecycle.js";
|
||||
import { handleMessageUpdate } from "./embedded-agent-subscribe.handlers.messages.update.js";
|
||||
import type { EmbeddedAgentSubscribeContext } from "./embedded-agent-subscribe.handlers.types.js";
|
||||
import { createThinkingTagStreamState } from "./embedded-agent-utils.js";
|
||||
|
||||
export function updateMessage(
|
||||
context: EmbeddedAgentSubscribeContext,
|
||||
event: { message: unknown; assistantMessageEvent?: unknown },
|
||||
) {
|
||||
// Stream fixtures intentionally include incomplete and malformed provider payloads.
|
||||
return handleMessageUpdate(context, {
|
||||
type: "message_update",
|
||||
...event,
|
||||
} as Parameters<typeof handleMessageUpdate>[1]);
|
||||
}
|
||||
|
||||
export function endMessage(context: EmbeddedAgentSubscribeContext, event: { message: unknown }) {
|
||||
// Message-end coverage includes malformed content and partial provider usage.
|
||||
return handleMessageEnd(context, {
|
||||
type: "message_end",
|
||||
...event,
|
||||
} as Parameters<typeof handleMessageEnd>[1]);
|
||||
}
|
||||
|
||||
export function createMessageUpdateContext(
|
||||
params: {
|
||||
onAgentEvent?: ReturnType<typeof vi.fn>;
|
||||
onPartialReply?: ReturnType<typeof vi.fn>;
|
||||
flushBlockReplyBuffer?: ReturnType<typeof vi.fn>;
|
||||
resetAssistantMessageState?: ReturnType<typeof vi.fn>;
|
||||
debug?: ReturnType<typeof vi.fn>;
|
||||
shouldEmitPartialReplies?: boolean;
|
||||
sourceReplyDeliveryMode?: "automatic" | "message_tool_only";
|
||||
consumePartialReplyDirectives?: ReturnType<typeof vi.fn>;
|
||||
stripBlockTags?: ReturnType<typeof vi.fn>;
|
||||
emitReasoningStream?: ReturnType<typeof vi.fn>;
|
||||
state?: Record<string, unknown>;
|
||||
} = {},
|
||||
) {
|
||||
// Update context fixture wires the partial-reply path through the same
|
||||
// directive accumulator used by streaming runtime events.
|
||||
const partialReplyDirectiveAccumulator = createStreamingDirectiveAccumulator();
|
||||
const onAgentEvent = params.onAgentEvent as ((event: unknown) => void) | undefined;
|
||||
const onPartialReply = params.onPartialReply as ((event: unknown) => void) | undefined;
|
||||
return {
|
||||
params: {
|
||||
runId: "run-1",
|
||||
session: { id: "session-1" },
|
||||
...(params.sourceReplyDeliveryMode
|
||||
? { sourceReplyDeliveryMode: params.sourceReplyDeliveryMode }
|
||||
: {}),
|
||||
...(params.onAgentEvent ? { onAgentEvent: params.onAgentEvent } : {}),
|
||||
...(params.onPartialReply ? { onPartialReply: params.onPartialReply } : {}),
|
||||
},
|
||||
state: {
|
||||
deterministicApprovalPromptPending: false,
|
||||
deterministicApprovalPromptSent: false,
|
||||
currentSourceMessagingToolSentTextsNormalized: [],
|
||||
currentSourceMessagingToolHeldPartial: undefined,
|
||||
reasoningStreamOpen: false,
|
||||
streamReasoning: false,
|
||||
deltaBuffer: "",
|
||||
thinkingTagStream: createThinkingTagStreamState(),
|
||||
blockBuffer: "",
|
||||
partialBlockState: {
|
||||
thinking: false,
|
||||
final: false,
|
||||
inlineCode: createInlineCodeState(),
|
||||
},
|
||||
lastStreamedAssistant: undefined,
|
||||
lastStreamedAssistantCleaned: undefined,
|
||||
emittedAssistantUpdate: false,
|
||||
shouldEmitPartialReplies: params.shouldEmitPartialReplies ?? true,
|
||||
blockReplyBreak: "text_end",
|
||||
assistantMessageIndex: 0,
|
||||
lastAssistantStreamItemId: undefined,
|
||||
assistantTexts: [],
|
||||
pendingAssistantReplyDirectives: undefined,
|
||||
...params.state,
|
||||
},
|
||||
log: { debug: params.debug ?? vi.fn() },
|
||||
noteLastAssistant: vi.fn(),
|
||||
noteCompletedAssistant: vi.fn(),
|
||||
stripBlockTags: params.stripBlockTags ?? vi.fn((text: string) => text),
|
||||
consumePartialReplyDirectives:
|
||||
params.consumePartialReplyDirectives ??
|
||||
vi.fn((text: string, options?: { final?: boolean }) =>
|
||||
partialReplyDirectiveAccumulator.consume(text, options),
|
||||
),
|
||||
emitReasoningStream: params.emitReasoningStream ?? vi.fn(),
|
||||
flushBlockReplyBuffer: params.flushBlockReplyBuffer ?? vi.fn(),
|
||||
resetAssistantMessageState: params.resetAssistantMessageState ?? vi.fn(),
|
||||
recordAssistantUsage: vi.fn(),
|
||||
commitAssistantUsage: vi.fn(),
|
||||
emitAssistantStreamData: vi.fn(
|
||||
(
|
||||
data: Parameters<EmbeddedAgentSubscribeContext["emitAssistantStreamData"]>[0],
|
||||
options?: { emitPartialReply?: boolean },
|
||||
) => {
|
||||
onAgentEvent?.({ stream: "assistant", data });
|
||||
if (options?.emitPartialReply === true && (params.shouldEmitPartialReplies ?? true)) {
|
||||
onPartialReply?.(data);
|
||||
}
|
||||
},
|
||||
),
|
||||
} as unknown as EmbeddedAgentSubscribeContext;
|
||||
}
|
||||
|
||||
export function createMessageEndContext(
|
||||
params: {
|
||||
onAgentEvent?: ReturnType<typeof vi.fn>;
|
||||
onBlockReply?: ReturnType<typeof vi.fn>;
|
||||
emitBlockReply?: ReturnType<typeof vi.fn>;
|
||||
finalizeAssistantTexts?: ReturnType<typeof vi.fn>;
|
||||
flushBlockReplyBuffer?: ReturnType<typeof vi.fn>;
|
||||
consumeReplyDirectives?: ReturnType<typeof vi.fn>;
|
||||
stripBlockTags?: ReturnType<typeof vi.fn>;
|
||||
warn?: ReturnType<typeof vi.fn>;
|
||||
builtinToolNames?: ReadonlySet<string>;
|
||||
sourceReplyDeliveryMode?: "automatic" | "message_tool_only";
|
||||
enforceFinalTag?: boolean;
|
||||
blockChunker?: { hasBuffered: () => boolean; reset: () => void };
|
||||
state?: Record<string, unknown>;
|
||||
} = {},
|
||||
) {
|
||||
// Message-end context starts with buffered assistant text so tests can assert
|
||||
// final flushing, directive consumption, and source-reply behavior.
|
||||
const onAgentEvent = params.onAgentEvent as ((event: unknown) => void) | undefined;
|
||||
return {
|
||||
params: {
|
||||
runId: "run-1",
|
||||
session: { id: "session-1" },
|
||||
...(params.sourceReplyDeliveryMode
|
||||
? { sourceReplyDeliveryMode: params.sourceReplyDeliveryMode }
|
||||
: {}),
|
||||
...(params.enforceFinalTag !== undefined ? { enforceFinalTag: params.enforceFinalTag } : {}),
|
||||
...(params.onAgentEvent ? { onAgentEvent: params.onAgentEvent } : {}),
|
||||
...(params.onBlockReply ? { onBlockReply: params.onBlockReply } : { onBlockReply: vi.fn() }),
|
||||
},
|
||||
state: {
|
||||
assistantTexts: [],
|
||||
assistantTextBaseline: 0,
|
||||
emittedAssistantUpdate: false,
|
||||
deterministicApprovalPromptPending: false,
|
||||
deterministicApprovalPromptSent: false,
|
||||
messagingToolSentTexts: [],
|
||||
messagingToolSentTextsNormalized: [],
|
||||
currentSourceMessagingToolSentTextsNormalized: [],
|
||||
currentSourceMessagingToolHeldPartial: undefined,
|
||||
includeReasoning: false,
|
||||
streamReasoning: false,
|
||||
blockReplyBreak: "message_end",
|
||||
deltaBuffer: "Need send.",
|
||||
blockBuffer: "Need send.",
|
||||
blockState: {
|
||||
thinking: false,
|
||||
final: false,
|
||||
inlineCode: createInlineCodeState(),
|
||||
},
|
||||
partialBlockState: {
|
||||
thinking: false,
|
||||
final: false,
|
||||
inlineCode: createInlineCodeState(),
|
||||
},
|
||||
lastStreamedAssistant: undefined,
|
||||
lastStreamedAssistantCleaned: undefined,
|
||||
lastReasoningSent: undefined,
|
||||
reasoningStreamOpen: false,
|
||||
...params.state,
|
||||
},
|
||||
noteLastAssistant: vi.fn(),
|
||||
noteCompletedAssistant: vi.fn(),
|
||||
recordAssistantUsage: vi.fn(),
|
||||
commitAssistantUsage: vi.fn(),
|
||||
log: { debug: vi.fn(), info: vi.fn(), warn: params.warn ?? vi.fn() },
|
||||
builtinToolNames: params.builtinToolNames,
|
||||
stripBlockTags: params.stripBlockTags ?? vi.fn((text: string) => text),
|
||||
finalizeAssistantTexts: params.finalizeAssistantTexts ?? vi.fn(),
|
||||
emitAssistantStreamData: vi.fn(
|
||||
(data: Parameters<EmbeddedAgentSubscribeContext["emitAssistantStreamData"]>[0]) => {
|
||||
onAgentEvent?.({ stream: "assistant", data });
|
||||
},
|
||||
),
|
||||
emitBlockReply: params.emitBlockReply ?? vi.fn(),
|
||||
consumeReplyDirectives: params.consumeReplyDirectives ?? vi.fn(() => ({ text: "Need send." })),
|
||||
emitReasoningStream: vi.fn(),
|
||||
flushBlockReplyBuffer: params.flushBlockReplyBuffer ?? vi.fn(),
|
||||
blockChunker: params.blockChunker ?? null,
|
||||
} as unknown as EmbeddedAgentSubscribeContext;
|
||||
}
|
||||
|
||||
export function firstMockCall(mock: { mock: { calls: unknown[][] } }, label: string): unknown[] {
|
||||
const call = mock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error(`Expected ${label} to be called`);
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
export function firstMockArg(mock: { mock: { calls: unknown[][] } }, label: string): unknown {
|
||||
return firstMockCall(mock, label)[0];
|
||||
}
|
||||
|
||||
export function createMessageToolEnvelope(
|
||||
message: string,
|
||||
args: Record<string, unknown> = {},
|
||||
): string {
|
||||
// Messaging tool envelopes mimic provider tool-call JSON used by fallback
|
||||
// reply extraction when the assistant otherwise says NO_REPLY.
|
||||
return JSON.stringify({
|
||||
name: "message",
|
||||
arguments: {
|
||||
action: "send",
|
||||
message,
|
||||
...args,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ReplyDirectiveParseResult } from "../auto-reply/reply/reply-directives.js";
|
||||
import type { AssistantPhase } from "../shared/chat-message-content.js";
|
||||
import "./embedded-agent-subscribe.handlers.messages.js";
|
||||
import "./embedded-agent-subscribe.handlers.messages.update.js";
|
||||
import type { EmbeddedAgentSubscribeState } from "./embedded-agent-subscribe.handlers.types.js";
|
||||
|
||||
type AssistantStreamDataParams = {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createMessageUpdateContext,
|
||||
firstMockArg,
|
||||
updateMessage,
|
||||
} from "./embedded-agent-subscribe.handlers.messages.test-helpers.js";
|
||||
import {
|
||||
createOpenAiResponsesPartial,
|
||||
createOpenAiResponsesTextBlock,
|
||||
createOpenAiResponsesTextEvent as createTextUpdateEvent,
|
||||
} from "./embedded-agent-subscribe.openai-responses.test-helpers.js";
|
||||
|
||||
describe("handleMessageUpdate commentary phase", () => {
|
||||
it("suppresses commentary-phase partial delivery and text_end flush", async () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const onPartialReply = vi.fn();
|
||||
const flushBlockReplyBuffer = vi.fn();
|
||||
const ctx = createMessageUpdateContext({
|
||||
onAgentEvent,
|
||||
onPartialReply,
|
||||
flushBlockReplyBuffer,
|
||||
});
|
||||
|
||||
updateMessage(
|
||||
ctx,
|
||||
createTextUpdateEvent({ type: "text_delta", text: "Need send.", messagePhase: "commentary" }),
|
||||
);
|
||||
updateMessage(
|
||||
ctx,
|
||||
createTextUpdateEvent({ type: "text_end", text: "Need send.", messagePhase: "commentary" }),
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(onAgentEvent).not.toHaveBeenCalled();
|
||||
expect(onPartialReply).not.toHaveBeenCalled();
|
||||
expect(flushBlockReplyBuffer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses commentary partials when phase exists only in textSignature metadata", async () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const onPartialReply = vi.fn();
|
||||
const flushBlockReplyBuffer = vi.fn();
|
||||
const commentaryBlock = createOpenAiResponsesTextBlock({
|
||||
text: "Need send.",
|
||||
id: "msg_sig",
|
||||
phase: "commentary",
|
||||
});
|
||||
const ctx = createMessageUpdateContext({
|
||||
onAgentEvent,
|
||||
onPartialReply,
|
||||
flushBlockReplyBuffer,
|
||||
});
|
||||
|
||||
updateMessage(
|
||||
ctx,
|
||||
createTextUpdateEvent({
|
||||
type: "text_delta",
|
||||
text: "Need send.",
|
||||
content: [commentaryBlock],
|
||||
}),
|
||||
);
|
||||
updateMessage(
|
||||
ctx,
|
||||
createTextUpdateEvent({
|
||||
type: "text_end",
|
||||
text: "Need send.",
|
||||
content: [commentaryBlock],
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
// Archive-always: commentary (textSignature-only phase — the F3 shape) is
|
||||
// emitted on the bus for archival + window, but kept out of the reply lanes.
|
||||
expect(onAgentEvent).toHaveBeenCalled();
|
||||
expect(onPartialReply).not.toHaveBeenCalled();
|
||||
expect(flushBlockReplyBuffer).not.toHaveBeenCalled();
|
||||
expect(ctx.state.deltaBuffer).toBe("");
|
||||
expect(ctx.state.blockBuffer).toBe("");
|
||||
});
|
||||
|
||||
it("keeps commentary partials out of reply lanes while emitting them on the bus", () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const ctx = createMessageUpdateContext({
|
||||
onAgentEvent,
|
||||
shouldEmitPartialReplies: false,
|
||||
});
|
||||
|
||||
updateMessage(
|
||||
ctx,
|
||||
createTextUpdateEvent({
|
||||
type: "text_delta",
|
||||
text: "Working...",
|
||||
partial: createOpenAiResponsesPartial({
|
||||
text: "Working...",
|
||||
id: "item_commentary",
|
||||
signaturePhase: "commentary",
|
||||
partialPhase: "commentary",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Emit-always: the bus sees the commentary delta with its phase tag. The raw
|
||||
// cumulative buffer retains it for end-event dedupe, but reply blocks stay untouched.
|
||||
expect(onAgentEvent).toHaveBeenCalledTimes(1);
|
||||
const commentaryEvent = firstMockArg(onAgentEvent, "agent event") as
|
||||
| { stream?: string; data?: { delta?: string; phase?: string } }
|
||||
| undefined;
|
||||
expect(commentaryEvent?.stream).toBe("assistant");
|
||||
expect(commentaryEvent?.data?.phase).toBe("commentary");
|
||||
expect(commentaryEvent?.data?.delta).toBe("Working...");
|
||||
expect(ctx.state.deltaBuffer).toBe("Working...");
|
||||
expect(ctx.state.blockBuffer).toBe("");
|
||||
|
||||
updateMessage(
|
||||
ctx,
|
||||
createTextUpdateEvent({
|
||||
type: "text_delta",
|
||||
text: "Done.",
|
||||
partial: createOpenAiResponsesPartial({
|
||||
text: "Done.",
|
||||
id: "item_final",
|
||||
signaturePhase: "final_answer",
|
||||
partialPhase: "final_answer",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onAgentEvent).toHaveBeenCalledTimes(2);
|
||||
const event = onAgentEvent.mock.calls[1]?.[0] as
|
||||
| { stream?: string; data?: { text?: string; delta?: string } }
|
||||
| undefined;
|
||||
expect(event?.stream).toBe("assistant");
|
||||
expect(event?.data?.text).toBe("Done.");
|
||||
expect(event?.data?.delta).toBe("Done.");
|
||||
});
|
||||
|
||||
it("contains synchronous text_end flush failures", async () => {
|
||||
const debug = vi.fn();
|
||||
const ctx = createMessageUpdateContext({
|
||||
debug,
|
||||
shouldEmitPartialReplies: false,
|
||||
flushBlockReplyBuffer: vi.fn(() => {
|
||||
throw new Error("boom");
|
||||
}),
|
||||
});
|
||||
|
||||
updateMessage(ctx, createTextUpdateEvent({ type: "text_end", text: "" }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(debug).toHaveBeenCalledWith("text_end block reply flush failed: Error: boom");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createMessageUpdateContext,
|
||||
updateMessage,
|
||||
} from "./embedded-agent-subscribe.handlers.messages.test-helpers.js";
|
||||
import { resolveCurrentSourceMessagingToolPartial } from "./embedded-agent-subscribe.handlers.messages.test-support.js";
|
||||
import { createOpenAiResponsesTextEvent as createTextUpdateEvent } from "./embedded-agent-subscribe.openai-responses.test-helpers.js";
|
||||
|
||||
describe("handleMessageUpdate current-source message-tool previews", () => {
|
||||
it("holds delta-only continuation fragments and releases one full divergent snapshot", () => {
|
||||
const state = {
|
||||
currentSourceMessagingToolHeldPartial: undefined as string | undefined,
|
||||
currentSourceMessagingToolSentTextsNormalized: ["qa-msteams-dm-ok"],
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveCurrentSourceMessagingToolPartial(state, {
|
||||
evtType: "text_delta",
|
||||
text: "QA-MSTEAMS",
|
||||
visibleDelta: "QA-MSTEAMS",
|
||||
}),
|
||||
).toEqual({ hold: true, text: "QA-MSTEAMS" });
|
||||
expect(
|
||||
resolveCurrentSourceMessagingToolPartial(state, {
|
||||
evtType: "text_delta",
|
||||
text: "-DM-OK",
|
||||
visibleDelta: "-DM-OK",
|
||||
}),
|
||||
).toEqual({ hold: true, text: "QA-MSTEAMS-DM-OK" });
|
||||
expect(
|
||||
resolveCurrentSourceMessagingToolPartial(state, {
|
||||
evtType: "text_delta",
|
||||
text: " with more detail",
|
||||
visibleDelta: " with more detail",
|
||||
}),
|
||||
).toEqual({ hold: false, text: "QA-MSTEAMS-DM-OK with more detail" });
|
||||
expect(state.currentSourceMessagingToolHeldPartial).toBeUndefined();
|
||||
});
|
||||
|
||||
it("holds automatic partial prefixes and exact duplicates after source delivery", () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const onPartialReply = vi.fn();
|
||||
const sentText = "QA-MSTEAMS-DM-OK";
|
||||
const context = createMessageUpdateContext({
|
||||
onAgentEvent,
|
||||
onPartialReply,
|
||||
sourceReplyDeliveryMode: "automatic",
|
||||
state: {
|
||||
currentSourceMessagingToolSentTextsNormalized: [sentText.toLowerCase()],
|
||||
},
|
||||
});
|
||||
|
||||
updateMessage(
|
||||
context,
|
||||
createTextUpdateEvent({
|
||||
type: "text_delta",
|
||||
text: "QA-MSTEAMS",
|
||||
id: "msg_source_duplicate",
|
||||
}),
|
||||
);
|
||||
updateMessage(
|
||||
context,
|
||||
createTextUpdateEvent({
|
||||
type: "text_end",
|
||||
text: sentText,
|
||||
id: "msg_source_duplicate",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onAgentEvent).toHaveBeenCalledTimes(1);
|
||||
expect(onPartialReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("releases the full cumulative snapshot when automatic text diverges", () => {
|
||||
const onPartialReply = vi.fn();
|
||||
const sentText = "QA-MSTEAMS-DM-OK";
|
||||
const context = createMessageUpdateContext({
|
||||
onPartialReply,
|
||||
sourceReplyDeliveryMode: "automatic",
|
||||
state: {
|
||||
currentSourceMessagingToolSentTextsNormalized: [sentText.toLowerCase()],
|
||||
},
|
||||
});
|
||||
|
||||
updateMessage(
|
||||
context,
|
||||
createTextUpdateEvent({
|
||||
type: "text_delta",
|
||||
text: "QA-MSTEAMS",
|
||||
id: "msg_source_diverges",
|
||||
}),
|
||||
);
|
||||
updateMessage(
|
||||
context,
|
||||
createTextUpdateEvent({
|
||||
type: "text_end",
|
||||
text: `${sentText} with more detail`,
|
||||
id: "msg_source_diverges",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onPartialReply).toHaveBeenCalledTimes(1);
|
||||
expect(onPartialReply).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: `${sentText} with more detail` }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps unrelated automatic partial text visible", () => {
|
||||
const onPartialReply = vi.fn();
|
||||
const context = createMessageUpdateContext({
|
||||
onPartialReply,
|
||||
sourceReplyDeliveryMode: "automatic",
|
||||
state: {
|
||||
currentSourceMessagingToolSentTextsNormalized: ["qa-msteams-dm-ok"],
|
||||
},
|
||||
});
|
||||
|
||||
updateMessage(
|
||||
context,
|
||||
createTextUpdateEvent({
|
||||
type: "text_end",
|
||||
text: "A genuinely different answer",
|
||||
id: "msg_source_different",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onPartialReply).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: "A genuinely different answer" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,399 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createStreamingDirectiveAccumulator } from "../auto-reply/reply/streaming-directives.js";
|
||||
import {
|
||||
createMessageUpdateContext,
|
||||
endMessage,
|
||||
firstMockArg,
|
||||
updateMessage,
|
||||
} from "./embedded-agent-subscribe.handlers.messages.test-helpers.js";
|
||||
import {
|
||||
createOpenAiResponsesPartial,
|
||||
createOpenAiResponsesTextEvent as createTextUpdateEvent,
|
||||
} from "./embedded-agent-subscribe.openai-responses.test-helpers.js";
|
||||
|
||||
describe("handleMessageUpdate text signatures", () => {
|
||||
it("emits the full incrementally extracted reasoning value on every delta", () => {
|
||||
const emitReasoningStream = vi.fn();
|
||||
const context = createMessageUpdateContext({ emitReasoningStream });
|
||||
|
||||
for (const chunk of ["<thi", "nk>reason", "ing</think>"]) {
|
||||
updateMessage(
|
||||
context,
|
||||
createTextUpdateEvent({ type: "text_delta", text: chunk, delta: chunk }),
|
||||
);
|
||||
}
|
||||
|
||||
expect(emitReasoningStream.mock.calls.map(([text]) => text)).toEqual([
|
||||
"",
|
||||
"reason",
|
||||
"reasoning",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses incremental text deltas for unphased OpenAI Responses streams", () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const stripBlockTags = vi.fn((text: string) => text);
|
||||
const context = createMessageUpdateContext({ onAgentEvent, stripBlockTags });
|
||||
|
||||
const createNonPhaseEvent = (text: string, delta: string) =>
|
||||
({
|
||||
message: { role: "assistant", content: [] },
|
||||
assistantMessageEvent: {
|
||||
type: "text_delta",
|
||||
contentIndex: 0,
|
||||
delta,
|
||||
partial: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
stopReason: "stop",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
usage: {},
|
||||
timestamp: 0,
|
||||
},
|
||||
},
|
||||
}) as never;
|
||||
|
||||
updateMessage(context, createNonPhaseEvent("Hello ", "Hello "));
|
||||
updateMessage(context, createNonPhaseEvent("Hello world", "world"));
|
||||
|
||||
expect(stripBlockTags.mock.calls.map(([text]) => text)).toEqual(["Hello ", "world"]);
|
||||
expect(onAgentEvent.mock.calls.map(([event]) => event)).toMatchObject([
|
||||
{
|
||||
stream: "assistant",
|
||||
data: { text: "Hello", delta: "Hello" },
|
||||
},
|
||||
{
|
||||
stream: "assistant",
|
||||
data: { text: "Hello world", delta: " world" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats unphased OpenAI Responses content-index changes as message boundaries", () => {
|
||||
const flushBlockReplyBuffer = vi.fn();
|
||||
const onAssistantMessageStart = vi.fn();
|
||||
const onPartialReply = vi.fn();
|
||||
const context = createMessageUpdateContext({
|
||||
flushBlockReplyBuffer,
|
||||
onPartialReply,
|
||||
state: {
|
||||
deltaBuffer: "First block",
|
||||
lastStreamedAssistant: "First block",
|
||||
lastStreamedAssistantCleaned: "First block",
|
||||
lastAssistantStreamContentIndex: 0,
|
||||
},
|
||||
});
|
||||
const resetAssistantMessageState = vi.fn(() => {
|
||||
context.state.deltaBuffer = "";
|
||||
context.state.lastStreamedAssistant = undefined;
|
||||
context.state.lastStreamedAssistantCleaned = undefined;
|
||||
});
|
||||
context.resetAssistantMessageState = resetAssistantMessageState;
|
||||
context.params.onAssistantMessageStart = onAssistantMessageStart;
|
||||
|
||||
updateMessage(context, {
|
||||
message: { role: "assistant", content: [] },
|
||||
assistantMessageEvent: {
|
||||
type: "text_end",
|
||||
contentIndex: 1,
|
||||
content: "First block",
|
||||
partial: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "First block" },
|
||||
{ type: "text", text: "First block" },
|
||||
],
|
||||
api: "openai-responses",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(flushBlockReplyBuffer).toHaveBeenCalledTimes(1);
|
||||
expect(resetAssistantMessageState).toHaveBeenCalledTimes(1);
|
||||
expect(onAssistantMessageStart).toHaveBeenCalledTimes(1);
|
||||
expect(onPartialReply).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: "First block", delta: "First block" }),
|
||||
);
|
||||
expect(context.state.blockBuffer).toBe("First block");
|
||||
expect(context.state.lastAssistantStreamContentIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("holds incomplete streaming directive tails without emitting them as text", () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const accumulator = createStreamingDirectiveAccumulator();
|
||||
const context = createMessageUpdateContext({
|
||||
onAgentEvent,
|
||||
consumePartialReplyDirectives: vi.fn((text: string, options?: { final?: boolean }) =>
|
||||
accumulator.consume(text, options),
|
||||
),
|
||||
});
|
||||
|
||||
const createNonPhaseEvent = (delta: string) =>
|
||||
({
|
||||
message: { role: "assistant", content: [] },
|
||||
assistantMessageEvent: {
|
||||
type: "text_delta",
|
||||
delta,
|
||||
},
|
||||
}) as never;
|
||||
|
||||
updateMessage(context, createNonPhaseEvent("Hello\n"));
|
||||
updateMessage(context, createNonPhaseEvent("M"));
|
||||
|
||||
expect(onAgentEvent).toHaveBeenCalledTimes(1);
|
||||
expect(firstMockArg(onAgentEvent, "agent event")).toMatchObject({
|
||||
stream: "assistant",
|
||||
data: { text: "Hello", delta: "Hello" },
|
||||
});
|
||||
expect(context.state.lastStreamedAssistantCleaned).toBe("Hello");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "the directive accumulator has no parsed result",
|
||||
text: "answer part A msg [[E1008]timeout] answer part B",
|
||||
hasParsedDirectives: false,
|
||||
},
|
||||
{
|
||||
name: "the directive accumulator flushes a buffered tail",
|
||||
text: "answer part A msg [[E1008]timeout] answer part B",
|
||||
hasParsedDirectives: true,
|
||||
},
|
||||
{
|
||||
name: "the final text ends with one bracket",
|
||||
text: "answer part A [",
|
||||
hasParsedDirectives: true,
|
||||
},
|
||||
])("keeps literal final text when $name", ({ text, hasParsedDirectives }) => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const context = createMessageUpdateContext({
|
||||
onAgentEvent,
|
||||
...(hasParsedDirectives ? {} : { consumePartialReplyDirectives: vi.fn(() => null) }),
|
||||
});
|
||||
|
||||
updateMessage(context, {
|
||||
message: { role: "assistant", content: [] },
|
||||
assistantMessageEvent: { type: "text_end", content: text },
|
||||
});
|
||||
|
||||
expect(context.state.lastStreamedAssistantCleaned).toBe(text);
|
||||
expect(firstMockArg(onAgentEvent, "final assistant event")).toMatchObject({
|
||||
stream: "assistant",
|
||||
data: { text },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps stripped reply directives out of later plain deltas", () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const context = createMessageUpdateContext({ onAgentEvent });
|
||||
|
||||
const createNonPhaseEvent = (delta: string) =>
|
||||
({
|
||||
message: { role: "assistant", content: [] },
|
||||
assistantMessageEvent: {
|
||||
type: "text_delta",
|
||||
delta,
|
||||
},
|
||||
}) as never;
|
||||
|
||||
updateMessage(context, createNonPhaseEvent("[[reply_to_current]]\nHello"));
|
||||
updateMessage(context, createNonPhaseEvent(" world"));
|
||||
|
||||
expect(onAgentEvent.mock.calls.map(([event]) => event)).toMatchObject([
|
||||
{
|
||||
stream: "assistant",
|
||||
data: { text: "Hello", delta: "Hello" },
|
||||
},
|
||||
{
|
||||
stream: "assistant",
|
||||
data: { text: "Hello world", delta: " world" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not expose complete legacy media directives on plain deltas", () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const context = createMessageUpdateContext({ onAgentEvent });
|
||||
|
||||
updateMessage(context, {
|
||||
message: { role: "assistant", content: [] },
|
||||
assistantMessageEvent: {
|
||||
type: "text_delta",
|
||||
delta: "Here it is.\nMEDIA:/tmp/final.png\n",
|
||||
},
|
||||
});
|
||||
|
||||
expect(firstMockArg(onAgentEvent, "agent event")).toMatchObject({
|
||||
stream: "assistant",
|
||||
data: { text: "Here it is.", delta: "Here it is." },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses full partial text for suffix deltas after a suppressed commentary item", () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const context = createMessageUpdateContext({ onAgentEvent });
|
||||
|
||||
updateMessage(
|
||||
context,
|
||||
createTextUpdateEvent({
|
||||
type: "text_delta",
|
||||
text: "Hello",
|
||||
delta: "Hello",
|
||||
id: "item-commentary",
|
||||
signaturePhase: "commentary",
|
||||
partialPhase: "commentary",
|
||||
}),
|
||||
);
|
||||
updateMessage(
|
||||
context,
|
||||
createTextUpdateEvent({
|
||||
type: "text_delta",
|
||||
text: "Hello world",
|
||||
delta: " world",
|
||||
id: "item-final",
|
||||
signaturePhase: "final_answer",
|
||||
partialPhase: "final_answer",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onAgentEvent.mock.calls.map(([event]) => event)).toMatchObject([
|
||||
// Emit-always: the commentary delta reaches the bus tagged with its
|
||||
// phase; reply lanes still exclude it (covered below).
|
||||
{
|
||||
stream: "assistant",
|
||||
data: { delta: "Hello", phase: "commentary", itemId: "item-commentary" },
|
||||
},
|
||||
{
|
||||
stream: "assistant",
|
||||
data: { text: "Hello world", delta: "Hello world", phase: "final_answer" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"openai-responses",
|
||||
"openai-chatgpt-responses",
|
||||
"openclaw-openai-responses-transport",
|
||||
"openclaw-openai-chatgpt-responses-transport",
|
||||
"openclaw-azure-openai-responses-transport",
|
||||
])("streams %s commentary bytes exactly once across start, deltas, and end", async (api) => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const context = createMessageUpdateContext({ onAgentEvent });
|
||||
const createPartial = (text: string) => ({
|
||||
...createOpenAiResponsesPartial({
|
||||
text,
|
||||
id: "item-commentary",
|
||||
signaturePhase: "commentary",
|
||||
partialPhase: "commentary",
|
||||
}),
|
||||
api,
|
||||
});
|
||||
const startPartial = createPartial("Work");
|
||||
const finalPartial = createPartial("Working...");
|
||||
|
||||
updateMessage(context, {
|
||||
message: startPartial,
|
||||
assistantMessageEvent: {
|
||||
type: "text_start",
|
||||
contentIndex: 0,
|
||||
partial: startPartial,
|
||||
},
|
||||
});
|
||||
updateMessage(context, {
|
||||
message: startPartial,
|
||||
assistantMessageEvent: {
|
||||
type: "text_delta",
|
||||
contentIndex: 0,
|
||||
delta: "Work",
|
||||
partial: startPartial,
|
||||
},
|
||||
});
|
||||
updateMessage(context, {
|
||||
message: finalPartial,
|
||||
assistantMessageEvent: {
|
||||
type: "text_delta",
|
||||
contentIndex: 0,
|
||||
delta: "ing...",
|
||||
partial: finalPartial,
|
||||
},
|
||||
});
|
||||
updateMessage(context, {
|
||||
message: finalPartial,
|
||||
assistantMessageEvent: {
|
||||
type: "text_end",
|
||||
contentIndex: 0,
|
||||
content: "Working...",
|
||||
partial: finalPartial,
|
||||
},
|
||||
});
|
||||
await endMessage(context, {
|
||||
message: finalPartial,
|
||||
});
|
||||
|
||||
expect(onAgentEvent.mock.calls.map(([event]) => event)).toMatchObject([
|
||||
{
|
||||
stream: "assistant",
|
||||
data: { delta: "Work", phase: "commentary", itemId: "item-commentary" },
|
||||
},
|
||||
{
|
||||
stream: "assistant",
|
||||
data: { delta: "ing...", phase: "commentary", itemId: "item-commentary" },
|
||||
},
|
||||
]);
|
||||
expect(context.state.deltaBuffer).toBe("Working...");
|
||||
expect(context.state.blockBuffer).toBe("");
|
||||
});
|
||||
|
||||
it("keeps same-index commentary snapshot extensions on the original live item key", async () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const context = createMessageUpdateContext({ onAgentEvent });
|
||||
const createPartial = (text: string, id: string) =>
|
||||
createOpenAiResponsesPartial({
|
||||
text,
|
||||
id,
|
||||
signaturePhase: "commentary",
|
||||
partialPhase: "commentary",
|
||||
});
|
||||
const firstPartial = createPartial("Working", "item-1");
|
||||
const extendedPartial = createPartial("Working now", "item-2");
|
||||
|
||||
updateMessage(context, {
|
||||
message: firstPartial,
|
||||
assistantMessageEvent: { type: "text_start", contentIndex: 0, partial: firstPartial },
|
||||
});
|
||||
updateMessage(context, {
|
||||
message: firstPartial,
|
||||
assistantMessageEvent: {
|
||||
type: "text_end",
|
||||
contentIndex: 0,
|
||||
content: "Working",
|
||||
partial: firstPartial,
|
||||
},
|
||||
});
|
||||
updateMessage(context, {
|
||||
message: extendedPartial,
|
||||
assistantMessageEvent: {
|
||||
type: "text_end",
|
||||
contentIndex: 0,
|
||||
content: "Working now",
|
||||
partial: extendedPartial,
|
||||
},
|
||||
});
|
||||
await endMessage(context, { message: extendedPartial });
|
||||
|
||||
expect(onAgentEvent.mock.calls.map(([event]) => event)).toMatchObject([
|
||||
{
|
||||
stream: "assistant",
|
||||
data: { delta: "Working", phase: "commentary", itemId: "item-1" },
|
||||
},
|
||||
{
|
||||
stream: "assistant",
|
||||
data: { delta: " now", phase: "commentary", itemId: "item-1" },
|
||||
},
|
||||
]);
|
||||
expect(context.state.lastAssistantStreamItemId).toBe("item-1");
|
||||
expect(context.state.deltaBuffer).toBe("Working now");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,462 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createStreamingDirectiveAccumulator } from "../auto-reply/reply/streaming-directives.js";
|
||||
import { consumePendingAssistantReplyDirectivesIntoReply } from "./embedded-agent-subscribe.handlers.messages.replies.js";
|
||||
import {
|
||||
createMessageUpdateContext,
|
||||
updateMessage,
|
||||
} from "./embedded-agent-subscribe.handlers.messages.test-helpers.js";
|
||||
import {
|
||||
createOpenAiResponsesPartial,
|
||||
createOpenAiResponsesTextBlock,
|
||||
createOpenAiResponsesTextEvent as createTextUpdateEvent,
|
||||
} from "./embedded-agent-subscribe.openai-responses.test-helpers.js";
|
||||
|
||||
describe("handleMessageUpdate text signatures", () => {
|
||||
it("emits a commentary snapshot when Anthropic text is classified after deltas", () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const context = createMessageUpdateContext({ onAgentEvent });
|
||||
const narration = "I'll check the repo first.";
|
||||
const commentaryPartial = {
|
||||
role: "assistant",
|
||||
api: "anthropic-messages",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: narration,
|
||||
textSignature: JSON.stringify({ v: 1, id: "commentary-0", phase: "commentary" }),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
updateMessage(context, {
|
||||
message: {
|
||||
role: "assistant",
|
||||
api: "anthropic-messages",
|
||||
content: [{ type: "text", text: narration }],
|
||||
},
|
||||
assistantMessageEvent: { type: "text_delta", delta: narration },
|
||||
});
|
||||
updateMessage(context, {
|
||||
message: { role: "assistant", api: "anthropic-messages", content: [] },
|
||||
assistantMessageEvent: {
|
||||
type: "text_end",
|
||||
content: narration,
|
||||
partial: commentaryPartial,
|
||||
},
|
||||
});
|
||||
|
||||
expect(onAgentEvent.mock.calls.map(([event]) => event)).toContainEqual(
|
||||
expect.objectContaining({
|
||||
stream: "assistant",
|
||||
data: expect.objectContaining({
|
||||
text: narration,
|
||||
replace: true,
|
||||
phase: "commentary",
|
||||
itemId: "commentary-0",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses incremental deltas for same-item phased streams", () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const context = createMessageUpdateContext({ onAgentEvent });
|
||||
const signature = JSON.stringify({ v: 1, id: "item-final", phase: "final_answer" });
|
||||
const partial = {
|
||||
role: "assistant",
|
||||
phase: "final_answer",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
textSignature: signature,
|
||||
get text() {
|
||||
throw new Error("full partial text should not be read");
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const createPhasedDelta = (delta: string) =>
|
||||
({
|
||||
message: { role: "assistant", content: [] },
|
||||
assistantMessageEvent: {
|
||||
type: "text_delta",
|
||||
delta,
|
||||
partial,
|
||||
},
|
||||
}) as never;
|
||||
|
||||
updateMessage(context, createPhasedDelta("Hello"));
|
||||
updateMessage(context, createPhasedDelta(" world"));
|
||||
|
||||
expect(onAgentEvent.mock.calls.map(([event]) => event)).toMatchObject([
|
||||
{
|
||||
stream: "assistant",
|
||||
data: { text: "Hello", delta: "Hello", phase: "final_answer" },
|
||||
},
|
||||
{
|
||||
stream: "assistant",
|
||||
data: { text: "Hello world", delta: " world", phase: "final_answer" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps same-item phased stream deltas on the user-visible sanitizer path", () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const context = createMessageUpdateContext({ onAgentEvent });
|
||||
const signature = JSON.stringify({ v: 1, id: "item-final", phase: "final_answer" });
|
||||
const partial = {
|
||||
role: "assistant",
|
||||
phase: "final_answer",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
textSignature: signature,
|
||||
get text() {
|
||||
throw new Error("full partial text should not be read");
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const createPhasedDelta = (delta: string) =>
|
||||
({
|
||||
message: { role: "assistant", content: [] },
|
||||
assistantMessageEvent: {
|
||||
type: "text_delta",
|
||||
delta,
|
||||
partial,
|
||||
},
|
||||
}) as never;
|
||||
|
||||
updateMessage(context, createPhasedDelta("Visible\n<tool_call>{"));
|
||||
updateMessage(
|
||||
context,
|
||||
createPhasedDelta('"name":"read","arguments":{"file_path":"secret.md"}}</tool_call>'),
|
||||
);
|
||||
updateMessage(context, createPhasedDelta("\nDone."));
|
||||
|
||||
expect(onAgentEvent.mock.calls.map(([event]) => event)).toMatchObject([
|
||||
{
|
||||
stream: "assistant",
|
||||
data: { text: "Visible", delta: "Visible", phase: "final_answer" },
|
||||
},
|
||||
{
|
||||
stream: "assistant",
|
||||
data: { text: "Visible\n\nDone.", delta: "\n\nDone.", phase: "final_answer" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps sanitizer context when a same-item phased stream starts hidden", () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const context = createMessageUpdateContext({ onAgentEvent });
|
||||
const signature = JSON.stringify({ v: 1, id: "item-final", phase: "final_answer" });
|
||||
const partial = {
|
||||
role: "assistant",
|
||||
phase: "final_answer",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
textSignature: signature,
|
||||
get text() {
|
||||
throw new Error("full partial text should not be read");
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const createPhasedDelta = (delta: string) =>
|
||||
({
|
||||
message: { role: "assistant", content: [] },
|
||||
assistantMessageEvent: {
|
||||
type: "text_delta",
|
||||
delta,
|
||||
partial,
|
||||
},
|
||||
}) as never;
|
||||
|
||||
updateMessage(context, createPhasedDelta("<tool_call>{"));
|
||||
updateMessage(
|
||||
context,
|
||||
createPhasedDelta('"name":"read","arguments":{"file_path":"secret.md"}}</tool_call>\nDone.'),
|
||||
);
|
||||
|
||||
expect(onAgentEvent.mock.calls.map(([event]) => event)).toMatchObject([
|
||||
{
|
||||
stream: "assistant",
|
||||
data: { text: "Done.", delta: "Done.", phase: "final_answer" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats phased textSignature item changes as assistant-message boundaries", () => {
|
||||
const flushBlockReplyBuffer = vi.fn();
|
||||
const resetAssistantMessageState = vi.fn();
|
||||
const onAssistantMessageStart = vi.fn();
|
||||
const onPartialReply = vi.fn();
|
||||
const context = createMessageUpdateContext({
|
||||
flushBlockReplyBuffer,
|
||||
resetAssistantMessageState,
|
||||
onPartialReply,
|
||||
});
|
||||
context.params.onAssistantMessageStart = onAssistantMessageStart;
|
||||
context.state.lastAssistantStreamContentIndex = 0;
|
||||
context.state.lastAssistantStreamItemId = "item-1";
|
||||
context.state.assistantMessageIndex = 7;
|
||||
|
||||
updateMessage(context, {
|
||||
message: { role: "assistant", content: [] },
|
||||
assistantMessageEvent: {
|
||||
type: "text_delta",
|
||||
contentIndex: 1,
|
||||
delta: "Second block",
|
||||
partial: {
|
||||
role: "assistant",
|
||||
phase: "final_answer",
|
||||
content: [
|
||||
createOpenAiResponsesTextBlock({
|
||||
text: "First block",
|
||||
id: "item-1",
|
||||
phase: "final_answer",
|
||||
}),
|
||||
createOpenAiResponsesTextBlock({
|
||||
text: "Second block",
|
||||
id: "item-2",
|
||||
phase: "final_answer",
|
||||
}),
|
||||
],
|
||||
stopReason: "stop",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
usage: {},
|
||||
timestamp: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(flushBlockReplyBuffer).toHaveBeenCalledWith({ assistantMessageIndex: 7 });
|
||||
expect(resetAssistantMessageState).toHaveBeenCalledWith(0);
|
||||
expect(onAssistantMessageStart).toHaveBeenCalledTimes(1);
|
||||
expect(onPartialReply).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: "Second block",
|
||||
delta: "Second block",
|
||||
phase: "final_answer",
|
||||
}),
|
||||
);
|
||||
expect(onPartialReply).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: "First block\nSecond block" }),
|
||||
);
|
||||
expect(context.state.lastAssistantStreamContentIndex).toBe(1);
|
||||
expect(context.state.lastAssistantStreamItemId).toBe("item-2");
|
||||
});
|
||||
|
||||
it("does not replay a deferred item snapshot before its first delta", () => {
|
||||
const flushBlockReplyBuffer = vi.fn();
|
||||
const resetAssistantMessageState = vi.fn();
|
||||
const onAssistantMessageStart = vi.fn();
|
||||
const onPartialReply = vi.fn();
|
||||
const context = createMessageUpdateContext({
|
||||
flushBlockReplyBuffer,
|
||||
resetAssistantMessageState,
|
||||
onPartialReply,
|
||||
state: {
|
||||
lastAssistantStreamContentIndex: 0,
|
||||
lastAssistantStreamItemId: "item-1",
|
||||
},
|
||||
});
|
||||
context.params.onAssistantMessageStart = onAssistantMessageStart;
|
||||
const partial = {
|
||||
role: "assistant",
|
||||
phase: "final_answer",
|
||||
content: [
|
||||
createOpenAiResponsesTextBlock({
|
||||
text: "First block",
|
||||
id: "item-1",
|
||||
phase: "final_answer",
|
||||
}),
|
||||
createOpenAiResponsesTextBlock({
|
||||
text: "Second block",
|
||||
id: "item-2",
|
||||
phase: "final_answer",
|
||||
}),
|
||||
],
|
||||
api: "openai-responses",
|
||||
};
|
||||
|
||||
updateMessage(context, {
|
||||
message: partial,
|
||||
assistantMessageEvent: {
|
||||
type: "text_start",
|
||||
contentIndex: 1,
|
||||
partial,
|
||||
},
|
||||
});
|
||||
updateMessage(context, {
|
||||
message: partial,
|
||||
assistantMessageEvent: {
|
||||
type: "text_delta",
|
||||
contentIndex: 1,
|
||||
delta: "Second block",
|
||||
},
|
||||
});
|
||||
|
||||
expect(flushBlockReplyBuffer).toHaveBeenCalledTimes(1);
|
||||
expect(resetAssistantMessageState).toHaveBeenCalledTimes(1);
|
||||
expect(onAssistantMessageStart).toHaveBeenCalledTimes(1);
|
||||
expect(onPartialReply).toHaveBeenCalledTimes(1);
|
||||
expect(onPartialReply).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: "Second block",
|
||||
delta: "Second block",
|
||||
phase: "final_answer",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps same-block OpenAI Responses snapshot extensions in one assistant message", () => {
|
||||
const flushBlockReplyBuffer = vi.fn();
|
||||
const resetAssistantMessageState = vi.fn();
|
||||
const onAssistantMessageStart = vi.fn();
|
||||
const onPartialReply = vi.fn();
|
||||
const context = createMessageUpdateContext({
|
||||
flushBlockReplyBuffer,
|
||||
resetAssistantMessageState,
|
||||
onPartialReply,
|
||||
state: {
|
||||
deltaBuffer: "First block",
|
||||
lastStreamedAssistant: "First block",
|
||||
lastStreamedAssistantCleaned: "First block",
|
||||
lastAssistantStreamContentIndex: 0,
|
||||
lastAssistantStreamItemId: "item-1",
|
||||
},
|
||||
});
|
||||
context.params.onAssistantMessageStart = onAssistantMessageStart;
|
||||
|
||||
updateMessage(context, {
|
||||
message: { role: "assistant", content: [] },
|
||||
assistantMessageEvent: {
|
||||
type: "text_end",
|
||||
contentIndex: 0,
|
||||
content: "First block extended",
|
||||
partial: createOpenAiResponsesPartial({
|
||||
text: "First block extended",
|
||||
id: "item-2",
|
||||
signaturePhase: "final_answer",
|
||||
partialPhase: "final_answer",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(flushBlockReplyBuffer).not.toHaveBeenCalled();
|
||||
expect(resetAssistantMessageState).not.toHaveBeenCalled();
|
||||
expect(onAssistantMessageStart).not.toHaveBeenCalled();
|
||||
expect(onPartialReply).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: "First block extended",
|
||||
delta: " extended",
|
||||
phase: "final_answer",
|
||||
}),
|
||||
);
|
||||
expect(context.state.lastAssistantStreamContentIndex).toBe(0);
|
||||
expect(context.state.lastAssistantStreamItemId).toBe("item-1");
|
||||
});
|
||||
|
||||
it("scopes item-id fallback boundaries to the matching signed block", () => {
|
||||
const onPartialReply = vi.fn();
|
||||
const resetAssistantMessageState = vi.fn();
|
||||
const context = createMessageUpdateContext({
|
||||
onPartialReply,
|
||||
resetAssistantMessageState,
|
||||
state: { lastAssistantStreamItemId: "item-1" },
|
||||
});
|
||||
|
||||
updateMessage(context, {
|
||||
message: { role: "assistant", content: [] },
|
||||
assistantMessageEvent: {
|
||||
type: "text_delta",
|
||||
delta: "Second block",
|
||||
partial: {
|
||||
role: "assistant",
|
||||
phase: "final_answer",
|
||||
content: [
|
||||
createOpenAiResponsesTextBlock({
|
||||
text: "First block",
|
||||
id: "item-1",
|
||||
phase: "final_answer",
|
||||
}),
|
||||
createOpenAiResponsesTextBlock({
|
||||
text: "Second block",
|
||||
id: "item-2",
|
||||
phase: "final_answer",
|
||||
}),
|
||||
],
|
||||
api: "openai-responses",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resetAssistantMessageState).toHaveBeenCalledTimes(1);
|
||||
expect(onPartialReply).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: "Second block",
|
||||
delta: "Second block",
|
||||
phase: "final_answer",
|
||||
}),
|
||||
);
|
||||
expect(onPartialReply).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: "First block\nSecond block" }),
|
||||
);
|
||||
expect(context.state.lastAssistantStreamContentIndex).toBeUndefined();
|
||||
expect(context.state.lastAssistantStreamItemId).toBe("item-2");
|
||||
});
|
||||
|
||||
it("preserves phase-aware voice and reply directives while deferring final media delivery", () => {
|
||||
const accumulator = createStreamingDirectiveAccumulator();
|
||||
const ctx = createMessageUpdateContext({
|
||||
consumePartialReplyDirectives: vi.fn((text: string, options?: { final?: boolean }) =>
|
||||
accumulator.consume(text, options),
|
||||
),
|
||||
state: {
|
||||
blockReplyBreak: "message_end",
|
||||
},
|
||||
});
|
||||
const replyText = "Done.\n\n[[reply_to_current]]\n[[audio_as_voice]]\nMEDIA:/tmp/reply.ogg";
|
||||
|
||||
updateMessage(
|
||||
ctx,
|
||||
createTextUpdateEvent({
|
||||
type: "text_delta",
|
||||
text: replyText,
|
||||
id: "item-final",
|
||||
signaturePhase: "final_answer",
|
||||
partialPhase: "final_answer",
|
||||
}),
|
||||
);
|
||||
updateMessage(
|
||||
ctx,
|
||||
createTextUpdateEvent({
|
||||
type: "text_end",
|
||||
text: replyText,
|
||||
id: "item-final",
|
||||
signaturePhase: "final_answer",
|
||||
partialPhase: "final_answer",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(ctx.state.blockBuffer).toBe("Done.");
|
||||
expect(
|
||||
consumePendingAssistantReplyDirectivesIntoReply(ctx.state, {
|
||||
text: "Done.",
|
||||
}),
|
||||
).toEqual({
|
||||
text: "Done.",
|
||||
audioAsVoice: true,
|
||||
replyToId: undefined,
|
||||
replyToTag: true,
|
||||
replyToCurrent: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,510 @@
|
||||
/**
|
||||
* Handles assistant message deltas, reasoning, directives, and block replies.
|
||||
*/
|
||||
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
|
||||
import { createInlineCodeState } from "../../packages/markdown-core/src/code-spans.js";
|
||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||
import type { AssistantMessage } from "../llm/types.js";
|
||||
import { coerceChatContentText } from "../shared/chat-content.js";
|
||||
import { resolveAssistantMessagePhase } from "../shared/chat-message-content.js";
|
||||
import { updateLiveEditDiffProgress } from "./embedded-agent-live-edit-diff.js";
|
||||
import { runBestEffortCallback } from "./embedded-agent-subscribe.callback.js";
|
||||
import { capturePendingAssistantUsage } from "./embedded-agent-subscribe.handlers.messages.lifecycle.js";
|
||||
import {
|
||||
hasAssistantVisibleReply,
|
||||
mergeReplyDirectiveResults,
|
||||
recordPendingAssistantReplyDirectives,
|
||||
} from "./embedded-agent-subscribe.handlers.messages.replies.js";
|
||||
import {
|
||||
appendBlockReplyChunk,
|
||||
buildAssistantStreamData,
|
||||
copyPartialBlockState,
|
||||
emitAssistantMessageStart,
|
||||
emitReasoningEnd,
|
||||
hasMessageToolOnlySourceDelivery,
|
||||
isAnthropicAssistantMessage,
|
||||
isOpenAiCompletionsAssistantMessage,
|
||||
isResponsesApiAssistantMessage,
|
||||
isSubscribeTranscriptOnlyOpenClawAssistantMessage,
|
||||
openReasoningStream,
|
||||
replaceBlockReplyBuffer,
|
||||
resolveAssistantStreamContentIndex,
|
||||
resolveAssistantStreamItemId,
|
||||
resolveAssistantTextChunk,
|
||||
resolveCurrentSourceMessagingToolPartial,
|
||||
resolveStreamVisibleText,
|
||||
resolveStreamingReplyText,
|
||||
resolveTextAppendDelta,
|
||||
scopeAssistantMessageToStreamBlock,
|
||||
shouldSuppressAssistantVisibleOutput,
|
||||
shouldSuppressDeterministicApprovalOutput,
|
||||
} from "./embedded-agent-subscribe.handlers.messages.stream.js";
|
||||
import type {
|
||||
EmbeddedAgentSubscribeContext,
|
||||
EmbeddedAgentSubscribeState,
|
||||
} from "./embedded-agent-subscribe.handlers.types.js";
|
||||
import { appendRawStream } from "./embedded-agent-subscribe.raw-stream.js";
|
||||
import {
|
||||
extractAssistantCommentaryText,
|
||||
extractAssistantThinking,
|
||||
extractAssistantVisibleText,
|
||||
extractThinkingFromTaggedStream,
|
||||
sanitizeAssistantVisibleStreamText,
|
||||
} from "./embedded-agent-utils.js";
|
||||
import type { AgentEvent, AgentMessage } from "./runtime/index.js";
|
||||
|
||||
const REASONING_TAG_RE = /<\s*\/?\s*(?:(?:antml:|mm:)?(?:think(?:ing)?|thought)|antthinking)\b/i;
|
||||
|
||||
export function handleMessageUpdate(
|
||||
ctx: EmbeddedAgentSubscribeContext,
|
||||
evt: AgentEvent & { message: AgentMessage; assistantMessageEvent?: unknown },
|
||||
) {
|
||||
const msg = evt.message;
|
||||
if (msg?.role !== "assistant" || isSubscribeTranscriptOnlyOpenClawAssistantMessage(msg)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.noteLastAssistant(msg);
|
||||
const assistantEvent = evt.assistantMessageEvent;
|
||||
const assistantRecord =
|
||||
assistantEvent && typeof assistantEvent === "object"
|
||||
? (assistantEvent as Record<string, unknown>)
|
||||
: undefined;
|
||||
const evtType = typeof assistantRecord?.type === "string" ? assistantRecord.type : "";
|
||||
const liveEditDiff = updateLiveEditDiffProgress(ctx.state.liveEditDiffStateById, assistantRecord);
|
||||
if (liveEditDiff) {
|
||||
const data = { phase: "input_delta", ...liveEditDiff };
|
||||
emitAgentEvent({ runId: ctx.params.runId, stream: "tool", data });
|
||||
runBestEffortCallback({
|
||||
label: "live edit diff agent event",
|
||||
log: ctx.log,
|
||||
callback: () => ctx.params.onAgentEvent?.({ stream: "tool", data }),
|
||||
});
|
||||
}
|
||||
const eventAssistantMessage =
|
||||
assistantRecord?.partial && typeof assistantRecord.partial === "object"
|
||||
? (assistantRecord.partial as AssistantMessage)
|
||||
: msg;
|
||||
const isResponsesTextEvent =
|
||||
isResponsesApiAssistantMessage(eventAssistantMessage) &&
|
||||
(evtType === "text_start" || evtType === "text_delta" || evtType === "text_end");
|
||||
const suppressVisibleAssistantOutput = shouldSuppressAssistantVisibleOutput(msg);
|
||||
if (suppressVisibleAssistantOutput && !isResponsesTextEvent) {
|
||||
const commentaryText = coerceChatContentText(extractAssistantCommentaryText(msg));
|
||||
if (commentaryText) {
|
||||
appendRawStream({
|
||||
ts: Date.now(),
|
||||
event: "assistant_text_stream",
|
||||
runId: ctx.params.runId,
|
||||
sessionId: (ctx.params.session as { id?: string }).id,
|
||||
evtType: "commentary_update",
|
||||
delta: "",
|
||||
content: commentaryText,
|
||||
});
|
||||
ctx.emitAssistantStreamData(
|
||||
buildAssistantStreamData({ text: commentaryText, replace: true, phase: "commentary" }),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const suppressDeterministicApprovalOutput = shouldSuppressDeterministicApprovalOutput(ctx.state);
|
||||
const suppressMessageToolOnlySourceReplyOutput = hasMessageToolOnlySourceDelivery(ctx);
|
||||
|
||||
const assistantPhase = resolveAssistantMessagePhase(msg);
|
||||
|
||||
if (evtType === "text_end" || evtType === "done" || evtType === "error") {
|
||||
capturePendingAssistantUsage(ctx, evt);
|
||||
if (evtType === "done" || evtType === "error") {
|
||||
ctx.commitAssistantUsage();
|
||||
}
|
||||
}
|
||||
|
||||
if (evtType === "thinking_start" || evtType === "thinking_delta" || evtType === "thinking_end") {
|
||||
if (
|
||||
!suppressMessageToolOnlySourceReplyOutput &&
|
||||
(evtType === "thinking_start" || evtType === "thinking_delta")
|
||||
) {
|
||||
openReasoningStream(ctx);
|
||||
}
|
||||
const thinkingDelta = typeof assistantRecord?.delta === "string" ? assistantRecord.delta : "";
|
||||
const thinkingContent =
|
||||
typeof assistantRecord?.content === "string" ? assistantRecord.content : "";
|
||||
appendRawStream({
|
||||
ts: Date.now(),
|
||||
event: "assistant_thinking_stream",
|
||||
runId: ctx.params.runId,
|
||||
sessionId: (ctx.params.session as { id?: string }).id,
|
||||
evtType,
|
||||
delta: thinkingDelta,
|
||||
content: thinkingContent,
|
||||
});
|
||||
// Emit-always: emitReasoningStream always reaches the bus/archive; the
|
||||
// streamReasoning rendering hook and message_tool_only source suppression
|
||||
// are gated downstream (dispatch wrapProgressCallback, #92738), so emission
|
||||
// here stays unconditional.
|
||||
// Prefer full partial-message thinking when available; fall back to event payloads.
|
||||
const partialThinking = extractAssistantThinking(msg);
|
||||
ctx.emitReasoningStream(partialThinking || thinkingContent || thinkingDelta);
|
||||
if (evtType === "thinking_end" && !suppressMessageToolOnlySourceReplyOutput) {
|
||||
// Mirror the open gate above: when message-tool-only delivery has made the
|
||||
// reasoning lane private, do not force-open it just to close it — that
|
||||
// would fire the lane's end hook (onReasoningEnd) for a lane that never
|
||||
// rendered, leaking the boundary signal.
|
||||
if (!ctx.state.reasoningStreamOpen) {
|
||||
openReasoningStream(ctx);
|
||||
}
|
||||
emitReasoningEnd(ctx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (evtType !== "text_delta" && evtType !== "text_start" && evtType !== "text_end") {
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = typeof assistantRecord?.delta === "string" ? assistantRecord.delta : "";
|
||||
const content = typeof assistantRecord?.content === "string" ? assistantRecord.content : "";
|
||||
|
||||
appendRawStream({
|
||||
ts: Date.now(),
|
||||
event: "assistant_text_stream",
|
||||
runId: ctx.params.runId,
|
||||
sessionId: (ctx.params.session as { id?: string }).id,
|
||||
evtType,
|
||||
delta,
|
||||
content,
|
||||
});
|
||||
|
||||
const chunk = resolveAssistantTextChunk({
|
||||
evtType,
|
||||
delta,
|
||||
content,
|
||||
accumulatedText: ctx.state.deltaBuffer,
|
||||
});
|
||||
|
||||
const partialAssistant = eventAssistantMessage;
|
||||
const streamContentIndex = resolveAssistantStreamContentIndex(assistantRecord?.contentIndex);
|
||||
const streamItemId = resolveAssistantStreamItemId({
|
||||
contentIndex: streamContentIndex,
|
||||
message: partialAssistant,
|
||||
});
|
||||
const streamAssistant = scopeAssistantMessageToStreamBlock(
|
||||
partialAssistant,
|
||||
streamContentIndex,
|
||||
streamItemId,
|
||||
);
|
||||
const deliveryPhase = resolveAssistantMessagePhase(streamAssistant);
|
||||
const isPhasePendingResponsesTextItem =
|
||||
evtType !== "text_end" &&
|
||||
!deliveryPhase &&
|
||||
Boolean(streamItemId) &&
|
||||
isResponsesApiAssistantMessage(partialAssistant);
|
||||
// These transports resolve commentary only at the tool boundary. Withhold
|
||||
// early unphased deltas from durable block replies until that decision exists.
|
||||
const isPhasePendingAnthropicText =
|
||||
evtType !== "text_end" && !deliveryPhase && isAnthropicAssistantMessage(partialAssistant);
|
||||
const isPhasePendingCompletionsText =
|
||||
!deliveryPhase && isOpenAiCompletionsAssistantMessage(partialAssistant);
|
||||
const hasResponsesContentIndex =
|
||||
streamContentIndex !== undefined && isResponsesApiAssistantMessage(partialAssistant);
|
||||
let streamItemChanged = false;
|
||||
let deliveryItemId = streamItemId;
|
||||
if (
|
||||
(deliveryPhase || isPhasePendingResponsesTextItem || hasResponsesContentIndex) &&
|
||||
(streamContentIndex !== undefined || streamItemId)
|
||||
) {
|
||||
const previousStreamContentIndex = ctx.state.lastAssistantStreamContentIndex;
|
||||
const previousStreamItemId = ctx.state.lastAssistantStreamItemId;
|
||||
const contentIndexChanged =
|
||||
previousStreamContentIndex !== undefined &&
|
||||
streamContentIndex !== undefined &&
|
||||
previousStreamContentIndex !== streamContentIndex;
|
||||
const itemIdChangedWithoutIndexes =
|
||||
(previousStreamContentIndex === undefined || streamContentIndex === undefined) &&
|
||||
Boolean(previousStreamItemId && streamItemId && previousStreamItemId !== streamItemId);
|
||||
if (contentIndexChanged || itemIdChangedWithoutIndexes) {
|
||||
streamItemChanged = true;
|
||||
void ctx.flushBlockReplyBuffer({ assistantMessageIndex: ctx.state.assistantMessageIndex });
|
||||
ctx.resetAssistantMessageState(ctx.state.assistantTexts.length);
|
||||
emitAssistantMessageStart(ctx);
|
||||
} else if (
|
||||
previousStreamContentIndex !== undefined &&
|
||||
streamContentIndex === previousStreamContentIndex &&
|
||||
previousStreamItemId
|
||||
) {
|
||||
// Snapshot-extension items can rotate provider ids while retaining one logical block.
|
||||
// Keep the original live key so downstream commentary accumulators do not split it.
|
||||
deliveryItemId = previousStreamItemId;
|
||||
}
|
||||
ctx.state.lastAssistantStreamContentIndex = streamContentIndex;
|
||||
ctx.state.lastAssistantStreamItemId = deliveryItemId;
|
||||
}
|
||||
// Responses text_start snapshots may already contain text replayed by the first delta.
|
||||
// Keep starts lifecycle-only so commentary and final-answer lanes consume each byte once.
|
||||
if (evtType === "text_start" && isResponsesApiAssistantMessage(partialAssistant)) {
|
||||
return;
|
||||
}
|
||||
if (deliveryPhase === "commentary") {
|
||||
const isResponsesCommentary = isResponsesApiAssistantMessage(partialAssistant);
|
||||
const hadResponsesCommentaryText = isResponsesCommentary && Boolean(ctx.state.deltaBuffer);
|
||||
if (isResponsesCommentary && chunk) {
|
||||
// Keep cumulative end events monotonic without feeding commentary into reply buffers.
|
||||
ctx.state.deltaBuffer += chunk;
|
||||
}
|
||||
const commentaryText =
|
||||
!chunk && (!isResponsesCommentary || !hadResponsesCommentaryText)
|
||||
? coerceChatContentText(extractAssistantCommentaryText(streamAssistant))
|
||||
: undefined;
|
||||
const commentaryData = chunk
|
||||
? buildAssistantStreamData({ delta: chunk, phase: "commentary", itemId: deliveryItemId })
|
||||
: commentaryText
|
||||
? buildAssistantStreamData({
|
||||
text: commentaryText,
|
||||
replace: true,
|
||||
phase: "commentary",
|
||||
itemId: deliveryItemId,
|
||||
})
|
||||
: undefined;
|
||||
if (commentaryData) {
|
||||
ctx.emitAssistantStreamData(commentaryData);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isPhasePendingResponsesTextItem) {
|
||||
return;
|
||||
}
|
||||
// Subagents have no live consumer; their final result is delivered from
|
||||
// message_end. Keep accumulating deltaBuffer, but skip per-chunk visible-text
|
||||
// parsing so long parallel subagent streams do not monopolize the event loop.
|
||||
const skipLiveStream = ctx.params.suppressLiveStreamOutput === true;
|
||||
const shouldUsePhaseAwareBlockReply = Boolean(deliveryPhase);
|
||||
|
||||
if (chunk) {
|
||||
ctx.state.deltaBuffer += chunk;
|
||||
if (!skipLiveStream && !shouldUsePhaseAwareBlockReply) {
|
||||
if (!isPhasePendingAnthropicText && !isPhasePendingCompletionsText) {
|
||||
appendBlockReplyChunk(ctx, chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (skipLiveStream) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle partial <think> tags: stream whatever reasoning is visible so far.
|
||||
// Emit-always: emitReasoningStream reaches the bus/archive; rendering +
|
||||
// message_tool_only suppression are gated downstream (#92738).
|
||||
ctx.emitReasoningStream(
|
||||
extractThinkingFromTaggedStream(ctx.state.deltaBuffer, ctx.state.thinkingTagStream),
|
||||
);
|
||||
const wasThinking = ctx.state.partialBlockState.thinking;
|
||||
let visibleDelta = "";
|
||||
// A text_start partial may already contain text that the following text_delta replays.
|
||||
// Use starts only for lifecycle boundaries; consume their text from delta/end events.
|
||||
const shouldReadScopedPartialText =
|
||||
streamItemChanged || (shouldUsePhaseAwareBlockReply && (evtType === "text_end" || !chunk));
|
||||
let next = shouldReadScopedPartialText
|
||||
? coerceChatContentText(extractAssistantVisibleText(streamAssistant)).trim()
|
||||
: "";
|
||||
let nextRawStreamText = next;
|
||||
let shouldPersistRawStreamText = false;
|
||||
if (shouldUsePhaseAwareBlockReply && !next && deliveryPhase === "final_answer" && chunk) {
|
||||
visibleDelta = ctx.stripBlockTags(chunk, ctx.state.partialBlockState, {
|
||||
final: evtType === "text_end",
|
||||
});
|
||||
const streamVisibleText = resolveStreamVisibleText({
|
||||
previousRawText: ctx.state.lastStreamedAssistant ?? "",
|
||||
visibleDelta,
|
||||
});
|
||||
const previousVisibleText = sanitizeAssistantVisibleStreamText(
|
||||
ctx.state.lastStreamedAssistant ?? "",
|
||||
).trim();
|
||||
next = sanitizeAssistantVisibleStreamText(streamVisibleText.rawText).trim();
|
||||
visibleDelta = resolveTextAppendDelta(previousVisibleText, next);
|
||||
nextRawStreamText = streamVisibleText.rawText;
|
||||
shouldPersistRawStreamText = true;
|
||||
} else if (!next && deliveryPhase !== "final_answer") {
|
||||
const pendingTagFragment = ctx.state.partialBlockState.pendingTagFragment;
|
||||
const shouldRecomputeFullStream = Boolean(pendingTagFragment) || REASONING_TAG_RE.test(chunk);
|
||||
if (shouldRecomputeFullStream) {
|
||||
const recomputeState: EmbeddedAgentSubscribeState["partialBlockState"] = {
|
||||
thinking: false,
|
||||
final: false,
|
||||
inlineCode: createInlineCodeState(),
|
||||
};
|
||||
const recomputedRawText = ctx.stripBlockTags(ctx.state.deltaBuffer, recomputeState, {
|
||||
final: evtType === "text_end",
|
||||
});
|
||||
const previousRawText = ctx.state.lastStreamedAssistant ?? "";
|
||||
const isFullStreamReplacement = !recomputedRawText.startsWith(previousRawText);
|
||||
next = recomputedRawText.trim();
|
||||
visibleDelta = isFullStreamReplacement
|
||||
? recomputedRawText
|
||||
: recomputedRawText.slice(previousRawText.length);
|
||||
nextRawStreamText = recomputedRawText;
|
||||
copyPartialBlockState(ctx.state.partialBlockState, recomputeState);
|
||||
} else {
|
||||
visibleDelta =
|
||||
chunk || evtType === "text_end"
|
||||
? ctx.stripBlockTags(chunk, ctx.state.partialBlockState, {
|
||||
final: evtType === "text_end",
|
||||
})
|
||||
: "";
|
||||
if (ctx.state.partialBlockState.pendingTagFragment) {
|
||||
visibleDelta = "";
|
||||
next = ctx.state.lastStreamedAssistantCleaned ?? "";
|
||||
nextRawStreamText = ctx.state.lastStreamedAssistant ?? "";
|
||||
} else {
|
||||
const streamVisibleText = resolveStreamVisibleText({
|
||||
previousRawText: ctx.state.lastStreamedAssistant ?? "",
|
||||
visibleDelta,
|
||||
});
|
||||
next = streamVisibleText.visibleText;
|
||||
nextRawStreamText = streamVisibleText.rawText;
|
||||
}
|
||||
}
|
||||
} else if (next && (chunk || evtType === "text_end")) {
|
||||
visibleDelta = ctx.stripBlockTags(chunk, ctx.state.partialBlockState, {
|
||||
final: evtType === "text_end",
|
||||
});
|
||||
}
|
||||
if (next) {
|
||||
if (
|
||||
!suppressMessageToolOnlySourceReplyOutput &&
|
||||
!wasThinking &&
|
||||
ctx.state.partialBlockState.thinking
|
||||
) {
|
||||
openReasoningStream(ctx);
|
||||
}
|
||||
// Detect when thinking block ends (</think> tag processed)
|
||||
if (
|
||||
!suppressMessageToolOnlySourceReplyOutput &&
|
||||
wasThinking &&
|
||||
!ctx.state.partialBlockState.thinking
|
||||
) {
|
||||
emitReasoningEnd(ctx);
|
||||
}
|
||||
const parsedDelta = visibleDelta ? ctx.consumePartialReplyDirectives(visibleDelta) : null;
|
||||
const finalParsedDelta =
|
||||
evtType === "text_end" ? ctx.consumePartialReplyDirectives("", { final: true }) : null;
|
||||
const parsedStreamDirectives = mergeReplyDirectiveResults(parsedDelta, finalParsedDelta);
|
||||
if (shouldUsePhaseAwareBlockReply) {
|
||||
recordPendingAssistantReplyDirectives(ctx.state, parsedStreamDirectives);
|
||||
}
|
||||
const previousCleaned = ctx.state.lastStreamedAssistantCleaned ?? "";
|
||||
const cleanedText = resolveStreamingReplyText({
|
||||
evtType,
|
||||
next,
|
||||
previousRawText: ctx.state.lastStreamedAssistant ?? "",
|
||||
previousCleaned,
|
||||
visibleDelta,
|
||||
parsedStreamDirectives,
|
||||
shouldUsePhaseAwareBlockReply,
|
||||
});
|
||||
const { mediaUrls, hasMedia } = resolveSendableOutboundReplyParts(parsedStreamDirectives ?? {});
|
||||
const hasAudio = Boolean(parsedStreamDirectives?.audioAsVoice);
|
||||
|
||||
let shouldEmit;
|
||||
let deltaText = "";
|
||||
let replace = false;
|
||||
if (!hasAssistantVisibleReply({ text: cleanedText, mediaUrls, audioAsVoice: hasAudio })) {
|
||||
shouldEmit = false;
|
||||
} else {
|
||||
replace = Boolean(previousCleaned && !cleanedText.startsWith(previousCleaned));
|
||||
deltaText = replace ? "" : cleanedText.slice(previousCleaned.length);
|
||||
shouldEmit = replace
|
||||
? cleanedText !== previousCleaned || hasMedia || hasAudio
|
||||
: Boolean(deltaText || hasMedia || hasAudio);
|
||||
}
|
||||
|
||||
if (shouldUsePhaseAwareBlockReply) {
|
||||
if (replace) {
|
||||
ctx.state.blockBuffer = "";
|
||||
ctx.blockChunker?.reset();
|
||||
}
|
||||
const blockReplyChunk = replace ? cleanedText : deltaText;
|
||||
if (blockReplyChunk) {
|
||||
appendBlockReplyChunk(ctx, blockReplyChunk);
|
||||
}
|
||||
|
||||
if (evtType === "text_end" && !ctx.state.lastBlockReplyText && cleanedText) {
|
||||
replaceBlockReplyBuffer(ctx, cleanedText);
|
||||
}
|
||||
} else if (streamItemChanged && !chunk) {
|
||||
// An unphased equal/shrinking Responses item can end without a delta.
|
||||
// Rebuild its block buffer from the scoped snapshot after the boundary reset.
|
||||
appendBlockReplyChunk(ctx, cleanedText);
|
||||
}
|
||||
|
||||
ctx.state.lastStreamedAssistant = nextRawStreamText;
|
||||
ctx.state.lastStreamedAssistantCleaned = cleanedText;
|
||||
|
||||
if (
|
||||
ctx.params.silentExpected ||
|
||||
suppressDeterministicApprovalOutput ||
|
||||
suppressMessageToolOnlySourceReplyOutput
|
||||
) {
|
||||
shouldEmit = false;
|
||||
}
|
||||
|
||||
if (shouldEmit) {
|
||||
const currentSourcePartial =
|
||||
ctx.params.sourceReplyDeliveryMode !== "message_tool_only"
|
||||
? resolveCurrentSourceMessagingToolPartial(ctx.state, {
|
||||
evtType,
|
||||
text: cleanedText,
|
||||
visibleDelta,
|
||||
})
|
||||
: { hold: false, text: cleanedText };
|
||||
const releaseHeldSnapshot = currentSourcePartial.text !== cleanedText;
|
||||
const data = buildAssistantStreamData({
|
||||
text: currentSourcePartial.text,
|
||||
delta: releaseHeldSnapshot ? currentSourcePartial.text : deltaText,
|
||||
replace: releaseHeldSnapshot || replace,
|
||||
mediaUrls,
|
||||
phase: deliveryPhase ?? assistantPhase,
|
||||
});
|
||||
ctx.emitAssistantStreamData(data, { emitPartialReply: !currentSourcePartial.hold });
|
||||
ctx.state.emittedAssistantUpdate = true;
|
||||
}
|
||||
} else if (shouldPersistRawStreamText) {
|
||||
ctx.state.lastStreamedAssistant = nextRawStreamText;
|
||||
}
|
||||
|
||||
if (
|
||||
!ctx.params.silentExpected &&
|
||||
!suppressDeterministicApprovalOutput &&
|
||||
!suppressMessageToolOnlySourceReplyOutput &&
|
||||
ctx.params.onBlockReply &&
|
||||
ctx.blockChunking &&
|
||||
ctx.state.blockReplyBreak === "text_end"
|
||||
) {
|
||||
ctx.blockChunker?.drain({ force: false, emit: ctx.emitBlockChunk });
|
||||
}
|
||||
|
||||
if (
|
||||
!ctx.params.silentExpected &&
|
||||
!suppressDeterministicApprovalOutput &&
|
||||
!suppressMessageToolOnlySourceReplyOutput &&
|
||||
evtType === "text_end" &&
|
||||
ctx.state.blockReplyBreak === "text_end"
|
||||
) {
|
||||
const assistantMessageIndex = ctx.state.assistantMessageIndex;
|
||||
void Promise.resolve()
|
||||
.then(() => ctx.flushBlockReplyBuffer({ assistantMessageIndex, final: true }))
|
||||
.catch((err: unknown) => {
|
||||
ctx.log.debug(`text_end block reply flush failed: ${String(err)}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.VITEST || process.env.NODE_ENV === "test") {
|
||||
(globalThis as Record<PropertyKey, unknown>)[
|
||||
Symbol.for("openclaw.embeddedSubscribeMessagesTestApi")
|
||||
] = {
|
||||
buildAssistantStreamData,
|
||||
recordPendingAssistantReplyDirectives,
|
||||
resolveCurrentSourceMessagingToolPartial,
|
||||
};
|
||||
}
|
||||
@@ -26,6 +26,11 @@ import {
|
||||
readMessageToolSourceReplyText,
|
||||
resolveMessageToolSourceReplyFinal,
|
||||
} from "./embedded-agent-message-tool-source-reply.js";
|
||||
import {
|
||||
extractMessagingToolSend,
|
||||
extractMessagingToolSendResult,
|
||||
extractMessagingToolSourceReplyPayload,
|
||||
} from "./embedded-agent-messaging-extraction.js";
|
||||
import {
|
||||
isMessagingTool,
|
||||
isMessagingToolSendAction,
|
||||
@@ -73,16 +78,14 @@ import type { ToolHandlerContext } from "./embedded-agent-subscribe.handlers.typ
|
||||
import {
|
||||
collectMessagingMediaUrlsFromRecord,
|
||||
collectMessagingMediaUrlsFromToolResult,
|
||||
} from "./embedded-agent-tool-media.js";
|
||||
import {
|
||||
capLiveExecResult,
|
||||
extractMessagingToolSourceReplyPayload,
|
||||
extractToolErrorCode,
|
||||
extractMessagingToolSend,
|
||||
extractMessagingToolSendResult,
|
||||
extractToolErrorMessage,
|
||||
isToolResultError,
|
||||
isToolResultTimedOut,
|
||||
sanitizeToolResult,
|
||||
} from "./embedded-agent-subscribe.tools.js";
|
||||
} from "./embedded-agent-tool-results.js";
|
||||
import { parseExecApprovalResultText } from "./exec-approval-result.js";
|
||||
import { readMcpConnectAction } from "./mcp-connect-action.js";
|
||||
import { readMcpAppChannelView } from "./mcp-ui-resource.js";
|
||||
@@ -93,6 +96,7 @@ import {
|
||||
} from "./tool-error-summary.js";
|
||||
import { resolveFileMutationToolName } from "./tool-mutation-names.js";
|
||||
import { normalizeToolPolicyName } from "./tool-policy.js";
|
||||
import { isToolResultError } from "./tool-result-error.js";
|
||||
import { cancelAskUserPromptDelivery } from "./tools/ask-user-tool.js";
|
||||
import { isAutomationsToolName } from "./tools/automations-tool-name.js";
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
capLiveExecResult,
|
||||
sanitizeToolResult,
|
||||
truncateLiveExecOutput,
|
||||
} from "./embedded-agent-subscribe.tools.js";
|
||||
} from "./embedded-agent-tool-results.js";
|
||||
import type { AgentEvent } from "./runtime/index.js";
|
||||
import { normalizeToolPolicyName } from "./tool-policy.js";
|
||||
|
||||
|
||||
@@ -21,10 +21,9 @@ import type { ExecToolDetails } from "./bash-tools.exec-types.js";
|
||||
import type { ToolHandlerContext } from "./embedded-agent-subscribe.handlers.types.js";
|
||||
import {
|
||||
extractToolResultMediaArtifact,
|
||||
extractToolResultText,
|
||||
filterToolResultMediaUrls,
|
||||
truncateLiveExecOutput,
|
||||
} from "./embedded-agent-subscribe.tools.js";
|
||||
} from "./embedded-agent-tool-media.js";
|
||||
import { extractToolResultText, truncateLiveExecOutput } from "./embedded-agent-tool-results.js";
|
||||
import type { ProcessTerminalDiagnostic } from "./tool-error-summary.js";
|
||||
import { readToolResultDetails } from "./tool-result-error.js";
|
||||
import { createToolTerminalObserver } from "./tool-terminal-outcome.js";
|
||||
|
||||
@@ -9,6 +9,7 @@ import { emitAgentActivityEvent, type AgentItemEventData } from "../infra/agent-
|
||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||
import { REQUIRED_PARAM_GROUPS, type RequiredParamGroup } from "./agent-tools.params.js";
|
||||
import { sanitizeForConsole } from "./console-sanitize.js";
|
||||
import { extractMessagingToolSend } from "./embedded-agent-messaging-extraction.js";
|
||||
import {
|
||||
isMessagingTool,
|
||||
isMessagingToolSendAction,
|
||||
@@ -23,11 +24,8 @@ import type {
|
||||
ToolCallSummary,
|
||||
ToolHandlerContext,
|
||||
} from "./embedded-agent-subscribe.handlers.types.js";
|
||||
import {
|
||||
collectMessagingMediaUrlsFromRecord,
|
||||
extractMessagingToolSend,
|
||||
sanitizeToolArgs,
|
||||
} from "./embedded-agent-subscribe.tools.js";
|
||||
import { collectMessagingMediaUrlsFromRecord } from "./embedded-agent-tool-media.js";
|
||||
import { sanitizeToolArgs } from "./embedded-agent-tool-results.js";
|
||||
import { buildAgentHarnessQuestionPromptPayload } from "./harness/user-input-bridge.js";
|
||||
import type { AgentEvent } from "./runtime/index.js";
|
||||
import { inferToolMetaFromArgsCore, isCommandBearingToolCall } from "./tool-display.js";
|
||||
|
||||
@@ -10,12 +10,12 @@ import {
|
||||
} from "./embedded-agent-subscribe.handlers.lifecycle.js";
|
||||
import {
|
||||
capturePendingAssistantUsage,
|
||||
handleMessageEnd,
|
||||
handleMessageStart,
|
||||
handleMessageUpdate,
|
||||
preservePendingAssistantUsage,
|
||||
resetPendingAssistantUsage,
|
||||
} from "./embedded-agent-subscribe.handlers.messages.js";
|
||||
handleMessageEnd,
|
||||
} from "./embedded-agent-subscribe.handlers.messages.lifecycle.js";
|
||||
import { handleMessageUpdate } from "./embedded-agent-subscribe.handlers.messages.update.js";
|
||||
import {
|
||||
handleToolExecutionEnd,
|
||||
handleToolExecutionStart,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import { extractMessagingToolSend } from "./embedded-agent-subscribe.tools.js";
|
||||
import { extractMessagingToolSend } from "./embedded-agent-messaging-extraction.js";
|
||||
|
||||
function normalizeTelegramMessagingTargetForTest(raw: string): string | undefined {
|
||||
// Test normalizer mirrors channel plugins that canonicalize human targets
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// Tool media extraction tests cover structured media payloads, image fallbacks,
|
||||
// trust decisions, and filtering of local/remote media URLs.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isToolResultMediaTrusted } from "./embedded-agent-subscribe.tools.test-support.js";
|
||||
import {
|
||||
extractToolResultMediaArtifact,
|
||||
filterToolResultMediaUrls,
|
||||
} from "./embedded-agent-subscribe.tools.js";
|
||||
import { isToolResultMediaTrusted } from "./embedded-agent-subscribe.tools.test-support.js";
|
||||
} from "./embedded-agent-tool-media.js";
|
||||
|
||||
describe("extractToolResultMediaArtifact", () => {
|
||||
it("returns undefined for null/undefined", () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/c
|
||||
import {
|
||||
extractMessagingToolSend,
|
||||
extractMessagingToolSendResult,
|
||||
} from "./embedded-agent-subscribe.tools.js";
|
||||
} from "./embedded-agent-messaging-extraction.js";
|
||||
|
||||
const PARTIAL_RESULT_PROVIDER = "partialthreadprovider";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import "./embedded-agent-subscribe.tools.js";
|
||||
import "./embedded-agent-tool-media.js";
|
||||
|
||||
type EmbeddedSubscribeToolsTestApi = {
|
||||
isToolResultMediaTrusted(
|
||||
|
||||
@@ -9,10 +9,10 @@ import {
|
||||
extractToolResultText,
|
||||
extractToolErrorCode,
|
||||
extractToolErrorMessage,
|
||||
isToolResultError,
|
||||
sanitizeToolArgs,
|
||||
sanitizeToolResult,
|
||||
} from "./embedded-agent-subscribe.tools.js";
|
||||
} from "./embedded-agent-tool-results.js";
|
||||
import { isToolResultError } from "./tool-result-error.js";
|
||||
|
||||
afterEach(() => {
|
||||
// Logging config spies are global module state; restore after every sanitizer
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,7 +42,7 @@ import {
|
||||
consumePendingToolMediaIntoReply,
|
||||
hasAssistantVisibleReply,
|
||||
readPendingToolMediaReply,
|
||||
} from "./embedded-agent-subscribe.handlers.messages.js";
|
||||
} from "./embedded-agent-subscribe.handlers.messages.replies.js";
|
||||
import {
|
||||
cleanupRunToolStartData,
|
||||
handleToolExecutionEnd,
|
||||
@@ -52,12 +52,12 @@ import type {
|
||||
EmbeddedAgentSubscribeContext,
|
||||
EmbeddedAgentSubscribeState,
|
||||
} from "./embedded-agent-subscribe.handlers.types.js";
|
||||
import type { SubscribeEmbeddedAgentSessionParams } from "./embedded-agent-subscribe.types.js";
|
||||
import {
|
||||
buildToolLifecycleErrorResult,
|
||||
extractToolResultMediaArtifact,
|
||||
filterToolResultMediaUrls,
|
||||
} from "./embedded-agent-subscribe.tools.js";
|
||||
import type { SubscribeEmbeddedAgentSessionParams } from "./embedded-agent-subscribe.types.js";
|
||||
} from "./embedded-agent-tool-media.js";
|
||||
import { buildToolLifecycleErrorResult } from "./embedded-agent-tool-results.js";
|
||||
import {
|
||||
createThinkingTagStreamState,
|
||||
stripDowngradedToolCallText,
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
/** Extracts and trust-filters media from embedded-agent tool results. */
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
||||
import { extractToolResultText } from "./embedded-agent-tool-results.js";
|
||||
import { normalizeToolPolicyName } from "./tool-policy.js";
|
||||
import { readToolResultDetails } from "./tool-result-error.js";
|
||||
import { AUTOMATIONS_TOOL_NAME } from "./tools/automations-tool-name.js";
|
||||
|
||||
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. */
|
||||
|
||||
const TRUSTED_TOOL_RESULT_MEDIA = new Set([
|
||||
"agents_list",
|
||||
"apply_patch",
|
||||
"browser",
|
||||
"canvas",
|
||||
AUTOMATIONS_TOOL_NAME,
|
||||
"edit",
|
||||
"exec",
|
||||
"gateway",
|
||||
"image",
|
||||
"image_generate",
|
||||
"memory_get",
|
||||
"memory_search",
|
||||
"message",
|
||||
"music_generate",
|
||||
"nodes",
|
||||
"process",
|
||||
"read",
|
||||
"session_status",
|
||||
"sessions_history",
|
||||
"sessions_list",
|
||||
"sessions_search",
|
||||
"sessions_send",
|
||||
"sessions_spawn",
|
||||
"subagents",
|
||||
"tts",
|
||||
"video_generate",
|
||||
"web_fetch",
|
||||
"web_search",
|
||||
"x_search",
|
||||
"write",
|
||||
]);
|
||||
const HTTP_URL_RE = /^https?:\/\//i;
|
||||
|
||||
function isCoreToolResultMediaTrustedName(toolName?: string): boolean {
|
||||
if (!toolName) {
|
||||
return false;
|
||||
}
|
||||
return TRUSTED_TOOL_RESULT_MEDIA.has(normalizeToolPolicyName(toolName));
|
||||
}
|
||||
|
||||
function isExternalToolResult(result: unknown): boolean {
|
||||
const details = readToolResultDetails(result);
|
||||
if (!details) {
|
||||
return false;
|
||||
}
|
||||
return typeof details.mcpServer === "string" || typeof details.mcpTool === "string";
|
||||
}
|
||||
|
||||
function isToolResultMediaTrusted(
|
||||
toolName?: string,
|
||||
result?: unknown,
|
||||
trustedLocalMediaToolNames?: ReadonlySet<string>,
|
||||
): boolean {
|
||||
if (!toolName || isExternalToolResult(result)) {
|
||||
return false;
|
||||
}
|
||||
const registeredName = toolName.trim();
|
||||
if (registeredName && trustedLocalMediaToolNames?.has(registeredName) === true) {
|
||||
return true;
|
||||
}
|
||||
return isCoreToolResultMediaTrustedName(toolName);
|
||||
}
|
||||
|
||||
if (process.env.VITEST || process.env.NODE_ENV === "test") {
|
||||
(globalThis as Record<PropertyKey, unknown>)[
|
||||
Symbol.for("openclaw.embeddedSubscribeToolsTestApi")
|
||||
] = { isToolResultMediaTrusted };
|
||||
}
|
||||
|
||||
function isTrustedOwnedTtsLocalMedia(
|
||||
toolName: string | undefined,
|
||||
result: unknown,
|
||||
trustedLocalMediaToolNames?: ReadonlySet<string>,
|
||||
): boolean {
|
||||
if (
|
||||
!toolName ||
|
||||
!isToolResultMediaTrusted(toolName, result, trustedLocalMediaToolNames) ||
|
||||
normalizeToolPolicyName(toolName) !== "tts"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const media = readToolResultDetails(result)?.media;
|
||||
if (!media || typeof media !== "object" || Array.isArray(media)) {
|
||||
return false;
|
||||
}
|
||||
return (media as Record<string, unknown>).trustedLocalMedia === true;
|
||||
}
|
||||
|
||||
export function filterToolResultMediaUrls(
|
||||
toolName: string | undefined,
|
||||
mediaUrls: string[],
|
||||
result?: unknown,
|
||||
trustedLocalMediaToolNames?: ReadonlySet<string>,
|
||||
): string[] {
|
||||
if (mediaUrls.length === 0) {
|
||||
return mediaUrls;
|
||||
}
|
||||
const trustedOwnedTtsLocalMedia = isTrustedOwnedTtsLocalMedia(
|
||||
toolName,
|
||||
result,
|
||||
trustedLocalMediaToolNames,
|
||||
);
|
||||
if (isToolResultMediaTrusted(toolName, result, trustedLocalMediaToolNames)) {
|
||||
// When the current run provides its exact trusted local-media tool names,
|
||||
// require the raw emitted tool name to match one of them before allowing
|
||||
// local media paths.
|
||||
// This blocks normalized aliases and case-variant collisions such as
|
||||
// "Bash" -> "bash" or "Web_Search" -> "web_search" from inheriting a
|
||||
// registered tool's media trust. TTS-generated local files carry a
|
||||
// separate trusted-media flag from the owned tool result, so they can
|
||||
// survive runs whose exact trusted set omitted the raw tts name.
|
||||
if (trustedLocalMediaToolNames !== undefined) {
|
||||
if (!trustedOwnedTtsLocalMedia) {
|
||||
const registeredName = toolName?.trim();
|
||||
if (!registeredName || !trustedLocalMediaToolNames.has(registeredName)) {
|
||||
return mediaUrls.filter((url) => HTTP_URL_RE.test(url.trim()));
|
||||
}
|
||||
}
|
||||
}
|
||||
return mediaUrls;
|
||||
}
|
||||
return mediaUrls.filter((url) => HTTP_URL_RE.test(url.trim()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract media file paths from a tool result.
|
||||
*
|
||||
* Strategy (first match wins):
|
||||
* 1. Read structured `details.media` attachments from tool details.
|
||||
* 2. Fall back to `details.path` when image content exists (legacy imageResult).
|
||||
*
|
||||
* Returns an empty array when no media is found (e.g. embedded `read` tool
|
||||
* returns base64 image data but no file path; those need a different delivery
|
||||
* path like saving to a temp file).
|
||||
*/
|
||||
type ToolResultMediaArtifact = {
|
||||
mediaUrls: string[];
|
||||
audioAsVoice?: boolean;
|
||||
trustedLocalMedia?: boolean;
|
||||
};
|
||||
|
||||
function readToolResultDetailsMedia(
|
||||
result: Record<string, unknown>,
|
||||
): Record<string, unknown> | undefined {
|
||||
const details = readToolResultDetails(result);
|
||||
const media =
|
||||
details?.media && typeof details.media === "object" && !Array.isArray(details.media)
|
||||
? (details.media as Record<string, unknown>)
|
||||
: undefined;
|
||||
return media;
|
||||
}
|
||||
|
||||
function collectStructuredMediaUrls(media: Record<string, unknown>): string[] {
|
||||
const urls: string[] = [];
|
||||
const pushString = (value: unknown) => {
|
||||
if (typeof value !== "string") {
|
||||
return;
|
||||
}
|
||||
const normalized = value.trim();
|
||||
if (normalized) {
|
||||
urls.push(normalized);
|
||||
}
|
||||
};
|
||||
const pushAttachment = (value: unknown) => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return;
|
||||
}
|
||||
const attachment = value as Record<string, unknown>;
|
||||
pushString(attachment.media);
|
||||
pushString(attachment.path);
|
||||
pushString(attachment.url);
|
||||
pushString(attachment.mediaUrl);
|
||||
pushString(attachment.filePath);
|
||||
pushString(attachment.fileUrl);
|
||||
};
|
||||
pushString(media.media);
|
||||
pushString(media.path);
|
||||
pushString(media.url);
|
||||
pushString(media.mediaUrl);
|
||||
pushString(media.filePath);
|
||||
pushString(media.fileUrl);
|
||||
if (Array.isArray(media.mediaUrls)) {
|
||||
for (const value of media.mediaUrls) {
|
||||
pushString(value);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(media.attachments)) {
|
||||
for (const attachment of media.attachments) {
|
||||
pushAttachment(attachment);
|
||||
}
|
||||
}
|
||||
return uniqueStrings(urls);
|
||||
}
|
||||
|
||||
function isNonOutboundToolResultMedia(media: Record<string, unknown>): boolean {
|
||||
return media.outbound === false;
|
||||
}
|
||||
|
||||
function hasImageContentBlock(content: unknown[]): boolean {
|
||||
for (const item of content) {
|
||||
if (!item || typeof item !== "object") {
|
||||
continue;
|
||||
}
|
||||
const entry = item as Record<string, unknown>;
|
||||
if (entry.type === "image") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function extractToolResultMediaArtifact(
|
||||
result: unknown,
|
||||
): ToolResultMediaArtifact | undefined {
|
||||
if (!result || typeof result !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const record = result as Record<string, unknown>;
|
||||
const detailsMedia = readToolResultDetailsMedia(record);
|
||||
if (detailsMedia) {
|
||||
if (isNonOutboundToolResultMedia(detailsMedia)) {
|
||||
return undefined;
|
||||
}
|
||||
const mediaUrls = collectStructuredMediaUrls(detailsMedia);
|
||||
if (mediaUrls.length > 0) {
|
||||
return {
|
||||
mediaUrls,
|
||||
...(detailsMedia.audioAsVoice === true ? { audioAsVoice: true } : {}),
|
||||
...(detailsMedia.trustedLocalMedia === true ? { trustedLocalMedia: true } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const content = Array.isArray(record.content) ? record.content : null;
|
||||
if (!content) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Fall back to legacy details.path when image content exists but no
|
||||
// structured media details.
|
||||
if (hasImageContentBlock(content)) {
|
||||
const details = record.details as Record<string, unknown> | undefined;
|
||||
const p = normalizeOptionalString(details?.path) ?? "";
|
||||
if (p) {
|
||||
return { mediaUrls: [p] };
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
/** Sanitizes, extracts, and classifies embedded-agent tool execution results. */
|
||||
import { estimateBase64DecodedBytes } from "@openclaw/media-core/base64";
|
||||
import { asOptionalRecord as readRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import {
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
readStringValue,
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import {
|
||||
redactSecrets,
|
||||
redactSensitiveFieldValue,
|
||||
redactToolPayloadText,
|
||||
} from "../logging/redact.js";
|
||||
import { truncateUtf16Safe } from "../utils.js";
|
||||
import { collectTextContentBlocks } from "./content-blocks.js";
|
||||
import {
|
||||
isToolResultError,
|
||||
readToolResultDetails,
|
||||
readToolResultStatus,
|
||||
} from "./tool-result-error.js";
|
||||
|
||||
const TOOL_RESULT_MAX_CHARS = 8000;
|
||||
const TOOL_ERROR_MAX_CHARS = 400;
|
||||
const LIVE_EXEC_OUTPUT_MAX_CHARS = 8000;
|
||||
const TOOL_DENIAL_ERROR_CODES = ["SYSTEM_RUN_DENIED", "INVALID_REQUEST"] as const;
|
||||
const OPAQUE_STRUCTURED_RESULT_FIELDS = new Set(["encrypted_content", "encrypted_stdout"]);
|
||||
const SENSITIVE_STRUCTURED_HEADER_FIELDS = new Set([
|
||||
"authorization",
|
||||
"proxy-authorization",
|
||||
"cookie",
|
||||
"set-cookie",
|
||||
"x-api-key",
|
||||
"x-auth-token",
|
||||
]);
|
||||
|
||||
function truncateToolText(text: string): string {
|
||||
if (text.length <= TOOL_RESULT_MAX_CHARS) {
|
||||
return text;
|
||||
}
|
||||
return `${truncateUtf16Safe(text, TOOL_RESULT_MAX_CHARS)}\n…(truncated)…`;
|
||||
}
|
||||
|
||||
export function truncateLiveExecOutput(text: string): string {
|
||||
if (text.length <= LIVE_EXEC_OUTPUT_MAX_CHARS) {
|
||||
return text;
|
||||
}
|
||||
return `${truncateUtf16Safe(text, LIVE_EXEC_OUTPUT_MAX_CHARS)}\n...(live output truncated)...`;
|
||||
}
|
||||
|
||||
export function capLiveExecResult(result: unknown): unknown {
|
||||
const details = readToolResultDetails(result);
|
||||
if (!details || typeof details.status !== "string" || typeof details.aggregated !== "string") {
|
||||
return result;
|
||||
}
|
||||
const aggregated = truncateLiveExecOutput(details.aggregated);
|
||||
if (aggregated === details.aggregated) {
|
||||
return result;
|
||||
}
|
||||
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
||||
return result;
|
||||
}
|
||||
return {
|
||||
...(result as Record<string, unknown>),
|
||||
details: {
|
||||
...details,
|
||||
aggregated,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeToolErrorText(text: string): string | undefined {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const firstLine = trimmed.split(/\r?\n/)[0]?.trim() ?? "";
|
||||
if (!firstLine) {
|
||||
return undefined;
|
||||
}
|
||||
return firstLine.length > TOOL_ERROR_MAX_CHARS
|
||||
? `${truncateUtf16Safe(firstLine, TOOL_ERROR_MAX_CHARS)}…`
|
||||
: firstLine;
|
||||
}
|
||||
|
||||
function isErrorLikeStatus(status: string): boolean {
|
||||
const normalized = normalizeOptionalLowercaseString(status);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
normalized === "0" ||
|
||||
normalized === "ok" ||
|
||||
normalized === "success" ||
|
||||
normalized === "completed" ||
|
||||
normalized === "running"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return /error|fail|timeout|timed[_\s-]?out|denied|cancel|invalid|forbidden/.test(normalized);
|
||||
}
|
||||
|
||||
function readErrorCandidate(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return normalizeToolErrorText(value);
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.message === "string") {
|
||||
return normalizeToolErrorText(record.message);
|
||||
}
|
||||
if (typeof record.error === "string") {
|
||||
return normalizeToolErrorText(record.error);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractErrorField(value: unknown): string | undefined {
|
||||
if (!value || typeof value !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const direct = extractDirectErrorField(record);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
const status = normalizeOptionalString(record.status) ?? "";
|
||||
if (!status || !isErrorLikeStatus(status)) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeToolErrorText(status);
|
||||
}
|
||||
|
||||
function extractDirectErrorField(value: unknown): string | undefined {
|
||||
if (!value || typeof value !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
return (
|
||||
readErrorCandidate(record.error) ??
|
||||
readErrorCandidate(record.message) ??
|
||||
readErrorCandidate(record.reason)
|
||||
);
|
||||
}
|
||||
|
||||
function readErrorCodeField(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? normalizeOptionalString(value) : undefined;
|
||||
}
|
||||
|
||||
function readDenialErrorCodeFromMessage(value: unknown): string | undefined {
|
||||
const message = typeof value === "string" ? normalizeOptionalString(value) : undefined;
|
||||
if (!message) {
|
||||
return undefined;
|
||||
}
|
||||
for (const code of TOOL_DENIAL_ERROR_CODES) {
|
||||
if (message === code || message.startsWith(`${code}:`)) {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readNestedErrorCodeField(value: unknown): string | undefined {
|
||||
if (!value || typeof value !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
return (
|
||||
readDenialErrorCodeFromMessage(record.message) ??
|
||||
readDenialErrorCodeFromMessage(record.error) ??
|
||||
readErrorCodeField(record.code) ??
|
||||
readErrorCodeField(record.gatewayCode)
|
||||
);
|
||||
}
|
||||
|
||||
function extractDirectErrorCodeField(value: unknown): string | undefined {
|
||||
if (!value || typeof value !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
return (
|
||||
readNestedErrorCodeField(record.error) ??
|
||||
readNestedErrorCodeField(record.nodeError) ??
|
||||
readErrorCodeField(record.code) ??
|
||||
readErrorCodeField(record.gatewayCode)
|
||||
);
|
||||
}
|
||||
|
||||
export function buildToolLifecycleErrorResult(error: unknown): {
|
||||
details: Record<string, unknown>;
|
||||
} {
|
||||
const errorRecord = readRecord(error);
|
||||
const rawDetails = readRecord(errorRecord?.details);
|
||||
const nodeError = readRecord(rawDetails?.nodeError);
|
||||
const gatewayCode =
|
||||
readErrorCodeField(errorRecord?.gatewayCode) ?? readErrorCodeField(errorRecord?.code);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
details: {
|
||||
status: "error",
|
||||
error: message,
|
||||
...(gatewayCode ? { gatewayCode } : {}),
|
||||
...(nodeError ? { nodeError } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function extractAggregatedErrorField(value: unknown): string | undefined {
|
||||
if (!value || typeof value !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
return readErrorCandidate(record.aggregated);
|
||||
}
|
||||
|
||||
function redactStringsDeep(value: unknown, seen = new WeakSet<object>()): unknown {
|
||||
if (typeof value === "string") {
|
||||
return redactToolPayloadText(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (seen.has(value)) {
|
||||
return "[Circular]";
|
||||
}
|
||||
seen.add(value);
|
||||
return value.map((item) => redactStringsDeep(item, seen));
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
if (seen.has(value)) {
|
||||
return "[Circular]";
|
||||
}
|
||||
seen.add(value);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[key] =
|
||||
typeof child === "string"
|
||||
? redactSensitiveFieldValue(key, child)
|
||||
: redactStringsDeep(child, seen);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function sanitizeToolArgs(args: unknown): unknown {
|
||||
return redactStringsDeep(args);
|
||||
}
|
||||
|
||||
export function sanitizeToolResult(result: unknown): unknown {
|
||||
if (typeof result === "string") {
|
||||
return redactToolPayloadText(result);
|
||||
}
|
||||
if (Array.isArray(result)) {
|
||||
return redactSecrets(result);
|
||||
}
|
||||
if (!result || typeof result !== "object") {
|
||||
return result;
|
||||
}
|
||||
const record = result as Record<string, unknown>;
|
||||
// Strip image data first so the deep redaction pass doesn't waste work
|
||||
// scanning base64 payloads (and so we capture the original byte counts).
|
||||
const preCleaned: Record<string, unknown> = { ...record };
|
||||
const originalContent = Array.isArray(record.content) ? record.content : null;
|
||||
if (originalContent) {
|
||||
preCleaned.content = originalContent.map((item) => {
|
||||
if (!item || typeof item !== "object") {
|
||||
return item;
|
||||
}
|
||||
const entry = item as Record<string, unknown>;
|
||||
if (readStringValue(entry.type) === "image") {
|
||||
const data = readStringValue(entry.data);
|
||||
const existingBytes = typeof entry.bytes === "number" ? entry.bytes : undefined;
|
||||
const bytes = data === undefined ? existingBytes : estimateBase64DecodedBytes(data);
|
||||
const cleaned = { ...entry };
|
||||
delete cleaned.data;
|
||||
return Object.assign({}, cleaned, { bytes, omitted: true });
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
// Deep-redact the entire result so any top-level or nested string is
|
||||
// protected, not just `details` and text content blocks.
|
||||
const baseline = redactSecrets(preCleaned);
|
||||
const out: Record<string, unknown> = { ...baseline };
|
||||
const content = Array.isArray(baseline.content) ? baseline.content : null;
|
||||
if (content) {
|
||||
out.content = content.map((item) => {
|
||||
if (!item || typeof item !== "object") {
|
||||
return item;
|
||||
}
|
||||
const entry = item as Record<string, unknown>;
|
||||
if (readStringValue(entry.type) === "text" && typeof entry.text === "string") {
|
||||
return Object.assign({}, entry, { text: truncateToolText(entry.text) });
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const INLINE_DATA_URI_VALUE_PATTERN =
|
||||
/^data:(?:[a-z][a-z0-9.+-]*\/[a-z0-9.+-]+)?(?:;[a-z0-9.+-]+(?:=[^,;"'\s]+)?)*,/i;
|
||||
|
||||
function redactInlineDataUriValue(value: string): string {
|
||||
const trimmed = value.trimStart();
|
||||
if (!INLINE_DATA_URI_VALUE_PATTERN.test(trimmed)) {
|
||||
return value;
|
||||
}
|
||||
return `[inline data URI: ${value.length} chars]`;
|
||||
}
|
||||
|
||||
function carriesBinaryData(record: Record<string, unknown>): boolean {
|
||||
const type = normalizeOptionalLowercaseString(record.type);
|
||||
if (type === "audio" || type === "image" || type === "base64") {
|
||||
return true;
|
||||
}
|
||||
const mediaType = normalizeOptionalLowercaseString(record.media_type ?? record.mimeType);
|
||||
return (
|
||||
mediaType?.startsWith("image/") === true ||
|
||||
mediaType?.startsWith("audio/") === true ||
|
||||
mediaType?.startsWith("video/") === true ||
|
||||
mediaType === "application/pdf"
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeStructuredToolResultValue(
|
||||
value: unknown,
|
||||
key = "",
|
||||
parentCarriesBinaryData = false,
|
||||
seen = new WeakSet<object>(),
|
||||
): unknown {
|
||||
if (typeof value === "string") {
|
||||
if (SENSITIVE_STRUCTURED_HEADER_FIELDS.has(key.toLowerCase())) {
|
||||
return "***";
|
||||
}
|
||||
if (key === "blob" || (key === "data" && parentCarriesBinaryData)) {
|
||||
return `[binary omitted: ${value.length} chars]`;
|
||||
}
|
||||
// Claude CLI result blocks carry replay-only ciphertext that is not useful display text.
|
||||
if (OPAQUE_STRUCTURED_RESULT_FIELDS.has(key)) {
|
||||
return `[opaque data omitted: ${value.length} chars]`;
|
||||
}
|
||||
return truncateToolText(redactInlineDataUriValue(redactSensitiveFieldValue(key, value)));
|
||||
}
|
||||
if (typeof value === "bigint") {
|
||||
return value.toString();
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
if (seen.has(value)) {
|
||||
return "[Circular]";
|
||||
}
|
||||
seen.add(value);
|
||||
if (Array.isArray(value)) {
|
||||
// Keep the owning key so arrays of credentials inherit the same redaction policy.
|
||||
return value.map((item) =>
|
||||
sanitizeStructuredToolResultValue(item, key, parentCarriesBinaryData, seen),
|
||||
);
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const hasBinaryData = carriesBinaryData(record);
|
||||
return Object.fromEntries(
|
||||
Object.entries(record).map(([childKey, child]) => [
|
||||
childKey,
|
||||
sanitizeStructuredToolResultValue(child, childKey, hasBinaryData, seen),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function stringifyStructuredToolResultContent(block: unknown): string | undefined {
|
||||
if (!block || typeof block !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const record = block as Record<string, unknown>;
|
||||
const type = readStringValue(record.type);
|
||||
if (type === "text" || type === "image" || type === "image_url" || type === "audio") {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const serialized = JSON.stringify(sanitizeStructuredToolResultValue(record));
|
||||
const redacted = serialized ? redactToolPayloadText(serialized) : serialized;
|
||||
return redacted && redacted !== "{}" ? redacted : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveToolResultContentBlocks(result: object): unknown[] {
|
||||
if (Array.isArray(result)) {
|
||||
return result;
|
||||
}
|
||||
const record = result as Record<string, unknown>;
|
||||
// Typed provider blocks own their `content`; only untyped tool-result envelopes unwrap it.
|
||||
if (readStringValue(record.type)) {
|
||||
return [record];
|
||||
}
|
||||
if (Array.isArray(record.content)) {
|
||||
return record.content;
|
||||
}
|
||||
if (record.content && typeof record.content === "object") {
|
||||
return [record.content];
|
||||
}
|
||||
return [record];
|
||||
}
|
||||
|
||||
export function extractToolResultText(result: unknown): string | undefined {
|
||||
if (typeof result === "string") {
|
||||
const trimmed = redactToolPayloadText(redactInlineDataUriValue(result)).trim();
|
||||
return trimmed ? truncateToolText(trimmed) : undefined;
|
||||
}
|
||||
if (!result || typeof result !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const content = resolveToolResultContentBlocks(result);
|
||||
const texts = collectTextContentBlocks(content)
|
||||
.map((item) => {
|
||||
const trimmed = item.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
})
|
||||
.filter((value): value is string => Boolean(value));
|
||||
if (texts.length > 0) {
|
||||
return truncateToolText(texts.join("\n"));
|
||||
}
|
||||
const structuredTexts: string[] = [];
|
||||
for (const item of content) {
|
||||
const structured = stringifyStructuredToolResultContent(item);
|
||||
if (structured) {
|
||||
structuredTexts.push(structured);
|
||||
}
|
||||
}
|
||||
if (structuredTexts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return truncateToolText(structuredTexts.join("\n"));
|
||||
}
|
||||
|
||||
export function extractToolErrorCode(result: unknown): string | undefined {
|
||||
if (!result || typeof result !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const record = result as Record<string, unknown>;
|
||||
return extractDirectErrorCodeField(record.details) ?? extractDirectErrorCodeField(record);
|
||||
}
|
||||
|
||||
export function isToolResultTimedOut(result: unknown): boolean {
|
||||
const normalizedStatus = readToolResultStatus(result);
|
||||
if (normalizedStatus === "timeout") {
|
||||
return true;
|
||||
}
|
||||
return readToolResultDetails(result)?.timedOut === true;
|
||||
}
|
||||
|
||||
export function extractToolErrorMessage(result: unknown): string | undefined {
|
||||
if (!result || typeof result !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const record = result as Record<string, unknown>;
|
||||
const fromDetails = extractDirectErrorField(record.details);
|
||||
if (fromDetails) {
|
||||
return fromDetails;
|
||||
}
|
||||
const fromDetailsAggregated = extractAggregatedErrorField(record.details);
|
||||
if (fromDetailsAggregated) {
|
||||
return fromDetailsAggregated;
|
||||
}
|
||||
const fromRoot = extractDirectErrorField(record);
|
||||
if (fromRoot) {
|
||||
return fromRoot;
|
||||
}
|
||||
const text = extractToolResultText(result);
|
||||
if (text) {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
const fromJson = extractErrorField(parsed);
|
||||
if (fromJson) {
|
||||
return fromJson;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to status/text fallback.
|
||||
}
|
||||
}
|
||||
const fromDetailsStatus = extractErrorField(record.details);
|
||||
if (fromDetailsStatus) {
|
||||
return fromDetailsStatus;
|
||||
}
|
||||
const fromRootStatus = extractErrorField(record);
|
||||
if (fromRootStatus) {
|
||||
return fromRootStatus;
|
||||
}
|
||||
const status = readToolResultStatus(result);
|
||||
if (status && !isToolResultError(result)) {
|
||||
return undefined;
|
||||
}
|
||||
return text ? normalizeToolErrorText(text) : undefined;
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getReplyPayloadMetadata } from "../../auto-reply/reply-payload.js";
|
||||
import { buildReplyPayloads } from "../../auto-reply/reply/agent-runner-payloads.js";
|
||||
import { extractMessagingToolSourceReplyPayload } from "../embedded-agent-messaging-extraction.js";
|
||||
import { buildEmbeddedRunPayloads } from "../embedded-agent-runner/run/payloads.js";
|
||||
import { extractMessagingToolSourceReplyPayload } from "../embedded-agent-subscribe.tools.js";
|
||||
import { createMessageTool } from "./message-tool-execution.js";
|
||||
|
||||
describe("WebChat message tool internal source reply", () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe
|
||||
import { runCliAgent } from "../../agents/cli-runner.js";
|
||||
import type { RunCliAgentParams } from "../../agents/cli-runner/types.js";
|
||||
import { clearCliSession, getCliSessionBinding } from "../../agents/cli-session.js";
|
||||
import { extractToolResultText } from "../../agents/embedded-agent-subscribe.tools.js";
|
||||
import { extractToolResultText } from "../../agents/embedded-agent-tool-results.js";
|
||||
import type { EmbeddedAgentRunResult } from "../../agents/embedded-agent.js";
|
||||
import {
|
||||
DEFAULT_FAST_MODE_AUTO_ON_SECONDS,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { consumePendingToolMediaIntoReply } from "../../agents/embedded-agent-subscribe.handlers.messages.js";
|
||||
import { consumePendingToolMediaIntoReply } from "../../agents/embedded-agent-subscribe.handlers.messages.replies.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
|
||||
import {
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
capLiveExecResult,
|
||||
sanitizeToolArgs,
|
||||
sanitizeToolResult,
|
||||
} from "../../agents/embedded-agent-subscribe.tools.js";
|
||||
} from "../../agents/embedded-agent-tool-results.js";
|
||||
import { normalizeToolPolicyName } from "../../agents/tool-policy.js";
|
||||
import { createTrajectoryRuntimeRecorder } from "../../trajectory/runtime.js";
|
||||
|
||||
|
||||
@@ -216,14 +216,18 @@ export { isMessagingTool, isMessagingToolSendAction } from "../agents/embedded-a
|
||||
export {
|
||||
extractMessagingToolSend,
|
||||
extractMessagingToolSendResult,
|
||||
extractToolErrorMessage,
|
||||
} from "../agents/embedded-agent-messaging-extraction.js";
|
||||
export {
|
||||
extractToolResultMediaArtifact,
|
||||
filterToolResultMediaUrls,
|
||||
isToolResultError,
|
||||
} from "../agents/embedded-agent-tool-media.js";
|
||||
export {
|
||||
extractToolErrorMessage,
|
||||
sanitizeToolResult,
|
||||
} from "../agents/embedded-agent-subscribe.tools.js";
|
||||
} from "../agents/embedded-agent-tool-results.js";
|
||||
export {
|
||||
formatToolExecutionErrorMessage,
|
||||
isToolResultError,
|
||||
resolveToolExecutionErrorKind,
|
||||
resolveToolResultFailureKind,
|
||||
type ToolResultFailureKind,
|
||||
|
||||
@@ -241,7 +241,7 @@ async function mirrorSystemAgentToolStateFromEvents(params: {
|
||||
{ resolveSystemAgentProposalTransition, resolveSystemAgentDirectiveTransition },
|
||||
] = await Promise.all([
|
||||
import("../infra/agent-events.js"),
|
||||
import("../agents/embedded-agent-subscribe.tools.js"),
|
||||
import("../agents/embedded-agent-tool-results.js"),
|
||||
import("../agents/tools/system-agent-tool.js"),
|
||||
]);
|
||||
return onAgentEvent((evt) => {
|
||||
|
||||
Reference in New Issue
Block a user