fix(imessage): honor block streaming config (#91449)

Merged via squash.

Prepared head SHA: 6e4e04fb2d
Co-authored-by: jmissig <1448107+jmissig@users.noreply.github.com>
Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com>
Reviewed-by: @omarshahine
This commit is contained in:
Julian Missig
2026-06-08 12:07:03 -07:00
committed by GitHub
parent d4c6662341
commit a7847ac484
10 changed files with 504 additions and 38 deletions

View File

@@ -6,6 +6,7 @@ import {
resolveMergedAccountConfig,
type OpenClawConfig,
} from "openclaw/plugin-sdk/account-resolution";
import { resolveAccountEntry } from "openclaw/plugin-sdk/routing";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { IMessageAccountConfig } from "./account-types.js";
@@ -25,14 +26,95 @@ const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("im
export const listIMessageAccountIds = listAccountIds;
export const resolveDefaultIMessageAccountId = resolveDefaultAccountId;
function resolveIMessageAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): IMessageAccountConfig | undefined {
return resolveAccountEntry(cfg.channels?.imessage?.accounts, accountId);
}
type IMessageStreamingConfig = NonNullable<IMessageAccountConfig["streaming"]>;
function asStreamingConfigObject(value: unknown): IMessageStreamingConfig | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as IMessageStreamingConfig)
: undefined;
}
function asOwnBooleanProperty(value: unknown, key: string): boolean | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const record = value as Record<string, unknown>;
return Object.hasOwn(record, key) && typeof record[key] === "boolean" ? record[key] : undefined;
}
function mergeIMessageStreamingConfig(
base: unknown,
account: unknown,
accountFlatBlockStreaming: unknown,
): IMessageStreamingConfig | undefined {
const baseConfig = asStreamingConfigObject(base);
const accountConfig = asStreamingConfigObject(account);
const accountBlockEnabled = asOwnBooleanProperty(accountConfig?.block, "enabled");
const flatAccountBlockEnabled =
accountBlockEnabled === undefined && typeof accountFlatBlockStreaming === "boolean"
? accountFlatBlockStreaming
: undefined;
const applyFlatAccountBlockEnabled = (
config: IMessageStreamingConfig | undefined,
): IMessageStreamingConfig | undefined => {
if (flatAccountBlockEnabled === undefined || config === undefined) {
return config;
}
return {
...config,
block: {
...config.block,
enabled: flatAccountBlockEnabled,
},
};
};
if (!baseConfig || !accountConfig) {
return applyFlatAccountBlockEnabled(accountConfig ?? baseConfig);
}
return applyFlatAccountBlockEnabled({
...baseConfig,
...accountConfig,
...(baseConfig.block || accountConfig.block
? {
block: {
...baseConfig.block,
...accountConfig.block,
...(baseConfig.block?.coalesce || accountConfig.block?.coalesce
? {
coalesce: {
...baseConfig.block?.coalesce,
...accountConfig.block?.coalesce,
},
}
: {}),
},
}
: {}),
});
}
function mergeIMessageAccountConfig(cfg: OpenClawConfig, accountId: string): IMessageAccountConfig {
return resolveMergedAccountConfig<IMessageAccountConfig>({
const accountConfig = resolveIMessageAccountConfig(cfg, accountId);
const merged = resolveMergedAccountConfig<IMessageAccountConfig>({
channelConfig: cfg.channels?.imessage as IMessageAccountConfig | undefined,
accounts: cfg.channels?.imessage?.accounts as
| Record<string, Partial<IMessageAccountConfig>>
| undefined,
accountId,
});
const streaming = mergeIMessageStreamingConfig(
(cfg.channels?.imessage as Record<string, unknown> | undefined)?.streaming,
(accountConfig as Record<string, unknown> | undefined)?.streaming,
(accountConfig as Record<string, unknown> | undefined)?.blockStreaming,
);
return streaming !== undefined ? ({ ...merged, streaming } as IMessageAccountConfig) : merged;
}
export function resolveIMessageAccount(params: {

View File

@@ -72,6 +72,31 @@ describe("imessage config schema", () => {
}
});
it("accepts nested delivery streaming config", () => {
const res = IMessageConfigSchema.safeParse({
enabled: true,
streaming: {
chunkMode: "newline",
block: {
enabled: true,
coalesce: { minChars: 200, idleMs: 50 },
},
},
accounts: {
personal: {
streaming: { chunkMode: "length", block: { enabled: false } },
},
},
});
expect(res.success).toBe(true);
if (res.success) {
expect(res.data.streaming?.chunkMode).toBe("newline");
expect(res.data.streaming?.block?.enabled).toBe(true);
expect(res.data.accounts?.personal?.streaming?.block?.enabled).toBe(false);
}
});
it("accepts reaction notification mode overrides", () => {
const res = IMessageConfigSchema.safeParse({
reactionNotifications: "all",

View File

@@ -2,7 +2,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime";
import type { GetReplyOptions, MsgContext } from "openclaw/plugin-sdk/reply-runtime";
import type { waitForTransportReady } from "openclaw/plugin-sdk/transport-ready-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { createIMessageRpcClient } from "./client.js";
@@ -19,21 +19,7 @@ import { installIMessageStateRuntimeForTest } from "./test-support/runtime.js";
type DispatchInboundMessageParams = {
ctx: MsgContext;
replyOptions?: {
suppressDefaultToolProgressMessages?: boolean;
allowProgressCallbacksWhenSourceDeliverySuppressed?: boolean;
onReplyStart?: () => Promise<void> | void;
onTypingCleanup?: () => void;
onTypingController?: (typing: {
startTypingLoop: () => Promise<void>;
refreshTypingTtl: () => void;
isActive: () => boolean;
markRunComplete: () => void;
markDispatchIdle: () => void;
cleanup: () => void;
}) => void;
onToolStart?: (payload: { name?: string; phase?: string }) => Promise<void> | void;
};
replyOptions?: GetReplyOptions;
};
const waitForTransportReadyMock = vi.hoisted(() =>
@@ -154,10 +140,14 @@ describe("iMessage monitor last-route updates", () => {
}
};
const typingController = {
onReplyStart: async () => {
await params.replyOptions?.onReplyStart?.();
},
startTypingLoop: async () => {
active = true;
await params.replyOptions?.onReplyStart?.();
},
startTypingOnText: async () => {},
refreshTypingTtl: () => {},
isActive: () => active,
markRunComplete: () => {
@@ -413,6 +403,260 @@ describe("iMessage monitor last-route updates", () => {
);
});
it.each([
{
label: "flat true",
imessagePatch: { blockStreaming: true },
expectedDisable: false,
},
{
label: "flat false",
imessagePatch: { blockStreaming: false },
expectedDisable: true,
},
{
label: "nested true",
imessagePatch: { streaming: { block: { enabled: true } } },
expectedDisable: false,
},
{
label: "nested false",
imessagePatch: { streaming: { block: { enabled: false } } },
expectedDisable: true,
},
{ label: "unset", imessagePatch: {}, expectedDisable: undefined },
] as const)(
"passes iMessage block streaming config ($label) through to reply dispatch",
async ({ imessagePatch, expectedDisable }) => {
dispatchInboundMessageMock.mockImplementationOnce(async (params) => {
expect(params.replyOptions?.disableBlockStreaming).toBe(expectedDisable);
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } } as const;
});
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
const client = {
request: vi.fn(async (method: string) => {
if (method === "watch.subscribe") {
return { subscription: 1 };
}
throw new Error(`unexpected imsg method ${method}`);
}),
waitForClose: vi.fn(async () => {
onNotification?.({
method: "message",
params: {
message: {
id: 10,
chat_id: 123,
sender: "+15550001111",
is_from_me: false,
text: "stream blocks before the final",
is_group: false,
created_at: new Date().toISOString(),
},
},
});
await Promise.resolve();
await Promise.resolve();
}),
stop: vi.fn(async () => {}),
};
createIMessageRpcClientMock.mockImplementation(async (params) => {
if (!params?.onNotification) {
throw new Error("expected iMessage notification handler");
}
onNotification = params.onNotification;
return client as never;
});
await monitorIMessageProvider({
config: {
channels: {
imessage: {
dmPolicy: "allowlist",
allowFrom: ["+15550001111"],
sendReadReceipts: false,
...imessagePatch,
},
},
messages: { inbound: { debounceMs: 0 } },
session: { mainKey: "main" },
} as never,
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
});
await vi.waitFor(() => {
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
});
},
);
it.each([
{
label: "flat false overrides channel nested true",
channelBlockEnabled: true,
accountBlockStreaming: false,
expectedDisable: true,
},
{
label: "flat true overrides channel nested false",
channelBlockEnabled: false,
accountBlockStreaming: true,
expectedDisable: false,
},
] as const)(
"preserves account-level block streaming opt-outs when inheriting channel streaming ($label)",
async ({ channelBlockEnabled, accountBlockStreaming, expectedDisable }) => {
dispatchInboundMessageMock.mockImplementationOnce(async (params) => {
expect(params.replyOptions?.disableBlockStreaming).toBe(expectedDisable);
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } } as const;
});
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
const client = {
request: vi.fn(async (method: string) => {
if (method === "watch.subscribe") {
return { subscription: 1 };
}
throw new Error(`unexpected imsg method ${method}`);
}),
waitForClose: vi.fn(async () => {
onNotification?.({
method: "message",
params: {
message: {
id: 11,
chat_id: 123,
sender: "+15550001111",
is_from_me: false,
text: "stream blocks before the final",
is_group: false,
created_at: new Date().toISOString(),
},
},
});
await Promise.resolve();
await Promise.resolve();
}),
stop: vi.fn(async () => {}),
};
createIMessageRpcClientMock.mockImplementation(async (params) => {
if (!params?.onNotification) {
throw new Error("expected iMessage notification handler");
}
onNotification = params.onNotification;
return client as never;
});
await monitorIMessageProvider({
accountId: "personal",
config: {
channels: {
imessage: {
dmPolicy: "allowlist",
allowFrom: ["+15550001111"],
sendReadReceipts: false,
streaming: { block: { enabled: channelBlockEnabled } },
accounts: {
personal: {
blockStreaming: accountBlockStreaming,
},
},
},
},
messages: { inbound: { debounceMs: 0 } },
session: { mainKey: "main" },
} as never,
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
});
await vi.waitFor(() => {
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
});
},
);
it.each([
{
label: "chunkMode",
accountStreaming: { chunkMode: "length" },
},
{
label: "block coalesce",
accountStreaming: { block: { coalesce: { idleMs: 1 } } },
},
] as const)(
"preserves channel-level nested block streaming when an account overrides $label",
async ({ accountStreaming }) => {
dispatchInboundMessageMock.mockImplementationOnce(async (params) => {
expect(params.replyOptions?.disableBlockStreaming).toBe(false);
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } } as const;
});
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
const client = {
request: vi.fn(async (method: string) => {
if (method === "watch.subscribe") {
return { subscription: 1 };
}
throw new Error(`unexpected imsg method ${method}`);
}),
waitForClose: vi.fn(async () => {
onNotification?.({
method: "message",
params: {
message: {
id: 11,
chat_id: 123,
sender: "+15550001111",
is_from_me: false,
text: "stream blocks before the final",
is_group: false,
created_at: new Date().toISOString(),
},
},
});
await Promise.resolve();
await Promise.resolve();
}),
stop: vi.fn(async () => {}),
};
createIMessageRpcClientMock.mockImplementation(async (params) => {
if (!params?.onNotification) {
throw new Error("expected iMessage notification handler");
}
onNotification = params.onNotification;
return client as never;
});
await monitorIMessageProvider({
accountId: "personal",
config: {
channels: {
imessage: {
dmPolicy: "allowlist",
allowFrom: ["+15550001111"],
sendReadReceipts: false,
streaming: { block: { enabled: true } },
accounts: {
personal: {
streaming: accountStreaming,
},
},
},
},
messages: { inbound: { debounceMs: 0 } },
session: { mainKey: "main" },
} as never,
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
});
await vi.waitFor(() => {
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
});
},
);
it("keeps per-channel-peer direct-message last-route writes on the isolated session", async () => {
const runtimeErrorMock = vi.fn();
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;

View File

@@ -14,6 +14,7 @@ import {
import {
deliverInboundReplyWithMessageSendContext,
createChannelMessageReplyPipeline,
resolveChannelStreamingBlockEnabled,
} from "openclaw/plugin-sdk/channel-outbound";
import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing";
import { registerChannelRuntimeContext } from "openclaw/plugin-sdk/channel-runtime-context";
@@ -1132,6 +1133,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
},
} as const)
: {};
const configuredBlockStreaming = resolveChannelStreamingBlockEnabled(accountInfo.config);
const inboundLastRouteSessionKey = resolveInboundLastRouteSessionKey({
route: decision.route,
sessionKey: decision.route.sessionKey,
@@ -1205,8 +1207,8 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
replyOptions: {
...typingReplyOptions,
disableBlockStreaming:
typeof accountInfo.config.blockStreaming === "boolean"
? !accountInfo.config.blockStreaming
typeof configuredBlockStreaming === "boolean"
? !configuredBlockStreaming
: undefined,
onModelSelected,
...directToolTypingOptions,

View File

@@ -618,6 +618,25 @@ describe("resolveChunkMode", () => {
{ cfg: providerCfg, provider: "discord", accountId: undefined, expected: "length" },
{ cfg: accountCfg, provider: "slack", accountId: "primary", expected: "newline" },
{ cfg: accountCfg, provider: "slack", accountId: "other", expected: "length" },
{
cfg: { channels: { imessage: { streaming: { chunkMode: "newline" as const } } } },
provider: "imessage",
accountId: undefined,
expected: "newline",
},
{
cfg: {
channels: {
imessage: {
streaming: { chunkMode: "length" as const },
accounts: { personal: { streaming: { chunkMode: "newline" as const } } },
},
},
},
provider: "imessage",
accountId: "personal",
expected: "newline",
},
{
cfg: { channels: { webchat: { chunkMode: "newline" as const } } },
provider: "webchat",

View File

@@ -73,6 +73,78 @@ describe("resolveEffectiveBlockStreamingConfig", () => {
expect(resolved.coalescing.joiner).toBe("\n\n");
});
it("honors channel and account scoped nested block coalescing", () => {
const cfg = {
channels: {
imessage: {
streaming: { block: { coalesce: { minChars: 25, maxChars: 80, idleMs: 5 } } },
accounts: {
personal: {
streaming: { block: { coalesce: { minChars: 10, maxChars: 40, idleMs: 2 } } },
},
},
},
},
} as OpenClawConfig;
expect(
resolveEffectiveBlockStreamingConfig({ cfg, provider: "imessage" }).coalescing,
).toMatchObject({ minChars: 25, maxChars: 80, idleMs: 5 });
expect(
resolveEffectiveBlockStreamingConfig({
cfg,
provider: "imessage",
accountId: "personal",
}).coalescing,
).toMatchObject({ minChars: 10, maxChars: 40, idleMs: 2 });
});
it("merges partial account nested block coalescing over channel config", () => {
const cfg = {
channels: {
imessage: {
streaming: { block: { coalesce: { minChars: 25, maxChars: 80, idleMs: 5 } } },
accounts: {
personal: {
streaming: { block: { coalesce: { idleMs: 2 } } },
},
},
},
},
} as OpenClawConfig;
expect(
resolveEffectiveBlockStreamingConfig({
cfg,
provider: "imessage",
accountId: "personal",
}).coalescing,
).toMatchObject({ minChars: 25, maxChars: 80, idleMs: 2 });
});
it("merges legacy account block coalescing over channel nested config", () => {
const cfg = {
channels: {
imessage: {
streaming: { block: { coalesce: { minChars: 25, maxChars: 80, idleMs: 5 } } },
accounts: {
personal: {
blockStreamingCoalesce: { idleMs: 2 },
},
},
},
},
} as OpenClawConfig;
expect(
resolveEffectiveBlockStreamingConfig({
cfg,
provider: "imessage",
accountId: "personal",
}).coalescing,
).toMatchObject({ minChars: 25, maxChars: 80, idleMs: 2 });
});
it("allows ACP maxChunkChars overrides above base defaults up to provider text limits", () => {
const cfg = {
channels: {

View File

@@ -39,6 +39,14 @@ type ProviderBlockStreamingConfig = {
>;
};
function resolveScopedBlockStreamingCoalesce(
config: ProviderBlockStreamingConfig | undefined,
): BlockStreamingCoalesceConfig | undefined {
return config
? (resolveChannelStreamingBlockCoalesce(config) ?? config.blockStreamingCoalesce)
: undefined;
}
function resolveProviderBlockStreamingCoalesce(params: {
cfg: OpenClawConfig | undefined;
providerKey?: TextChunkProvider;
@@ -48,19 +56,21 @@ function resolveProviderBlockStreamingCoalesce(params: {
if (!cfg || !providerKey) {
return undefined;
}
const providerCfg = (cfg as Record<string, unknown>)[providerKey];
const channelsConfig = cfg.channels as Record<string, unknown> | undefined;
const providerCfg =
channelsConfig?.[providerKey] ?? (cfg as Record<string, unknown>)[providerKey];
if (!providerCfg || typeof providerCfg !== "object") {
return undefined;
}
const normalizedAccountId = normalizeAccountId(accountId);
const typed = providerCfg as ProviderBlockStreamingConfig;
const accountCfg = resolveAccountEntry(typed.accounts, normalizedAccountId);
return (
resolveChannelStreamingBlockCoalesce(accountCfg) ??
resolveChannelStreamingBlockCoalesce(typed) ??
accountCfg?.blockStreamingCoalesce ??
typed.blockStreamingCoalesce
);
const channelCoalesce = resolveScopedBlockStreamingCoalesce(typed);
const accountCoalesce = resolveScopedBlockStreamingCoalesce(accountCfg);
if (channelCoalesce || accountCoalesce) {
return { ...channelCoalesce, ...accountCoalesce };
}
return undefined;
}
export type BlockStreamingCoalescing = {

File diff suppressed because one or more lines are too long

View File

@@ -4,6 +4,7 @@
*/
import type {
BlockStreamingCoalesceConfig,
ChannelDeliveryStreamingConfig,
ContextVisibilityMode,
DmPolicy,
GroupPolicy,
@@ -95,6 +96,8 @@ export type IMessageAccountConfig = {
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
/** Structured streaming + chunking settings. */
streaming?: ChannelDeliveryStreamingConfig;
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;

View File

@@ -108,6 +108,13 @@ const ChannelStreamingProgressSchema = z
const SlackStreamingProgressSchema = ChannelStreamingProgressSchema.extend({
nativeTaskCards: z.boolean().optional(),
}).strict();
const ChannelDeliveryStreamingConfigSchema = z
.object({
chunkMode: TextChunkModeSchema.optional(),
block: ChannelStreamingBlockSchema.optional(),
})
.strict();
const ChannelPreviewStreamingConfigSchema = z
.object({
mode: UnifiedStreamingModeSchema.optional(),
@@ -1410,6 +1417,7 @@ export const IMessageAccountSchemaBase = z
probeTimeoutMs: z.number().int().positive().optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
streaming: ChannelDeliveryStreamingConfigSchema.optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
sendReadReceipts: z.boolean().optional(),