fix(config): normalize channel streaming config shape (#61381)

* feat(config): add canonical streaming config helpers

* refactor(runtime): prefer canonical streaming accessors

* feat(config): normalize preview channel streaming shape

* test(config): lock streaming normalization followups

* fix(config): polish streaming migration edges

* chore(config): refresh streaming baseline hash
This commit is contained in:
Vincent Koc
2026-04-06 05:08:20 +01:00
committed by GitHub
parent 93ddcb37de
commit 0fdf9e874b
48 changed files with 3012 additions and 705 deletions

View File

@@ -425,3 +425,672 @@ describe("config paths", () => {
expect(getConfigValueAtPath(root, parsed.path)).toBeUndefined();
});
});
describe("config strict validation", () => {
it("rejects unknown fields", async () => {
const res = validateConfigObject({
agents: { list: [{ id: "pi" }] },
customUnknownField: { nested: "value" },
});
expect(res.ok).toBe(false);
});
it("accepts documented agents.list[].params overrides", () => {
const res = validateConfigObject({
agents: {
list: [
{
id: "main",
model: "anthropic/claude-opus-4-6",
params: {
cacheRetention: "none",
temperature: 0.4,
maxTokens: 8192,
},
},
],
},
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.agents?.list?.[0]?.params).toEqual({
cacheRetention: "none",
temperature: 0.4,
maxTokens: 8192,
});
}
});
it("accepts top-level memorySearch via auto-migration and reports legacyIssues", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
memorySearch: {
provider: "local",
fallback: "none",
query: { maxResults: 7 },
},
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "memorySearch")).toBe(true);
expect(snap.sourceConfig.agents?.defaults?.memorySearch).toMatchObject({
provider: "local",
fallback: "none",
query: { maxResults: 7 },
});
expect((snap.sourceConfig as { memorySearch?: unknown }).memorySearch).toBeUndefined();
});
});
it("accepts top-level heartbeat agent settings via auto-migration and reports legacyIssues", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
heartbeat: {
every: "30m",
model: "anthropic/claude-3-5-haiku-20241022",
},
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "heartbeat")).toBe(true);
expect(snap.sourceConfig.agents?.defaults?.heartbeat).toMatchObject({
every: "30m",
model: "anthropic/claude-3-5-haiku-20241022",
});
expect((snap.sourceConfig as { heartbeat?: unknown }).heartbeat).toBeUndefined();
});
});
it("accepts top-level heartbeat visibility via auto-migration and reports legacyIssues", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
heartbeat: {
showOk: true,
showAlerts: false,
useIndicator: true,
},
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "heartbeat")).toBe(true);
expect(snap.sourceConfig.channels?.defaults?.heartbeat).toMatchObject({
showOk: true,
showAlerts: false,
useIndicator: true,
});
expect((snap.sourceConfig as { heartbeat?: unknown }).heartbeat).toBeUndefined();
});
});
it("accepts legacy messages.tts provider keys via auto-migration and reports legacyIssues", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
messages: {
tts: {
provider: "elevenlabs",
elevenlabs: {
apiKey: "test-key",
voiceId: "voice-1",
},
},
},
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "messages.tts")).toBe(true);
expect(snap.sourceConfig.messages?.tts?.providers?.elevenlabs).toEqual({
apiKey: "test-key",
voiceId: "voice-1",
});
expect(
(snap.sourceConfig.messages?.tts as Record<string, unknown> | undefined)?.elevenlabs,
).toBeUndefined();
});
});
it("accepts legacy talk flat fields via auto-migration and reports legacyIssues", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
talk: {
voiceId: "voice-1",
modelId: "eleven_v3",
apiKey: "test-key",
},
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "talk")).toBe(true);
expect(snap.sourceConfig.talk?.providers?.elevenlabs).toEqual({
voiceId: "voice-1",
modelId: "eleven_v3",
apiKey: "test-key",
});
expect(
(snap.sourceConfig.talk as Record<string, unknown> | undefined)?.voiceId,
).toBeUndefined();
expect(
(snap.sourceConfig.talk as Record<string, unknown> | undefined)?.modelId,
).toBeUndefined();
expect(
(snap.sourceConfig.talk as Record<string, unknown> | undefined)?.apiKey,
).toBeUndefined();
});
});
it("accepts legacy sandbox perSession via auto-migration and reports legacyIssues", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
agents: {
defaults: {
sandbox: {
perSession: true,
},
},
list: [
{
id: "pi",
sandbox: {
perSession: false,
},
},
],
},
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "agents.defaults.sandbox")).toBe(
true,
);
expect(snap.legacyIssues.some((issue) => issue.path === "agents.list")).toBe(true);
expect(snap.sourceConfig.agents?.defaults?.sandbox).toEqual({
scope: "session",
});
expect(snap.sourceConfig.agents?.list?.[0]?.sandbox).toEqual({
scope: "shared",
});
});
});
it("accepts legacy x_search auth via auto-migration and reports legacyIssues", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
tools: {
web: {
x_search: {
apiKey: "test-key",
},
},
},
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "tools.web.x_search.apiKey")).toBe(
true,
);
expect(snap.sourceConfig.plugins?.entries?.xai?.config?.webSearch).toMatchObject({
apiKey: "test-key",
});
expect(
(snap.sourceConfig.tools?.web?.x_search as Record<string, unknown> | undefined)?.apiKey,
).toBeUndefined();
});
});
it("accepts legacy thread binding ttlHours via auto-migration and reports legacyIssues", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
session: {
threadBindings: {
ttlHours: 24,
},
},
channels: {
discord: {
threadBindings: {
ttlHours: 12,
},
accounts: {
alpha: {
threadBindings: {
ttlHours: 6,
},
},
},
},
},
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "session.threadBindings")).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "channels")).toBe(true);
expect(snap.sourceConfig.session?.threadBindings).toMatchObject({
idleHours: 24,
});
expect(snap.sourceConfig.channels?.discord?.threadBindings).toMatchObject({
idleHours: 12,
});
expect(snap.sourceConfig.channels?.discord?.accounts?.alpha?.threadBindings).toMatchObject({
idleHours: 6,
});
expect(
(snap.sourceConfig.session?.threadBindings as Record<string, unknown> | undefined)
?.ttlHours,
).toBeUndefined();
expect(
(snap.sourceConfig.channels?.discord?.threadBindings as Record<string, unknown> | undefined)
?.ttlHours,
).toBeUndefined();
expect(
(
snap.sourceConfig.channels?.discord?.accounts?.alpha?.threadBindings as
| Record<string, unknown>
| undefined
)?.ttlHours,
).toBeUndefined();
});
});
it("accepts legacy channel streaming aliases via auto-migration and reports legacyIssues", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
channels: {
telegram: {
streamMode: "block",
chunkMode: "newline",
blockStreaming: true,
draftChunk: {
minChars: 120,
},
},
discord: {
streaming: false,
blockStreamingCoalesce: {
idleMs: 250,
},
accounts: {
work: {
streamMode: "block",
draftChunk: {
maxChars: 900,
},
},
},
},
googlechat: {
streamMode: "append",
accounts: {
work: {
streamMode: "replace",
},
},
},
slack: {
streaming: true,
nativeStreaming: false,
},
},
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "channels.telegram")).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "channels.discord")).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "channels.discord.accounts")).toBe(
true,
);
expect(snap.legacyIssues.some((issue) => issue.path === "channels.googlechat")).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "channels.googlechat.accounts")).toBe(
true,
);
expect(snap.legacyIssues.some((issue) => issue.path === "channels.slack")).toBe(true);
expect(snap.sourceConfig.channels?.telegram).toMatchObject({
streaming: {
mode: "block",
chunkMode: "newline",
block: {
enabled: true,
},
preview: {
chunk: {
minChars: 120,
},
},
},
});
expect(
(snap.sourceConfig.channels?.telegram as Record<string, unknown> | undefined)?.streamMode,
).toBeUndefined();
expect(snap.sourceConfig.channels?.discord).toMatchObject({
streaming: {
mode: "off",
block: {
coalesce: {
idleMs: 250,
},
},
},
});
expect(snap.sourceConfig.channels?.discord?.accounts?.work).toMatchObject({
streaming: {
mode: "block",
preview: {
chunk: {
maxChars: 900,
},
},
},
});
expect(
(snap.sourceConfig.channels?.googlechat as Record<string, unknown> | undefined)?.streamMode,
).toBeUndefined();
expect(
(
snap.sourceConfig.channels?.googlechat?.accounts?.work as
| Record<string, unknown>
| undefined
)?.streamMode,
).toBeUndefined();
expect(snap.sourceConfig.channels?.slack).toMatchObject({
streaming: {
mode: "partial",
nativeTransport: false,
},
});
});
});
it("accepts legacy nested channel allow aliases via auto-migration and reports legacyIssues", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
channels: {
slack: {
channels: {
ops: {
allow: false,
},
},
accounts: {
work: {
channels: {
general: {
allow: true,
},
},
},
},
},
googlechat: {
groups: {
"spaces/aaa": {
allow: false,
},
},
accounts: {
work: {
groups: {
"spaces/bbb": {
allow: true,
},
},
},
},
},
discord: {
guilds: {
"100": {
channels: {
general: {
allow: false,
},
},
},
},
accounts: {
work: {
guilds: {
"200": {
channels: {
help: {
allow: true,
},
},
},
},
},
},
},
},
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "channels.slack")).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "channels.slack.accounts")).toBe(
true,
);
expect(snap.legacyIssues.some((issue) => issue.path === "channels.googlechat")).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "channels.googlechat.accounts")).toBe(
true,
);
expect(snap.legacyIssues.some((issue) => issue.path === "channels.discord")).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "channels.discord.accounts")).toBe(
true,
);
expect(snap.sourceConfig.channels?.slack?.channels?.ops).toMatchObject({
enabled: false,
});
expect(snap.sourceConfig.channels?.googlechat?.groups?.["spaces/aaa"]).toMatchObject({
enabled: false,
});
expect(snap.sourceConfig.channels?.discord?.guilds?.["100"]?.channels?.general).toMatchObject(
{
enabled: false,
},
);
expect(
(snap.sourceConfig.channels?.slack?.channels?.ops as Record<string, unknown> | undefined)
?.allow,
).toBeUndefined();
expect(
(
snap.sourceConfig.channels?.googlechat?.groups?.["spaces/aaa"] as
| Record<string, unknown>
| undefined
)?.allow,
).toBeUndefined();
expect(
(
snap.sourceConfig.channels?.discord?.guilds?.["100"]?.channels?.general as
| Record<string, unknown>
| undefined
)?.allow,
).toBeUndefined();
});
});
it("accepts telegram groupMentionsOnly via auto-migration and reports legacyIssues", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
channels: {
telegram: {
groupMentionsOnly: true,
},
},
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(
snap.legacyIssues.some((issue) => issue.path === "channels.telegram.groupMentionsOnly"),
).toBe(true);
expect(snap.sourceConfig.channels?.telegram?.groups?.["*"]).toMatchObject({
requireMention: true,
});
expect(
(snap.sourceConfig.channels?.telegram as Record<string, unknown> | undefined)
?.groupMentionsOnly,
).toBeUndefined();
});
});
it("accepts legacy plugins.entries.*.config.tts provider keys via auto-migration", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
plugins: {
entries: {
"voice-call": {
config: {
tts: {
provider: "openai",
openai: {
model: "gpt-4o-mini-tts",
voice: "alloy",
},
},
},
},
},
},
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "plugins.entries")).toBe(true);
const voiceCallTts = (
snap.sourceConfig.plugins?.entries as
| Record<
string,
{
config?: {
tts?: {
providers?: Record<string, unknown>;
openai?: unknown;
};
};
}
>
| undefined
)?.["voice-call"]?.config?.tts;
expect(voiceCallTts?.providers?.openai).toEqual({
model: "gpt-4o-mini-tts",
voice: "alloy",
});
expect(voiceCallTts?.openai).toBeUndefined();
});
});
it("accepts legacy discord voice tts provider keys via auto-migration and reports legacyIssues", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
channels: {
discord: {
voice: {
tts: {
provider: "elevenlabs",
elevenlabs: {
voiceId: "voice-1",
},
},
},
accounts: {
main: {
voice: {
tts: {
edge: {
voice: "en-US-AvaNeural",
},
},
},
},
},
},
},
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "channels.discord.voice.tts")).toBe(
true,
);
expect(snap.legacyIssues.some((issue) => issue.path === "channels.discord.accounts")).toBe(
true,
);
expect(snap.sourceConfig.channels?.discord?.voice?.tts?.providers?.elevenlabs).toEqual({
voiceId: "voice-1",
});
expect(
snap.sourceConfig.channels?.discord?.accounts?.main?.voice?.tts?.providers?.microsoft,
).toEqual({
voice: "en-US-AvaNeural",
});
expect(
(snap.sourceConfig.channels?.discord?.voice?.tts as Record<string, unknown> | undefined)
?.elevenlabs,
).toBeUndefined();
expect(
(
snap.sourceConfig.channels?.discord?.accounts?.main?.voice?.tts as
| Record<string, unknown>
| undefined
)?.edge,
).toBeUndefined();
});
});
it("does not treat resolved-only gateway.bind aliases as source-literal legacy or invalid", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
gateway: { bind: "${OPENCLAW_BIND}" },
});
const prev = process.env.OPENCLAW_BIND;
process.env.OPENCLAW_BIND = "0.0.0.0";
try {
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues).toHaveLength(0);
expect(snap.issues).toHaveLength(0);
} finally {
if (prev === undefined) {
delete process.env.OPENCLAW_BIND;
} else {
process.env.OPENCLAW_BIND = prev;
}
}
});
});
it("still marks literal gateway.bind host aliases as legacy", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
gateway: { bind: "0.0.0.0" },
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "gateway.bind")).toBe(true);
});
});
});