mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-08 11:02:26 +00:00
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:
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user