diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index b076d58b34cd..66a95f9cb59f 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -372,7 +372,7 @@ extensions/feishu/src/setup-core.ts 1 extensions/feishu/src/setup-surface.ts 17 extensions/feishu/src/thread-bindings.ts 2 extensions/feishu/src/tool-account.ts 1 -extensions/feishu/src/typing-backoff.ts 3 +extensions/feishu/src/typing-backoff.ts 2 extensions/feishu/src/wiki.ts 2 extensions/file-transfer/index.ts 4 extensions/file-transfer/src/node-host/dir-fetch.ts 1 diff --git a/extensions/feishu/src/typing-backoff.ts b/extensions/feishu/src/typing-backoff.ts index a40300bb2ab2..83597d8ac215 100644 --- a/extensions/feishu/src/typing-backoff.ts +++ b/extensions/feishu/src/typing-backoff.ts @@ -21,13 +21,12 @@ export function isFeishuBackoffError(err: unknown): boolean { if (response.status === 429) { return true; } - if (typeof response.data?.code === "number" && FEISHU_BACKOFF_CODES.has(response.data.code)) { + if (getBackoffCodeFromResponse(response.data) !== undefined) { return true; } } - const code = (err as { code?: number }).code; - return typeof code === "number" && FEISHU_BACKOFF_CODES.has(code); + return getBackoffCodeFromResponse(err) !== undefined; } export function getBackoffCodeFromResponse(response: unknown): number | undefined { diff --git a/extensions/line/src/auto-reply-delivery.ts b/extensions/line/src/auto-reply-delivery.ts index 27050d57788a..fde9d6177263 100644 --- a/extensions/line/src/auto-reply-delivery.ts +++ b/extensions/line/src/auto-reply-delivery.ts @@ -269,11 +269,7 @@ export async function deliverLineAutoReply(params: { }; const mediaMessages: messagingApi.Message[] = []; let deliveryError: unknown; - for (const rawUrl of mediaUrls) { - const url = rawUrl?.trim(); - if (!url) { - continue; - } + for (const url of mediaUrls) { try { mediaMessages.push(await buildLineMediaMessage(url, mediaOpts, to)); } catch (err) { diff --git a/extensions/signal/src/media-text.ts b/extensions/signal/src/media-text.ts index 32e82d09e931..c3ccb0486d28 100644 --- a/extensions/signal/src/media-text.ts +++ b/extensions/signal/src/media-text.ts @@ -4,13 +4,6 @@ import { } from "openclaw/plugin-sdk/channel-inbound"; import { kindFromMime } from "openclaw/plugin-sdk/media-runtime"; -function formatAttachmentKindCount(kind: string, count: number): string { - if (kind === "attachment") { - return `${count} file${count > 1 ? "s" : ""}`; - } - return `${count} ${kind}${count > 1 ? "s" : ""}`; -} - /** Keeps Signal's established multi-attachment text while sharing single-item rendering. */ export function formatSignalMediaText(media: readonly MediaPlaceholderTextFact[]): string { if (media.length <= 1) { @@ -24,8 +17,8 @@ export function formatSignalMediaText(media: readonly MediaPlaceholderTextFact[] : (kindFromMime(entry.contentType) ?? "attachment"); kindCounts.set(kind, (kindCounts.get(kind) ?? 0) + 1); } - const parts = [...kindCounts.entries()].map(([kind, count]) => - formatAttachmentKindCount(kind, count), + const parts = [...kindCounts.entries()].map( + ([kind, count]) => `${count} ${kind === "attachment" ? "file" : kind}${count > 1 ? "s" : ""}`, ); return `[${parts.join(" + ")} attached]`; } diff --git a/extensions/zalouser/src/channel.adapters.ts b/extensions/zalouser/src/channel.adapters.ts index 26ab77f9d229..8504a4463d07 100644 --- a/extensions/zalouser/src/channel.adapters.ts +++ b/extensions/zalouser/src/channel.adapters.ts @@ -174,11 +174,6 @@ const sendZalouserOutbound: NonNullable = a }), ); -const zalouserRawSendResultAdapter = { - sendText: sendZalouserOutbound, - sendMedia: sendZalouserOutbound, -}; - export const zalouserMessageAdapter = defineChannelMessageAdapter({ id: "zalouser", durableFinal: { @@ -445,18 +440,15 @@ export const zalouserOutboundAdapter = { deliveryMode: "direct" as const, chunker: chunkTextForOutbound, chunkerMode: "markdown" as const, - sendPayload: async ( - ctx: { payload: object } & Parameters< - NonNullable - >[0], - ) => + sendPayload: async (ctx: { payload: object } & Parameters[0]) => await sendPayloadWithChunkedTextAndMedia({ ctx, - sendText: zalouserRawSendResultAdapter.sendText, - sendMedia: zalouserRawSendResultAdapter.sendMedia, + sendText: sendZalouserOutbound, + sendMedia: sendZalouserOutbound, emptyResult: createEmptyChannelResult("zalouser"), }), - ...zalouserRawSendResultAdapter, + sendText: sendZalouserOutbound, + sendMedia: sendZalouserOutbound, sanitizeText: ({ text }) => sanitizeAssistantVisibleText(text), } satisfies ChannelOutboundAdapter; diff --git a/src/auto-reply/reply/block-streaming.ts b/src/auto-reply/reply/block-streaming.ts index 7cbc08515888..a3f00300eb96 100644 --- a/src/auto-reply/reply/block-streaming.ts +++ b/src/auto-reply/reply/block-streaming.ts @@ -100,12 +100,16 @@ export function resolveEffectiveBlockStreamingConfig(params: { chunking: BlockStreamingChunking; coalescing: BlockStreamingCoalescing; } { - const { textLimit } = resolveProviderChunkContext(params.cfg, params.provider, params.accountId); + const providerContext = resolveProviderChunkContext( + params.cfg, + params.provider, + params.accountId, + ); const chunkingDefaults = params.chunking ?? resolveBlockStreamingChunking(params.cfg, params.provider, params.accountId); const chunkingMax = clampPositiveInteger(params.maxChunkChars, chunkingDefaults.maxChars, { min: 1, - max: Math.max(1, textLimit), + max: Math.max(1, providerContext.textLimit), }); const chunking: BlockStreamingChunking = { ...chunkingDefaults, @@ -114,7 +118,7 @@ export function resolveEffectiveBlockStreamingConfig(params: { }; const coalescingDefaults = resolveBlockStreamingCoalescing( params.cfg, - params.provider, + providerContext, params.accountId, chunking, ); @@ -164,16 +168,10 @@ export function resolveBlockStreamingChunking( function resolveBlockStreamingCoalescing( cfg: OpenClawConfig | undefined, - provider: string | undefined, + { providerKey, providerId, textLimit }: ReturnType, accountId: string | null | undefined, chunking: BlockStreamingChunking, ): BlockStreamingCoalescing { - const { providerKey, providerId, textLimit } = resolveProviderChunkContext( - cfg, - provider, - accountId, - ); - const providerDefaults = providerId ? getChannelPlugin(providerId)?.streaming?.blockStreamingCoalesceDefaults : undefined; diff --git a/src/auto-reply/reply/dispatch-from-config.prepare-delivery.ts b/src/auto-reply/reply/dispatch-from-config.prepare-delivery.ts index f00ef4e43ec4..d1b99e34e8d3 100644 --- a/src/auto-reply/reply/dispatch-from-config.prepare-delivery.ts +++ b/src/auto-reply/reply/dispatch-from-config.prepare-delivery.ts @@ -2,7 +2,6 @@ import { isParentOwnedBackgroundAcpSession } from "@openclaw/acp-core/session-in import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { readAcpSessionEntryAsync } from "../../acp/runtime/session-meta.js"; import { logVerbose } from "../../globals.js"; -import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js"; import { resolveCommandTurnTargetSessionKey } from "../command-turn-context.js"; import { copyReplyPayloadMetadata, @@ -56,25 +55,25 @@ export async function prepareDispatchDelivery(state: GatherDispatchRequestReadyS ? { ...currentAcpSession.entry, acp: currentAcpSession.acp } : undefined; const suppressAcpChildUserDelivery = isParentOwnedBackgroundAcpSession(sessionEntryWithAcp); - const normalizedRouteReplyChannel = normalizeMessageChannel(replyRoute.channel); - const normalizedProviderChannel = normalizeMessageChannel(ctx.Provider); - const normalizedSurfaceChannel = normalizeMessageChannel(ctx.Surface); - const normalizedCurrentSurface = normalizedProviderChannel ?? normalizedSurfaceChannel; const effectiveExplicitDeliverRoute = ctx.ExplicitDeliverRoute === true || replyRoute.inheritedExternalRoute === true; - const isInternalWebchatTurn = - normalizedCurrentSurface === INTERNAL_MESSAGE_CHANNEL && - (normalizedSurfaceChannel === INTERNAL_MESSAGE_CHANNEL || !normalizedSurfaceChannel) && - !effectiveExplicitDeliverRoute; - const hasRouteReplyCandidate = Boolean( - !suppressAcpChildUserDelivery && - !isInternalWebchatTurn && - normalizedRouteReplyChannel && - replyRoute.to && - normalizedRouteReplyChannel !== normalizedCurrentSurface && - !state.replyOperationRunState.heartbeat, - ); - const routeReplyRuntime = hasRouteReplyCandidate ? await loadRouteReplyRuntime() : undefined; + const { + currentSurface: normalizedCurrentSurface, + isInternalWebchatTurn, + shouldRouteToOriginating: hasRouteReplyCandidate, + } = resolveReplyRoutingDecision({ + provider: ctx.Provider, + surface: ctx.Surface, + explicitDeliverRoute: effectiveExplicitDeliverRoute, + originatingChannel: replyRoute.channel, + originatingTo: replyRoute.to, + suppressDirectUserDelivery: suppressAcpChildUserDelivery, + isRoutableChannel: Boolean, + }); + const routeReplyRuntime = + hasRouteReplyCandidate && !state.replyOperationRunState.heartbeat + ? await loadRouteReplyRuntime() + : undefined; const { originatingChannel: routeReplyChannel, currentSurface, diff --git a/src/auto-reply/reply/inbound-context.ts b/src/auto-reply/reply/inbound-context.ts index 2772f2b9bbc8..50b3837fe9c7 100644 --- a/src/auto-reply/reply/inbound-context.ts +++ b/src/auto-reply/reply/inbound-context.ts @@ -155,9 +155,7 @@ function finalizeInboundContextImpl>( normalized.BodyForAgent = normalized.agentText; normalized.BodyForCommands = normalized.commandText; - const label = - normalizeOptionalString(normalized.ConversationLabel) ?? - normalizeOptionalString(resolveConversationLabel(normalized)); + const label = resolveConversationLabel(normalized); if (label) { normalized.ConversationLabel = label; } diff --git a/src/channels/message/receipt.ts b/src/channels/message/receipt.ts index 4d3e42d856f6..6e0470d99295 100644 --- a/src/channels/message/receipt.ts +++ b/src/channels/message/receipt.ts @@ -2,7 +2,7 @@ import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { normalizeUniqueStringEntries, - uniqueStrings, + normalizeUniqueTrimmedStringList, } from "@openclaw/normalization-core/string-normalization"; import type { MessageReceipt, @@ -14,9 +14,6 @@ type MessageReceiptInputResult = MessageReceiptSourceResult & { receipt?: MessageReceipt; }; -const normalizeIdentity = (value: string | undefined): string | undefined => - value?.trim() || undefined; - /** Reads reported recipients, including every physical part of an aggregate receipt. */ export function listMessageReceiptSourceTargets(value: unknown): string[] { const targets = new Set(); @@ -64,9 +61,9 @@ export function resolveReceiptSourceId(result: MessageReceiptInputResult): strin return undefined; } return ( - normalizeIdentity(result.messageId) ?? + normalizeOptionalString(result.messageId) ?? (result.receipt ? resolveMessageReceiptPrimaryId(result.receipt) : undefined) ?? - normalizeIdentity(result.pollId) + normalizeOptionalString(result.pollId) ); } @@ -79,21 +76,24 @@ export function createMessageReceiptFromOutboundResults(params: { sentAt?: number; }): MessageReceipt { const sentResults = params.results.filter((result) => result.outcome !== "not_sent"); - const requestedThreadId = normalizeIdentity(params.threadId); + const requestedThreadId = normalizeOptionalString(params.threadId); const providerThreadIds = normalizeUniqueStringEntries( sentResults.flatMap(({ receipt }) => receipt?.parts.length ? receipt.parts.flatMap( - (part) => normalizeIdentity(part.threadId) ?? normalizeIdentity(receipt.threadId) ?? [], + (part) => + normalizeOptionalString(part.threadId) ?? + normalizeOptionalString(receipt.threadId) ?? + [], ) - : (normalizeIdentity(receipt?.threadId) ?? []), + : (normalizeOptionalString(receipt?.threadId) ?? []), ), ); const aggregateThreadId = providerThreadIds.length > 1 ? undefined : (providerThreadIds[0] ?? requestedThreadId); const parts = sentResults.flatMap((result, resultIndex) => { if (result.receipt) { - const receiptThreadId = normalizeIdentity(result.receipt.threadId) ?? requestedThreadId; + const receiptThreadId = normalizeOptionalString(result.receipt.threadId) ?? requestedThreadId; if (result.receipt.parts.length === 0) { return result.receipt.platformMessageIds.map((platformMessageId, partIndex) => ({ platformMessageId, @@ -109,7 +109,7 @@ export function createMessageReceiptFromOutboundResults(params: { return result.receipt.parts.map((part, partIndex) => ({ ...part, index: part.index ?? partIndex, - ...(normalizeIdentity(part.threadId) || !receiptThreadId + ...(normalizeOptionalString(part.threadId) || !receiptThreadId ? {} : { threadId: receiptThreadId }), ...(part.replyToId || !params.replyToId || hasPartReplyMetadata @@ -132,19 +132,16 @@ export function createMessageReceiptFromOutboundResults(params: { }, ]; }); - const platformMessageIds = uniqueStrings( - sentResults - .flatMap((result) => - result.receipt - ? [ - result.receipt.primaryPlatformMessageId, - ...result.receipt.platformMessageIds, - ...result.receipt.parts.map((part) => part.platformMessageId), - ] - : [resolveReceiptSourceId(result)], - ) - .map(normalizeIdentity) - .filter((id): id is string => Boolean(id)), + const platformMessageIds = normalizeUniqueTrimmedStringList( + sentResults.flatMap((result) => + result.receipt + ? [ + result.receipt.primaryPlatformMessageId, + ...result.receipt.platformMessageIds, + ...result.receipt.parts.map((part) => part.platformMessageId), + ] + : [resolveReceiptSourceId(result)], + ), ); const firstNestedReceipt = sentResults.find((result) => result.receipt)?.receipt; return { @@ -167,13 +164,13 @@ export function listMessageReceiptPlatformIds(receipt: MessageReceipt): string[] /** Resolves the explicit primary platform id, falling back to the first unique receipt id. */ export function resolveMessageReceiptPrimaryId(receipt: MessageReceipt): string | undefined { - const primary = normalizeIdentity(receipt.primaryPlatformMessageId); + const primary = normalizeOptionalString(receipt.primaryPlatformMessageId); if (primary) { return primary; } return ( listMessageReceiptPlatformIds(receipt)[0] ?? - receipt.parts.map((part) => normalizeIdentity(part.platformMessageId)).find(Boolean) + receipt.parts.map((part) => normalizeOptionalString(part.platformMessageId)).find(Boolean) ); } @@ -183,12 +180,14 @@ export function resolveMessageReceiptThreadId( requestedThreadId?: string, ): string | undefined { const partThreadIds = normalizeUniqueStringEntries( - receipt.parts.flatMap((part) => normalizeIdentity(part.threadId) ?? []), + receipt.parts.flatMap((part) => normalizeOptionalString(part.threadId) ?? []), ); if (partThreadIds.length > 1) { return undefined; } return ( - partThreadIds[0] ?? normalizeIdentity(receipt.threadId) ?? normalizeIdentity(requestedThreadId) + partThreadIds[0] ?? + normalizeOptionalString(receipt.threadId) ?? + normalizeOptionalString(requestedThreadId) ); } diff --git a/src/plugin-sdk/channel-inbound.ts b/src/plugin-sdk/channel-inbound.ts index 5a705762a9a8..fc9b105beeac 100644 --- a/src/plugin-sdk/channel-inbound.ts +++ b/src/plugin-sdk/channel-inbound.ts @@ -15,7 +15,6 @@ import { type FinalizeChannelInboundContextResult, } from "../channels/inbound-event/context.js"; import type { InboundEventKind } from "../channels/inbound-event/kind.js"; -import "../channels/turn/dispatch-result.js"; import { runPreparedChannelTurn } from "../channels/turn/execution.js"; import { dispatchAssembledChannelTurn, diff --git a/src/plugin-sdk/reply-payload.ts b/src/plugin-sdk/reply-payload.ts index e7494f0a66ee..72dfb126293a 100644 --- a/src/plugin-sdk/reply-payload.ts +++ b/src/plugin-sdk/reply-payload.ts @@ -262,13 +262,11 @@ export async function sendPayloadWithChunkedTextAndMedia< } const limit = params.textChunkLimit; const chunkedText = limit && params.chunker ? params.chunker(text, limit) : [text]; - const chunks = resolveTextChunksWithFallback(text, chunkedText); - let lastResult = params.emptyResult; - for (const chunk of chunks) { - lastResult = await params.sendText({ ...params.ctx, text: chunk }); - await params.onResult?.(lastResult); - } - return lastResult; + return (await sendPayloadTextChunkSequence({ + chunks: resolveTextChunksWithFallback(text, chunkedText), + send: ({ text: chunk }) => params.sendText({ ...params.ctx, text: chunk }), + onResult: (result) => params.onResult?.(result), + }))!; } /** @@ -455,19 +453,18 @@ export async function sendTextMediaPayload(params: { limit && params.adapter.chunker ? params.adapter.chunker(text, limit, { formatting: params.ctx.formatting }) : [text]; - const chunks = resolveTextChunksWithFallback(text, chunkedText); - let lastResult: Awaited>>; - for (const chunk of chunks) { - lastResult = await sendAndReport((onDeliveryResult) => - params.adapter.sendText!({ - ...params.ctx, - text: chunk, - replyToId: nextReplyToId(), - onDeliveryResult, - }), - ); - } - return lastResult!; + return (await sendPayloadTextChunkSequence({ + chunks: resolveTextChunksWithFallback(text, chunkedText), + send: ({ text: chunk }) => + sendAndReport((onDeliveryResult) => + params.adapter.sendText!({ + ...params.ctx, + text: chunk, + replyToId: nextReplyToId(), + onDeliveryResult, + }), + ), + }))!; } /** Detect numeric-looking target ids for channels that distinguish ids from handles. */ diff --git a/ui/src/e2e/board-a2ui.e2e.test.ts b/ui/src/e2e/board-a2ui.e2e.test.ts index 7aed98d0f75c..67252715d7e0 100644 --- a/ui/src/e2e/board-a2ui.e2e.test.ts +++ b/ui/src/e2e/board-a2ui.e2e.test.ts @@ -120,7 +120,8 @@ describeControlUiE2e("Control UI dashboard A2UI", () => { path: path.resolve("extensions/canvas/src/host/a2ui/a2ui.bundle.js"), type: "module", }); - const result = await page.evaluate(() => { + const result = await page.evaluate(async () => { + await customElements.whenDefined("openclaw-a2ui-host"); const emitted: unknown[] = []; Reflect.set(globalThis, "openclaw", { state: {